Get rid of loader: true

This commit is contained in:
Kalle
2026-08-17 07:34:58 +03:00
parent 8d007f4272
commit b552c10287
4 changed files with 31 additions and 79 deletions

View File

@@ -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,
}),
});

View File

@@ -9,38 +9,33 @@ import * as SearchParams from "./search-params.ts";
type AnyShape = Record<string, ParamDef<any>>;
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<Shape extends AnyShape>(
definition: SearchParamsDefinition<Shape>,
) {
// 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<SearchParamsValues<Shape>>,
opts?: SetSearchParamsOptions,
) {
function set(updates: Partial<SearchParamsValues<Shape>>) {
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<Shape extends AnyShape>(
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 {

View File

@@ -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);
});
});

View File

@@ -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<unknown, unknown, v.BaseIssue<unknown>>;
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<T> = ParamOptionsBase & { default: T };
export interface ParamDef<T> {
default: T;
loader: boolean;
resets: string[];
compress: boolean;
timeDependent: boolean;
@@ -185,9 +186,8 @@ export function applyToSearchParams<Shape extends AnyShape>(
definition: SearchParamsDefinition<Shape>,
current: URLSearchParams,
updates: Partial<SearchParamsValues<Shape>>,
): { 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<Shape extends AnyShape>(
}
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<T>(
opts: ResolvedParamOptions<T>,
): Pick<
ParamDef<T>,
"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,