This commit is contained in:
Kalle 2026-08-03 10:50:08 +03:00
parent 692794d3e4
commit edbf55239e
11 changed files with 41 additions and 28 deletions

View File

@ -47,8 +47,7 @@
## Search params
- all URL search param handling goes through `app/modules/search-params/`, see [search-params.md](./docs/dev/search-params.md) for the conventions
// xxx: biome plugin to check for that?
- never use raw `useSearchParams` or `searchParams.get()`; declare params once per feature in a `<feature>-search-params.ts` definition (every param has a default, decode never fails)
- never use raw `useSearchParams` or `searchParams.get()`; declare params once per feature in a `<feature>-search-params.ts` definition (every param has a default, decode never fails). Enforced by the `no-raw-search-params` Biome plugin
- every definition gets a round-trip test via `assertRoundTrips`
## Styling

View File

@ -23,6 +23,7 @@ import { authSessionStorage } from "./session.server";
import { getUser } from "./user.server";
export const callbackLoader: LoaderFunction = async ({ request, url }) => {
// biome-ignore lint/plugin: OAuth callback param, its name and values defined by the provider
if (url.searchParams.get("error") === "access_denied") {
// The user denied the authentication request
// https://www.oauth.com/oauth2-servers/server-side-apps/possible-errors/
@ -78,6 +79,7 @@ export const impersonateAction: ActionFunction = async ({ request, url }) => {
}
if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) {
// biome-ignore lint/plugin: a missing or malformed `id` must 400, not fall back to a default
const targetId = Number(url.searchParams.get("id"));
if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {
throw new Response("Forbidden", { status: 403 });
@ -93,9 +95,11 @@ export const impersonateAction: ActionFunction = async ({ request, url }) => {
const realUserId = session.get(SESSION_KEY);
// biome-ignore-start lint/plugin: a missing or malformed `id` must 400, not fall back to a default
const rawId = url.searchParams.get("id");
const userId = Number(url.searchParams.get("id"));
// biome-ignore-end lint/plugin: a missing or malformed `id` must 400, not fall back to a default
if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 });
logger.info(

View File

@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import * as SearchParams from "~/modules/search-params/search-params";
import { describe, it } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
@ -19,14 +18,6 @@ describe("mapListGeneratorSearchParams", () => {
});
});
it("decodes the legacy bare readonly form", () => {
expect(
SearchParams.decodeParam(mapListGeneratorSearchParams.shape.readonly, [
"",
]),
).toBe(true);
});
it("decodes garbage to defaults", () => {
assertDecodesToDefault(mapListGeneratorSearchParams, "eventId", [
["abc"],

View File

@ -147,6 +147,7 @@ function extractSerializedPool(input: string): string | null {
if (trimmed.includes("://")) {
try {
const url = new URL(trimmed);
// biome-ignore lint/plugin: URL pasted by the user, not one this app routed to
return url.searchParams.get("pool");
} catch {
return null;

View File

@ -4,7 +4,6 @@ import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { compressToBase64 } from "~/utils/compression";
import { DEFAULT_TIERS } from "./tier-list-maker-constants";
import type { TierListState } from "./tier-list-maker-schemas";
import { tierListMakerSearchParams } from "./tier-list-maker-search-params";
@ -48,20 +47,6 @@ describe("tierListMakerSearchParams", () => {
expect(encoded[0]).toMatch(/^lz~/);
});
it("decodes the legacy signature-less base64 state format", () => {
const legacy = compressToBase64(
JSON.stringify({
tiers: FILLED_STATE.tiers,
tierItems: Array.from(FILLED_STATE.tierItems.entries()),
}),
{ urlSafe: true },
);
expect(
SearchParams.decodeParam(tierListMakerSearchParams.shape.state, [legacy]),
).toEqual(FILLED_STATE);
});
it("decodes the legacy JSON modes format", () => {
expect(
SearchParams.decodeParam(tierListMakerSearchParams.shape.modes, [

View File

@ -54,6 +54,7 @@ function renderPage() {
{
path: "/search",
loader: ({ request }: LoaderFunctionArgs) => {
// biome-ignore lint/plugin: stub loader standing in for the real search route, reading the request the component built
const query = new URL(request.url).searchParams.get("q") ?? "";
return {
query,

View File

@ -98,6 +98,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const json = args.json as Record<string, unknown> | undefined;
if (json?.revalidateRoot === true) return true;
// biome-ignore lint/plugin: presence check only, before any route's definition has parsed the URL
if (args.nextUrl.searchParams.has("lng")) return true;
return false;
@ -270,6 +271,7 @@ function useExternalAwareHref(href: string) {
}
function useTriggerToasts() {
// biome-ignore lint/plugin: app-wide toast params written by server redirects, belonging to no one feature
const [searchParams] = useSearchParams();
const navigate = useNavigate();

View File

@ -81,9 +81,11 @@ describe("paginate()", () => {
expect(location).not.toBeNull();
const locationUrl = new URL(location!, "https://sendou.ink");
expect(locationUrl.pathname).toBe("/vods");
// biome-ignore-start lint/plugin: asserting on the raw redirect URL is the point of the test
expect(locationUrl.searchParams.get("page")).toBe("3");
expect(locationUrl.searchParams.get("type")).toBe("TOURNAMENT");
expect(locationUrl.searchParams.get("mode")).toBe("SZ");
// biome-ignore-end lint/plugin: asserting on the raw redirect URL is the point of the test
});
it("stays on page 1 when there are no results", () => {

View File

@ -0,0 +1,18 @@
language js
// All URL search param handling goes through `app/modules/search-params`, so
// that a param's codec, default and loader-relevance are declared exactly once.
// See docs/dev/search-params.md.
or {
`useSearchParams()` as $hook where {
register_diagnostic(span=$hook, message="Do not use raw `useSearchParams`. Declare the params in a `<feature>-search-params.ts` definition and read them with `useSearchParamsTyped`. See docs/dev/search-params.md.", severity="error")
},
`$_.searchParams.$method($_)` as $read where {
$method <: or {
`get`,
`getAll`,
`has`
},
register_diagnostic(span=$read, message="Do not read search params off a URL directly. Declare them in a `<feature>-search-params.ts` definition and read them with `definition.parse(request)`. If this is not a sendou.ink URL (an outgoing API call, or a URL pasted by a user), suppress with `// biome-ignore lint/plugin: <reason>`.", severity="error")
}
}

View File

@ -73,6 +73,10 @@
}
},
"overrides": [
{
"includes": ["app/**", "!app/modules/search-params/**"],
"plugins": ["./biome-plugins/no-raw-search-params.grit"]
},
{
"includes": ["**/*.test.ts", "**/*.test.tsx"],
"plugins": ["./biome-plugins/no-raw-db-writes-in-tests.grit"]

View File

@ -111,4 +111,10 @@ export const shouldRevalidate = buildsSearchParams.shouldRevalidate;
// revalidates only when a loader:true param's decoded canonical value changed
```
Submissions, revalidator calls, pathname changes and unknown-param changes defer to the router default.
Submissions, revalidator calls, pathname changes and unknown-param changes defer to the router default.
## Enforcement
The `no-raw-search-params` Biome plugin fails the lint on `useSearchParams()` and on `…searchParams.get/getAll/has(…)` anywhere in `app/` outside this module.
The escape hatch is `// biome-ignore lint/plugin: <reason>`, for the cases the convention genuinely does not cover: URLs this app did not route to (an OAuth provider's callback params, a URL pasted by a user), and reads where the total-decoding guarantee is wrong — a param whose absence must fail the request rather than resolve to a default.