diff --git a/apps/web/src/lib/features/leaderboards/leaderboards-search-params.ts b/apps/web/src/lib/features/leaderboards/leaderboards-search-params.ts index c24fdeadb..5038ddb21 100644 --- a/apps/web/src/lib/features/leaderboards/leaderboards-search-params.ts +++ b/apps/web/src/lib/features/leaderboards/leaderboards-search-params.ts @@ -6,9 +6,7 @@ import { LEADERBOARD_TYPES } from "./leaderboards-constants.ts"; export const leaderboardsSearchParams = SearchParams.define({ type: SP.param(v.picklist(LEADERBOARD_TYPES), { default: LEADERBOARD_TYPES[0], - loader: true, }), season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), { - loader: true, }), }); diff --git a/apps/web/src/lib/modules/search-params/search-params-state.svelte.ts b/apps/web/src/lib/modules/search-params/search-params-state.svelte.ts index e6aa4b0cb..41f8c52bf 100644 --- a/apps/web/src/lib/modules/search-params/search-params-state.svelte.ts +++ b/apps/web/src/lib/modules/search-params/search-params-state.svelte.ts @@ -9,38 +9,33 @@ import * as SearchParams from "./search-params.ts"; type AnyShape = Record>; -interface SetSearchParamsOptions { - /** Overrides the write channel derived from the written params' `loader`. */ - loader?: boolean; -} - /** * Typed search params state for a whole definition. * * `current` tracks the URL reactively. Writes are merges: params not mentioned * are preserved, declared `resets` are applied and values equal to their - * default are removed from the URL. If any written param is `loader: true` the - * batch writes through one replace navigation (queries keyed on the parsed - * values rerun exactly when a decoded value changed), otherwise through a - * shallow navigation that reruns nothing. A write known not to change query - * data can force the latter with `{ loader: false }`. + * default are removed from the URL. Every write is one shallow replace + * navigation — it reruns nothing by itself. Queries are keyed on their + * (decoded) args, so a write refetches exactly the queries whose args a + * component derives from the changed params. */ export function searchParamsState( definition: SearchParamsDefinition, ) { + // shallow navigations leave page.url stale; their target is page.shallow.url const relevantSearch = $derived( - SearchParams.pickRelevantSearch(definition.keys, page.url.search), + SearchParams.pickRelevantSearch( + definition.keys, + (page.shallow?.url ?? page.url).search, + ), ); const values = $derived( definition.parse(new URLSearchParams(relevantSearch)), ); - function set( - updates: Partial>, - opts?: SetSearchParamsOptions, - ) { + function set(updates: Partial>) { const current = new URLSearchParams(window.location.search); - const { next, navigationNeeded } = SearchParams.applyToSearchParams( + const next = SearchParams.applyToSearchParams( definition, current, updates, @@ -51,11 +46,7 @@ export function searchParamsState( const url = `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`; - void goto(url, { - replace: true, - reset: false, - shallow: !(opts?.loader ?? navigationNeeded), - }); + void goto(url, { replace: true, reset: false, shallow: true }); } return { diff --git a/apps/web/src/lib/modules/search-params/search-params.test.ts b/apps/web/src/lib/modules/search-params/search-params.test.ts index 2bdd7cc8e..cf41adab2 100644 --- a/apps/web/src/lib/modules/search-params/search-params.test.ts +++ b/apps/web/src/lib/modules/search-params/search-params.test.ts @@ -12,32 +12,26 @@ const testDefinition = SearchParams.define({ v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(100)), { default: 24, - loader: true, }, ), name: SP.param(v.pipe(v.string(), v.maxLength(20)), { default: "", - loader: true, }), - enabled: SP.param(v.boolean(), { default: false, loader: false }), + enabled: SP.param(v.boolean(), { default: false }), mode: SP.param(v.picklist(["TW", "SZ", "TC"]), { default: "TW", - loader: true, }), season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), { - loader: true, }), ids: SP.param(v.array(v.pipe(v.number(), v.integer(), v.minValue(1))), { default: [], - loader: false, }), filters: SP.json( v.object({ minValue: v.number(), tags: v.array(v.string()) }), - { default: { minValue: 0, tags: [] }, loader: true, resets: ["limit"] }, + { default: { minValue: 0, tags: [] }, resets: ["limit"] }, ), blob: SP.json(v.object({ text: v.string() }), { default: { text: "" }, - loader: false, compress: true, }), }); @@ -121,7 +115,6 @@ describe("SearchParams.define", () => { const definitionWithModes = SearchParams.define({ modes: SP.param(v.array(v.picklist(["SZ", "TC", "RM", "CB"])), { default: ["SZ", "TC", "RM", "CB"], - loader: false, }), }); @@ -152,7 +145,6 @@ describe("SearchParams.define", () => { expect(() => SP.param(v.object({ a: v.string() }) as any, { default: { a: "" }, - loader: true, }), ).toThrow(/derive/); expect(() => @@ -163,25 +155,21 @@ describe("SearchParams.define", () => { ) as any, { default: 0, - loader: true, }, ), ).toThrow(/derive/); expect(() => SP.param(v.array(v.array(v.number())) as any, { default: [], - loader: true, }), ).toThrow(/derive/); }); test("defaults v.nullable() params to null without declaring it", () => { const omitted = SP.param(v.nullable(v.pipe(v.number(), v.integer())), { - loader: true, }); const declared = SP.param(v.nullable(v.pipe(v.number(), v.integer())), { default: null, - loader: true, }); expect(omitted.default).toBeNull(); @@ -193,10 +181,10 @@ describe("SearchParams.define", () => { test("rejects v.optional() and non-null defaults for v.nullable()", () => { expect(() => - SP.param(v.optional(v.number()) as any, { default: 1, loader: true }), + SP.param(v.optional(v.number()) as any, { default: 1 }), ).toThrow(/nullable/); expect(() => - SP.param(v.nullable(v.number()), { default: 1 as any, loader: true }), + SP.param(v.nullable(v.number()), { default: 1 as any }), ).toThrow(/null as its default/); }); @@ -210,7 +198,7 @@ describe("SearchParams.define", () => { encode: (date) => date!.toISOString(), }; const customDefinition = SearchParams.define({ - from: SP.custom(isoDate, { default: null, loader: true }), + from: SP.custom(isoDate, { default: null }), }); const value = new Date("2024-05-01T12:00:00.000Z"); @@ -228,7 +216,7 @@ describe("SearchParams.define", () => { test("rejects resets pointing at unknown params", () => { expect(() => SearchParams.define({ - a: SP.param(v.number(), { default: 0, loader: true, resets: ["b"] }), + a: SP.param(v.number(), { default: 0, resets: ["b"] }), }), ).toThrow(/unknown param/); }); @@ -320,7 +308,6 @@ describe("SearchParams.href", () => { const definitionWithDefault = SearchParams.define({ modes: SP.param(v.array(v.picklist(["SZ", "TC"])), { default: ["SZ", "TC"], - loader: false, }), }); @@ -335,7 +322,7 @@ describe("SearchParams.href", () => { describe("SearchParams.applyToSearchParams", () => { test("preserves params outside the definition", () => { - const { next } = SearchParams.applyToSearchParams( + const next = SearchParams.applyToSearchParams( testDefinition, new URLSearchParams("unrelated=yes&limit=50"), { mode: "SZ" }, @@ -347,7 +334,7 @@ describe("SearchParams.applyToSearchParams", () => { }); test("applies declared resets", () => { - const { next } = SearchParams.applyToSearchParams( + const next = SearchParams.applyToSearchParams( testDefinition, new URLSearchParams("limit=50&enabled=true"), { filters: { minValue: 1, tags: [] } }, @@ -358,7 +345,7 @@ describe("SearchParams.applyToSearchParams", () => { }); test("does not reset a param written in the same batch", () => { - const { next } = SearchParams.applyToSearchParams( + const next = SearchParams.applyToSearchParams( testDefinition, new URLSearchParams(), { limit: 50, filters: { minValue: 1, tags: ["a"] } }, @@ -368,7 +355,7 @@ describe("SearchParams.applyToSearchParams", () => { }); test("removes params written back to their default", () => { - const { next } = SearchParams.applyToSearchParams( + const next = SearchParams.applyToSearchParams( testDefinition, new URLSearchParams("mode=SZ"), { mode: "TW" }, @@ -376,19 +363,4 @@ describe("SearchParams.applyToSearchParams", () => { expect(next.has("mode")).toBe(false); }); - - test("needs navigation exactly when a loader: true param is written", () => { - expect( - SearchParams.applyToSearchParams(testDefinition, new URLSearchParams(), { - enabled: true, - ids: [1], - }).navigationNeeded, - ).toBe(false); - expect( - SearchParams.applyToSearchParams(testDefinition, new URLSearchParams(), { - enabled: true, - mode: "SZ", - }).navigationNeeded, - ).toBe(true); - }); }); diff --git a/apps/web/src/lib/modules/search-params/search-params.ts b/apps/web/src/lib/modules/search-params/search-params.ts index 5de89c678..707ebf73b 100644 --- a/apps/web/src/lib/modules/search-params/search-params.ts +++ b/apps/web/src/lib/modules/search-params/search-params.ts @@ -6,6 +6,7 @@ import { } from "#lib/utils/compression.ts"; // xxx: check location, do we even want "modules" or just packages +// xxx: remove the migration comment /** * URL search param definitions: one `define()` per route or feature drives @@ -13,6 +14,9 @@ import { * app's module with valibot in place of zod; the `shouldRevalidate` member is * gone because remote queries are keyed on their (decoded) args — an URL write * that decodes to the same values reuses the cached query instead of refetching. + * The React module's per-param `loader` flag is gone for the same reason: + * whether a param change refetches is decided by which decoded values the + * component wires into query args, so every write is a shallow navigation. */ const COMPRESSED_PREFIX = "lz~"; @@ -30,8 +34,6 @@ type EncodeMode = "canonical" | "compact"; type AnyValiSchema = v.BaseSchema>; interface ParamOptionsBase { - /** Whether changing this param must rerun the route's queries. `false` params write through shallow navigation only. */ - loader: boolean; /** Param keys reset to their defaults whenever this param is written. */ resets?: string[]; /** The param's canonical encoding is the compressed form. Only for params whose values are inherently large. */ @@ -59,7 +61,6 @@ type ResolvedParamOptions = ParamOptionsBase & { default: T }; export interface ParamDef { default: T; - loader: boolean; resets: string[]; compress: boolean; timeDependent: boolean; @@ -185,9 +186,8 @@ export function applyToSearchParams( definition: SearchParamsDefinition, current: URLSearchParams, updates: Partial>, -): { next: URLSearchParams; navigationNeeded: boolean } { +): URLSearchParams { const next = new URLSearchParams(current); - let navigationNeeded = false; const updatedKeys = definition.keys.filter((key) => key in updates); @@ -199,25 +199,17 @@ export function applyToSearchParams( } for (const key of updatedKeys) { - const def = definition.shape[key]; - if (def.loader) { - navigationNeeded = true; - } - next.delete(key); - for (const encoded of encodeParam(def, updates[key])) { + for (const encoded of encodeParam(definition.shape[key], updates[key])) { next.append(key, encoded); } } for (const resetKey of resetKeys) { - if (definition.shape[resetKey].loader && next.has(resetKey)) { - navigationNeeded = true; - } next.delete(resetKey); } - return { next, navigationNeeded }; + return next; } /** @@ -310,7 +302,7 @@ export const SP = { v.minValue(1), v.maxValue(opts?.max ?? DEFAULT_MAX_PAGE), ), - { default: 1, loader: true, resets: opts?.resets }, + { default: 1, resets: opts?.resets }, ); }, @@ -377,11 +369,10 @@ function baseDef( opts: ResolvedParamOptions, ): Pick< ParamDef, - "default" | "loader" | "resets" | "compress" | "timeDependent" | "decodeCache" + "default" | "resets" | "compress" | "timeDependent" | "decodeCache" > { return { default: opts.default, - loader: opts.loader, resets: opts.resets ?? [], compress: opts.compress ?? false, timeDependent: opts.timeDependent ?? false,