mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 15:17:43 -05:00
Get rid of loader: true
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user