mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
194
apps/web-react/app/modules/search-params/hooks.browser.test.tsx
Normal file
194
apps/web-react/app/modules/search-params/hooks.browser.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import * as React from "react";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { render } from "vitest-browser-react";
|
||||
import { z } from "zod";
|
||||
import { useSearchParam, useSearchParamsTyped } from "./hooks";
|
||||
import * as SearchParams from "./search-params";
|
||||
import { SP } from "./search-params";
|
||||
|
||||
const definition = SearchParams.define({
|
||||
page: SP.param(z.number().int().min(1), { default: 1, loader: true }),
|
||||
filters: SP.json(z.object({ q: z.string() }), {
|
||||
default: { q: "" },
|
||||
loader: true,
|
||||
resets: ["page"],
|
||||
}),
|
||||
view: SP.param(z.enum(["list", "grid"]), { default: "list", loader: false }),
|
||||
other: SP.param(z.number(), { default: 0, loader: false }),
|
||||
});
|
||||
|
||||
let loaderCalls = 0;
|
||||
let viewRenders = 0;
|
||||
|
||||
function TestComponent() {
|
||||
const [params, setParams] = useSearchParamsTyped(definition);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="page">{params.page}</div>
|
||||
<div data-testid="view">{params.view}</div>
|
||||
<div data-testid="q">{params.filters.q}</div>
|
||||
<button type="button" onClick={() => setParams({ view: "grid" })}>
|
||||
set view
|
||||
</button>
|
||||
<button type="button" onClick={() => setParams({ page: 5 })}>
|
||||
set page
|
||||
</button>
|
||||
<button type="button" onClick={() => setParams({ page: 1 })}>
|
||||
reset page
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams({ filters: { q: "hello" } })}
|
||||
>
|
||||
set filters
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams({ view: "grid", page: 2 })}
|
||||
>
|
||||
mixed batch
|
||||
</button>
|
||||
<button type="button" onClick={() => setParams({ other: 9 })}>
|
||||
set other
|
||||
</button>
|
||||
<ViewOnly />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ViewOnly = React.memo(function ViewOnly() {
|
||||
const [view] = useSearchParam(definition, "view");
|
||||
viewRenders++;
|
||||
|
||||
return <div data-testid="view-only">{view}</div>;
|
||||
});
|
||||
|
||||
async function renderTestRouter() {
|
||||
loaderCalls = 0;
|
||||
viewRenders = 0;
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "*",
|
||||
loader: () => {
|
||||
loaderCalls++;
|
||||
return null;
|
||||
},
|
||||
element: <TestComponent />,
|
||||
},
|
||||
]);
|
||||
|
||||
return { screen: await render(<RouterProvider router={router} />), router };
|
||||
}
|
||||
|
||||
function currentSearchParams() {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
});
|
||||
|
||||
describe("useSearchParamsTyped", () => {
|
||||
test("loader: false writes update the URL without running loaders", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("list");
|
||||
const initialLoaderCalls = loaderCalls;
|
||||
|
||||
await screen.getByRole("button", { name: "set view" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("grid");
|
||||
expect(currentSearchParams().get("view")).toBe("grid");
|
||||
expect(loaderCalls).toBe(initialLoaderCalls);
|
||||
});
|
||||
|
||||
test("loader: true writes navigate and run loaders", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
const initialLoaderCalls = loaderCalls;
|
||||
|
||||
await screen.getByRole("button", { name: "set page", exact: true }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("5");
|
||||
expect(currentSearchParams().get("page")).toBe("5");
|
||||
await expect.poll(() => loaderCalls).toBeGreaterThan(initialLoaderCalls);
|
||||
});
|
||||
|
||||
test("a mixed batch carries both changes in one navigation", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
|
||||
await screen.getByRole("button", { name: "mixed batch" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("2");
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("grid");
|
||||
expect(currentSearchParams().get("page")).toBe("2");
|
||||
expect(currentSearchParams().get("view")).toBe("grid");
|
||||
});
|
||||
|
||||
test("writes merge: params outside the batch are preserved", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
window.history.replaceState(null, "", "?unrelated=yes&page=5");
|
||||
|
||||
await screen.getByRole("button", { name: "set view" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("view")).toHaveTextContent("grid");
|
||||
expect(currentSearchParams().get("unrelated")).toBe("yes");
|
||||
expect(currentSearchParams().get("page")).toBe("5");
|
||||
});
|
||||
|
||||
test("values equal to their default are removed from the URL", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
window.history.replaceState(null, "", "?page=5");
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("5");
|
||||
|
||||
await screen.getByRole("button", { name: "reset page" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
expect(currentSearchParams().has("page")).toBe(false);
|
||||
});
|
||||
|
||||
test("declared resets reset other params on write", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
window.history.replaceState(null, "", "?page=5");
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("5");
|
||||
|
||||
await screen.getByRole("button", { name: "set filters" }).click();
|
||||
|
||||
await expect.element(screen.getByTestId("q")).toHaveTextContent("hello");
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
expect(currentSearchParams().has("page")).toBe(false);
|
||||
});
|
||||
|
||||
test("external navigation syncs the params", async () => {
|
||||
const { screen, router } = await renderTestRouter();
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("1");
|
||||
|
||||
await router.navigate(`${window.location.pathname}?page=7`);
|
||||
|
||||
await expect.element(screen.getByTestId("page")).toHaveTextContent("7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSearchParam", () => {
|
||||
test("rerenders only when the subscribed param changes", async () => {
|
||||
const { screen } = await renderTestRouter();
|
||||
await expect
|
||||
.element(screen.getByTestId("view-only"))
|
||||
.toHaveTextContent("list");
|
||||
const initialViewRenders = viewRenders;
|
||||
|
||||
await screen.getByRole("button", { name: "set other" }).click();
|
||||
await expect.poll(() => currentSearchParams().get("other")).toBe("9");
|
||||
|
||||
expect(viewRenders).toBe(initialViewRenders);
|
||||
|
||||
await screen.getByRole("button", { name: "set view" }).click();
|
||||
await expect
|
||||
.element(screen.getByTestId("view-only"))
|
||||
.toHaveTextContent("grid");
|
||||
expect(viewRenders).toBeGreaterThan(initialViewRenders);
|
||||
});
|
||||
});
|
||||
144
apps/web-react/app/modules/search-params/hooks.ts
Normal file
144
apps/web-react/app/modules/search-params/hooks.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import * as React from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import type {
|
||||
ParamDef,
|
||||
SearchParamsDefinition,
|
||||
SearchParamsValues,
|
||||
} from "./search-params";
|
||||
import * as SearchParams from "./search-params";
|
||||
import * as Store from "./store";
|
||||
|
||||
type AnyShape = Record<string, ParamDef<any>>;
|
||||
|
||||
interface SetSearchParamsOptions {
|
||||
replace?: boolean;
|
||||
preventScrollReset?: boolean;
|
||||
/** Overrides the write channel derived from the written params' `loader`. */
|
||||
loader?: boolean;
|
||||
}
|
||||
|
||||
type SetSearchParams<Shape extends AnyShape> = (
|
||||
updates: Partial<SearchParamsValues<Shape>>,
|
||||
opts?: SetSearchParamsOptions,
|
||||
) => void;
|
||||
|
||||
const InitialSearchContext = React.createContext<string | null>(null);
|
||||
|
||||
/**
|
||||
* Provides the initial search string for server rendering and hydration.
|
||||
* Mounted once in the root route; the value never updates so consumers do not
|
||||
* rerender through it.
|
||||
*/
|
||||
export function SearchParamsProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const [initialSearch] = React.useState(location.search);
|
||||
|
||||
return React.createElement(
|
||||
InitialSearchContext.Provider,
|
||||
{ value: initialSearch },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed search params state for a whole definition.
|
||||
*
|
||||
* 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 navigation,
|
||||
* otherwise through `history.replaceState` without triggering loaders. A write
|
||||
* known not to change loader data can force the latter with `{ loader: false }`.
|
||||
*/
|
||||
export function useSearchParamsTyped<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
): [SearchParamsValues<Shape>, SetSearchParams<Shape>] {
|
||||
const values = useDecodedValues(definition, definition.keys);
|
||||
const setParams = useSetSearchParams(definition);
|
||||
|
||||
return [values, setParams];
|
||||
}
|
||||
|
||||
/**
|
||||
* Focused subscription to a single param of a definition: rerenders only when
|
||||
* that param's raw string value changes.
|
||||
*/
|
||||
export function useSearchParam<
|
||||
Shape extends AnyShape,
|
||||
K extends keyof Shape & string,
|
||||
>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
key: K,
|
||||
): [
|
||||
SearchParamsValues<Shape>[K],
|
||||
(value: SearchParamsValues<Shape>[K], opts?: SetSearchParamsOptions) => void,
|
||||
] {
|
||||
const values = useDecodedValues(definition, [key]);
|
||||
const setParams = useSetSearchParams(definition);
|
||||
|
||||
const setValue = React.useCallback(
|
||||
(value: SearchParamsValues<Shape>[K], opts?: SetSearchParamsOptions) => {
|
||||
setParams({ [key]: value } as Partial<SearchParamsValues<Shape>>, opts);
|
||||
},
|
||||
[setParams, key],
|
||||
);
|
||||
|
||||
return [values[key], setValue];
|
||||
}
|
||||
|
||||
function useDecodedValues<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
keys: string[],
|
||||
): SearchParamsValues<Shape> {
|
||||
const initialSearch = React.useContext(InitialSearchContext) ?? "";
|
||||
|
||||
const relevantSearch = React.useSyncExternalStore(
|
||||
Store.subscribe,
|
||||
() => SearchParams.pickRelevantSearch(keys, window.location.search),
|
||||
() => SearchParams.pickRelevantSearch(keys, initialSearch),
|
||||
);
|
||||
|
||||
return React.useMemo(
|
||||
() => definition.parse(new URLSearchParams(relevantSearch)),
|
||||
[definition, relevantSearch],
|
||||
);
|
||||
}
|
||||
|
||||
function useSetSearchParams<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
): SetSearchParams<Shape> {
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = React.useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
|
||||
return React.useCallback(
|
||||
(updates, opts) => {
|
||||
const { loader, ...navigateOptions } = opts ?? {};
|
||||
const current = new URLSearchParams(window.location.search);
|
||||
const { next, navigationNeeded } = SearchParams.applyToSearchParams(
|
||||
definition,
|
||||
current,
|
||||
updates,
|
||||
);
|
||||
|
||||
const queryString = next.toString();
|
||||
if (queryString === current.toString()) return;
|
||||
|
||||
const url = `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`;
|
||||
|
||||
if (loader ?? navigationNeeded) {
|
||||
navigateRef.current(url, {
|
||||
replace: true,
|
||||
preventScrollReset: true,
|
||||
...navigateOptions,
|
||||
});
|
||||
} else {
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}
|
||||
},
|
||||
[definition],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect } from "vitest";
|
||||
import type {
|
||||
ParamDef,
|
||||
SearchParamsDefinition,
|
||||
SearchParamsValues,
|
||||
} from "./search-params";
|
||||
import * as SearchParams from "./search-params";
|
||||
|
||||
type AnyShape = Record<string, ParamDef<any>>;
|
||||
|
||||
/**
|
||||
* Asserts `decode(encode(x))` deep-equals `x` for every given example value,
|
||||
* through both the plain and the compressed transport form, and additionally
|
||||
* through a full `href` → `parse` cycle. Every feature definition registers a
|
||||
* test with representative and edge-case values.
|
||||
*/
|
||||
export function assertRoundTrips<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
examples: {
|
||||
[K in keyof Shape]: Array<SearchParamsValues<Shape>[K]>;
|
||||
},
|
||||
) {
|
||||
for (const key of definition.keys) {
|
||||
const def = definition.shape[key];
|
||||
|
||||
expect
|
||||
.soft(SearchParams.decodeParam(def, []), `${key}: absent → default`)
|
||||
.toEqual(def.default);
|
||||
|
||||
for (const value of examples[key]) {
|
||||
const encoded = SearchParams.encodeParam(def, value);
|
||||
expect
|
||||
.soft(
|
||||
SearchParams.decodeParam(def, encoded),
|
||||
`${key}: plain round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
|
||||
const compressed =
|
||||
def.compress || encoded.length === 0
|
||||
? encoded
|
||||
: def
|
||||
.encodePlain(value)
|
||||
.map((plain) => SearchParams.compressTransportValue(plain));
|
||||
expect
|
||||
.soft(
|
||||
SearchParams.decodeParam(def, compressed),
|
||||
`${key}: compressed round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
|
||||
const href = definition.href("/round-trip", {
|
||||
[key]: value,
|
||||
} as Partial<SearchParamsValues<Shape>>);
|
||||
const parsed = definition.parse(new URL(href, "http://localhost"));
|
||||
expect
|
||||
.soft(
|
||||
parsed[key],
|
||||
`${key}: href → parse round trip of ${JSON.stringify(value)}`,
|
||||
)
|
||||
.toEqual(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Asserts that each given raw URL value list decodes to the param's default. */
|
||||
export function assertDecodesToDefault<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
key: keyof Shape & string,
|
||||
rawValuesList: string[][],
|
||||
) {
|
||||
const def = definition.shape[key];
|
||||
|
||||
for (const rawValues of rawValuesList) {
|
||||
expect
|
||||
.soft(
|
||||
SearchParams.decodeParam(def, rawValues),
|
||||
`${key}: ${JSON.stringify(rawValues)} → default`,
|
||||
)
|
||||
.toEqual(def.default);
|
||||
}
|
||||
}
|
||||
436
apps/web-react/app/modules/search-params/search-params.test.ts
Normal file
436
apps/web-react/app/modules/search-params/search-params.test.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import type { ShouldRevalidateFunction } from "react-router";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import * as SearchParams from "./search-params";
|
||||
import { SP } from "./search-params";
|
||||
import {
|
||||
assertDecodesToDefault,
|
||||
assertRoundTrips,
|
||||
} from "./search-params-test-utils";
|
||||
|
||||
const testDefinition = SearchParams.define({
|
||||
limit: SP.param(z.number().int().min(1).max(100), {
|
||||
default: 24,
|
||||
loader: true,
|
||||
}),
|
||||
name: SP.param(z.string().max(20), { default: "", loader: true }),
|
||||
enabled: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
mode: SP.param(z.enum(["TW", "SZ", "TC"]), {
|
||||
default: "TW",
|
||||
loader: true,
|
||||
}),
|
||||
season: SP.param(z.number().int().nullable(), { loader: true }),
|
||||
ids: SP.param(z.array(z.number().int().positive()), {
|
||||
default: [],
|
||||
loader: false,
|
||||
}),
|
||||
filters: SP.json(
|
||||
z.object({ minValue: z.number(), tags: z.array(z.string()) }),
|
||||
{ default: { minValue: 0, tags: [] }, loader: true, resets: ["limit"] },
|
||||
),
|
||||
blob: SP.json(z.object({ text: z.string() }), {
|
||||
default: { text: "" },
|
||||
loader: false,
|
||||
compress: true,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("SearchParams round trips", () => {
|
||||
test("round-trips representative and edge-case values", () => {
|
||||
assertRoundTrips(testDefinition, {
|
||||
limit: [24, 1, 100, 55],
|
||||
name: ["", "hello", "with space", "ä&=?#ö", "lz~sneaky", "lz~~x"],
|
||||
enabled: [true, false],
|
||||
mode: ["TW", "SZ", "TC"],
|
||||
season: [null, 5, 0, -3],
|
||||
ids: [[], [1], [1, 2, 3]],
|
||||
filters: [
|
||||
{ minValue: 0, tags: [] },
|
||||
{ minValue: 3, tags: ["a", "b"] },
|
||||
],
|
||||
blob: [{ text: "" }, { text: "a".repeat(500) }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.define", () => {
|
||||
test("decodes garbage to the default", () => {
|
||||
assertDecodesToDefault(testDefinition, "limit", [
|
||||
[""],
|
||||
["abc"],
|
||||
["0"],
|
||||
["101"],
|
||||
["1.5"],
|
||||
["Infinity"],
|
||||
["lz~%%%%"],
|
||||
]);
|
||||
assertDecodesToDefault(testDefinition, "enabled", [
|
||||
[""],
|
||||
["TRUE"],
|
||||
["1"],
|
||||
["yes"],
|
||||
]);
|
||||
assertDecodesToDefault(testDefinition, "mode", [[""], ["RM"], ["tw"]]);
|
||||
assertDecodesToDefault(testDefinition, "season", [[""], ["x"]]);
|
||||
assertDecodesToDefault(testDefinition, "filters", [
|
||||
[""],
|
||||
["{"],
|
||||
['{"minValue":"nope","tags":[]}'],
|
||||
["[1,2,3]"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses a Request, URL and URLSearchParams", () => {
|
||||
const url = "http://localhost/builds?limit=50&mode=SZ";
|
||||
const expected = { limit: 50, mode: "SZ" };
|
||||
|
||||
expect(testDefinition.parse(new Request(url))).toMatchObject(expected);
|
||||
expect(testDefinition.parse(new URL(url))).toMatchObject(expected);
|
||||
expect(
|
||||
testDefinition.parse(new URLSearchParams("limit=50&mode=SZ")),
|
||||
).toMatchObject(expected);
|
||||
});
|
||||
|
||||
test("resolves every missing param to its default", () => {
|
||||
expect(testDefinition.parse(new URLSearchParams())).toEqual({
|
||||
limit: 24,
|
||||
name: "",
|
||||
enabled: false,
|
||||
mode: "TW",
|
||||
season: null,
|
||||
ids: [],
|
||||
filters: { minValue: 0, tags: [] },
|
||||
blob: { text: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("drops invalid array members instead of the whole array", () => {
|
||||
expect(
|
||||
testDefinition.parse(new URLSearchParams("ids=1&ids=x&ids=-2&ids=3")).ids,
|
||||
).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
test("decodes legacy JSON-encoded arrays", () => {
|
||||
const definitionWithModes = SearchParams.define({
|
||||
modes: SP.param(z.array(z.enum(["SZ", "TC", "RM", "CB"])), {
|
||||
default: ["SZ", "TC", "RM", "CB"],
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
SearchParams.decodeParam(definitionWithModes.shape.modes, [
|
||||
'["SZ","TC"]',
|
||||
]),
|
||||
).toEqual(["SZ", "TC"]);
|
||||
expect(testDefinition.parse(new URLSearchParams("ids=[1,2]")).ids).toEqual([
|
||||
1, 2,
|
||||
]);
|
||||
});
|
||||
|
||||
test("decodes legacy comma-joined numeric arrays", () => {
|
||||
expect(testDefinition.parse(new URLSearchParams("ids=1,2,3")).ids).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns referentially equal values for the same raw input", () => {
|
||||
const first = testDefinition.parse(new URLSearchParams("ids=1&ids=2"));
|
||||
const second = testDefinition.parse(new URLSearchParams("ids=1&ids=2"));
|
||||
|
||||
expect(first.ids).toBe(second.ids);
|
||||
});
|
||||
|
||||
test("rejects schemas outside the derivation table at define time", () => {
|
||||
expect(() =>
|
||||
SP.param(z.object({ a: z.string() }) as any, {
|
||||
default: { a: "" },
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
expect(() =>
|
||||
SP.param(z.string().transform((s) => s.length) as any, {
|
||||
default: 0,
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
expect(() =>
|
||||
SP.param(z.array(z.array(z.number())) as any, {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
).toThrow(/derive/);
|
||||
});
|
||||
|
||||
test("defaults .nullable() params to null without declaring it", () => {
|
||||
const omitted = SP.param(z.number().int().nullable(), { loader: true });
|
||||
const declared = SP.param(z.number().int().nullable(), {
|
||||
default: null,
|
||||
loader: true,
|
||||
});
|
||||
|
||||
expect(omitted.default).toBeNull();
|
||||
expect(SearchParams.decodeParam(omitted, [])).toBeNull();
|
||||
expect(SearchParams.decodeParam(omitted, ["nope"])).toBeNull();
|
||||
expect(SearchParams.encodeParam(omitted, null)).toEqual([]);
|
||||
expect(declared.default).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects .optional() and non-null defaults for .nullable()", () => {
|
||||
expect(() =>
|
||||
SP.param(z.number().optional() as any, { default: 1, loader: true }),
|
||||
).toThrow(/nullable/);
|
||||
expect(() =>
|
||||
SP.param(z.number().nullable(), { default: 1 as any, loader: true }),
|
||||
).toThrow(/null as its default/);
|
||||
});
|
||||
|
||||
test("supports SP.custom codecs with total decode via issues", () => {
|
||||
const isoDate = z.codec(z.string(), z.date(), {
|
||||
decode: (value, payload) => {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
payload.issues.push({
|
||||
code: "custom",
|
||||
message: "invalid date",
|
||||
input: value,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return date;
|
||||
},
|
||||
encode: (date) => date.toISOString(),
|
||||
});
|
||||
const customDefinition = SearchParams.define({
|
||||
from: SP.custom(isoDate.nullable(), { default: null, loader: true }),
|
||||
});
|
||||
|
||||
const value = new Date("2024-05-01T12:00:00.000Z");
|
||||
expect(
|
||||
customDefinition.parse(
|
||||
new URL(
|
||||
customDefinition.href("/x", { from: value }),
|
||||
"http://localhost",
|
||||
),
|
||||
).from,
|
||||
).toEqual(value);
|
||||
assertDecodesToDefault(customDefinition, "from", [["garbage"], [""]]);
|
||||
});
|
||||
|
||||
test("rejects resets pointing at unknown params", () => {
|
||||
expect(() =>
|
||||
SearchParams.define({
|
||||
a: SP.param(z.number(), { default: 0, loader: true, resets: ["b"] }),
|
||||
}),
|
||||
).toThrow(/unknown param/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams compression", () => {
|
||||
test("decodes a compressed arrival of any param identically to plain", () => {
|
||||
const def = testDefinition.shape.filters;
|
||||
const value = { minValue: 7, tags: ["x"] };
|
||||
const plain = def.encodePlain(value)[0];
|
||||
|
||||
expect(
|
||||
SearchParams.decodeParam(def, [
|
||||
SearchParams.compressTransportValue(plain),
|
||||
]),
|
||||
).toEqual(value);
|
||||
});
|
||||
|
||||
test("always emits the compressed form for compress: true params", () => {
|
||||
const encoded = SearchParams.encodeParam(testDefinition.shape.blob, {
|
||||
text: "hello world",
|
||||
});
|
||||
|
||||
expect(encoded).toHaveLength(1);
|
||||
expect(encoded[0]).toMatch(/^lz~/);
|
||||
});
|
||||
|
||||
test("resolves a corrupt compressed payload to the default", () => {
|
||||
expect(
|
||||
SearchParams.decodeParam(testDefinition.shape.blob, ["lz~$$$$"]),
|
||||
).toEqual({ text: "" });
|
||||
});
|
||||
|
||||
test("resolves a compression bomb to the default", () => {
|
||||
const bomb = SearchParams.compressTransportValue(
|
||||
JSON.stringify({ text: "a".repeat(10 * 1024 * 1024) }),
|
||||
);
|
||||
|
||||
expect(SearchParams.decodeParam(testDefinition.shape.blob, [bomb])).toEqual(
|
||||
{ text: "" },
|
||||
);
|
||||
});
|
||||
|
||||
test("compresses on demand only when it shortens the value", () => {
|
||||
const longFilters = {
|
||||
minValue: 1,
|
||||
tags: Array.from({ length: 30 }, (_, i) => `long-tag-number-${i}`),
|
||||
};
|
||||
const compactHref = testDefinition.href(
|
||||
"/x",
|
||||
{ filters: longFilters, limit: 50 },
|
||||
{ compress: true },
|
||||
);
|
||||
|
||||
const searchParams = new URL(compactHref, "http://localhost").searchParams;
|
||||
expect(searchParams.get("filters")).toMatch(/^lz~/);
|
||||
expect(searchParams.get("limit")).toBe("50");
|
||||
expect(
|
||||
testDefinition.parse(new URL(compactHref, "http://localhost")),
|
||||
).toMatchObject({ filters: longFilters, limit: 50 });
|
||||
});
|
||||
|
||||
test("compares the forms percent-encoded, not as raw strings", () => {
|
||||
// shorter than its compressed form as a raw string, longer once percent-encoded
|
||||
const filters = { minValue: 1, tags: ["ゲームのタグ"] };
|
||||
const compactHref = testDefinition.href(
|
||||
"/x",
|
||||
{ filters },
|
||||
{ compress: true },
|
||||
);
|
||||
|
||||
const searchParams = new URL(compactHref, "http://localhost").searchParams;
|
||||
expect(searchParams.get("filters")).toMatch(/^lz~/);
|
||||
expect(compactHref.length).toBeLessThan(
|
||||
testDefinition.href("/x", { filters }).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.href", () => {
|
||||
test("omits values equal to their default", () => {
|
||||
expect(
|
||||
testDefinition.href("/builds", { limit: 24, mode: "SZ", season: null }),
|
||||
).toBe("/builds?mode=SZ");
|
||||
expect(testDefinition.href("/builds", { limit: 24 })).toBe("/builds");
|
||||
});
|
||||
|
||||
test("encodes arrays as repeated keys and empty arrays as one empty value", () => {
|
||||
const definitionWithDefault = SearchParams.define({
|
||||
modes: SP.param(z.array(z.enum(["SZ", "TC"])), {
|
||||
default: ["SZ", "TC"],
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(testDefinition.href("/x", { ids: [1, 2] })).toBe("/x?ids=1&ids=2");
|
||||
expect(definitionWithDefault.href("/x", { modes: [] })).toBe("/x?modes=");
|
||||
expect(
|
||||
definitionWithDefault.parse(new URL("/x?modes=", "http://localhost"))
|
||||
.modes,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.applyToSearchParams", () => {
|
||||
test("preserves params outside the definition", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams("unrelated=yes&limit=50"),
|
||||
{ mode: "SZ" },
|
||||
);
|
||||
|
||||
expect(next.get("unrelated")).toBe("yes");
|
||||
expect(next.get("limit")).toBe("50");
|
||||
expect(next.get("mode")).toBe("SZ");
|
||||
});
|
||||
|
||||
test("applies declared resets", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams("limit=50&enabled=true"),
|
||||
{ filters: { minValue: 1, tags: [] } },
|
||||
);
|
||||
|
||||
expect(next.has("limit")).toBe(false);
|
||||
expect(next.get("enabled")).toBe("true");
|
||||
});
|
||||
|
||||
test("does not reset a param written in the same batch", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams(),
|
||||
{ limit: 50, filters: { minValue: 1, tags: ["a"] } },
|
||||
);
|
||||
|
||||
expect(next.get("limit")).toBe("50");
|
||||
});
|
||||
|
||||
test("removes params written back to their default", () => {
|
||||
const { next } = SearchParams.applyToSearchParams(
|
||||
testDefinition,
|
||||
new URLSearchParams("mode=SZ"),
|
||||
{ mode: "TW" },
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SearchParams.shouldRevalidate", () => {
|
||||
function run(
|
||||
currentSearch: string,
|
||||
nextSearch: string,
|
||||
overrides?: Partial<Parameters<ShouldRevalidateFunction>[0]>,
|
||||
) {
|
||||
return testDefinition.shouldRevalidate({
|
||||
currentUrl: new URL(`http://localhost/x${currentSearch}`),
|
||||
nextUrl: new URL(`http://localhost/x${nextSearch}`),
|
||||
currentParams: {},
|
||||
nextParams: {},
|
||||
defaultShouldRevalidate: true,
|
||||
...overrides,
|
||||
} as Parameters<ShouldRevalidateFunction>[0]);
|
||||
}
|
||||
|
||||
test("revalidates when a loader: true param's decoded value changes", () => {
|
||||
expect(run("?limit=50", "?limit=60")).toBe(true);
|
||||
expect(run("", "?mode=SZ")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not revalidate for loader: false params", () => {
|
||||
expect(run("", "?enabled=true&ids=1")).toBe(false);
|
||||
});
|
||||
|
||||
test("does not revalidate for non-canonical but equal values", () => {
|
||||
expect(
|
||||
run(
|
||||
'?filters={"minValue":1,"tags":[]}',
|
||||
'?filters={"tags":[],"minValue":1}',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(run("?limit=24", "")).toBe(false);
|
||||
});
|
||||
|
||||
test("defers to the default for other pathnames, submissions and unknown params", () => {
|
||||
expect(
|
||||
testDefinition.shouldRevalidate({
|
||||
currentUrl: new URL("http://localhost/x"),
|
||||
nextUrl: new URL("http://localhost/y"),
|
||||
currentParams: {},
|
||||
nextParams: {},
|
||||
defaultShouldRevalidate: true,
|
||||
} as Parameters<ShouldRevalidateFunction>[0]),
|
||||
).toBe(true);
|
||||
expect(run("", "", { formMethod: "POST" })).toBe(true);
|
||||
expect(run("?unrelated=1", "?unrelated=2")).toBe(true);
|
||||
});
|
||||
});
|
||||
609
apps/web-react/app/modules/search-params/search-params.ts
Normal file
609
apps/web-react/app/modules/search-params/search-params.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import type { ShouldRevalidateFunction } from "react-router";
|
||||
import { isDeepEqual } from "remeda";
|
||||
import { z } from "zod";
|
||||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
|
||||
const COMPRESSED_PREFIX = "lz~";
|
||||
const ESCAPED_PREFIX = "lz~~";
|
||||
const DECODE_CACHE_MAX_SIZE = 300;
|
||||
const MAX_DECOMPRESSED_VALUE_BYTES = 256 * 1024;
|
||||
const DEFAULT_MAX_PAGE = 1000;
|
||||
|
||||
const DECODE_FAILED = Symbol("DECODE_FAILED");
|
||||
|
||||
type ScalarBase = "string" | "number" | "boolean";
|
||||
|
||||
type EncodeMode = "canonical" | "compact";
|
||||
|
||||
interface ParamOptionsBase {
|
||||
/** Whether changing this param must run loaders. `false` params write through `history.replaceState` and never trigger revalidation. */
|
||||
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. */
|
||||
compress?: boolean;
|
||||
/** The value schema reads the clock, so its decode results must never be cached. */
|
||||
timeDependent?: boolean;
|
||||
}
|
||||
|
||||
type DefaultOption<T> = {
|
||||
/** Value used when the param is missing or fails to decode. Values equal to it are omitted from the URL. Must be a static value. */
|
||||
default: T;
|
||||
};
|
||||
|
||||
type ParamOptions<T> = ParamOptionsBase &
|
||||
(unknown extends T
|
||||
? DefaultOption<T>
|
||||
: null extends T
|
||||
? {
|
||||
/** Omit it: a nullable param's default is always `null`. */
|
||||
default?: null;
|
||||
}
|
||||
: DefaultOption<T>);
|
||||
|
||||
type ResolvedParamOptions<T> = ParamOptionsBase & { default: T };
|
||||
|
||||
export interface ParamDef<T> {
|
||||
default: T;
|
||||
loader: boolean;
|
||||
resets: string[];
|
||||
compress: boolean;
|
||||
timeDependent: boolean;
|
||||
decodeValues: (values: string[]) => T;
|
||||
encodePlain: (value: T) => string[];
|
||||
decodeCache: Map<string, T>;
|
||||
}
|
||||
|
||||
type AnyShape = Record<string, ParamDef<any>>;
|
||||
|
||||
export type SearchParamsValues<Shape extends AnyShape> = {
|
||||
[K in keyof Shape]: Shape[K] extends ParamDef<infer T> ? T : never;
|
||||
};
|
||||
|
||||
export interface SearchParamsDefinition<Shape extends AnyShape> {
|
||||
shape: Shape;
|
||||
keys: string[];
|
||||
/** Decodes all params of the definition. Total: defaults resolve for missing or malformed values, never throws. */
|
||||
parse: (input: Request | URL | URLSearchParams) => SearchParamsValues<Shape>;
|
||||
/** Builds a href with the given values encoded as search params. Values equal to their default are omitted. */
|
||||
href: (
|
||||
path: string,
|
||||
values: Partial<SearchParamsValues<Shape>>,
|
||||
opts?: { compress?: boolean },
|
||||
) => string;
|
||||
/** Revalidates only when a `loader: true` param's decoded canonical value changed. */
|
||||
shouldRevalidate: ShouldRevalidateFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a search params definition from param declarations (see `SP.param`,
|
||||
* `SP.json` and `SP.custom`). One definition per route or feature drives
|
||||
* loader parsing, client state, href building and revalidation.
|
||||
*/
|
||||
export function define<Shape extends AnyShape>(
|
||||
shape: Shape,
|
||||
): SearchParamsDefinition<Shape> {
|
||||
const keys = Object.keys(shape);
|
||||
|
||||
for (const [key, def] of Object.entries(shape)) {
|
||||
for (const resetKey of def.resets) {
|
||||
if (!keys.includes(resetKey)) {
|
||||
throw new Error(
|
||||
`Search param "${key}" resets unknown param "${resetKey}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const definition: SearchParamsDefinition<Shape> = {
|
||||
shape,
|
||||
keys,
|
||||
parse: (input) => {
|
||||
const searchParams = toSearchParams(input);
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
result[key] = decodeParam(shape[key], searchParams.getAll(key));
|
||||
}
|
||||
return result as SearchParamsValues<Shape>;
|
||||
},
|
||||
href: (path, values, opts) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
const mode: EncodeMode = opts?.compress ? "compact" : "canonical";
|
||||
for (const key of keys) {
|
||||
if (!(key in values)) continue;
|
||||
for (const encoded of encodeParam(shape[key], values[key], mode)) {
|
||||
searchParams.append(key, encoded);
|
||||
}
|
||||
}
|
||||
const queryString = searchParams.toString();
|
||||
if (!queryString) return path;
|
||||
|
||||
return `${path}${path.includes("?") ? "&" : "?"}${queryString}`;
|
||||
},
|
||||
shouldRevalidate: (args) => {
|
||||
if (args.currentUrl.pathname !== args.nextUrl.pathname) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
if (args.formMethod && args.formMethod !== "GET") {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
if (args.currentUrl.href === args.nextUrl.href) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
const current = args.currentUrl.searchParams;
|
||||
const next = args.nextUrl.searchParams;
|
||||
if (unknownParamsChanged(keys, current, next)) {
|
||||
return args.defaultShouldRevalidate;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const def = shape[key];
|
||||
if (!def.loader) continue;
|
||||
if (
|
||||
!isDeepEqual(
|
||||
decodeParam(def, current.getAll(key)),
|
||||
decodeParam(def, next.getAll(key)),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
return definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes one param from its raw URL values, resolving to the default when the
|
||||
* param is missing or malformed. Uses a per-param cache keyed on the raw values
|
||||
* so repeated decodes of the same string return the same reference. Params
|
||||
* declared `timeDependent` skip the cache and decode fresh every time.
|
||||
*/
|
||||
export function decodeParam<T>(def: ParamDef<T>, values: string[]): T {
|
||||
if (def.timeDependent) return def.decodeValues(values);
|
||||
|
||||
const cacheKey = JSON.stringify(values);
|
||||
if (def.decodeCache.has(cacheKey)) {
|
||||
return def.decodeCache.get(cacheKey) as T;
|
||||
}
|
||||
|
||||
const decoded = def.decodeValues(values);
|
||||
|
||||
if (def.decodeCache.size >= DECODE_CACHE_MAX_SIZE) {
|
||||
def.decodeCache.clear();
|
||||
}
|
||||
def.decodeCache.set(cacheKey, decoded);
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes one param value to its URL values. Returns an empty array (param
|
||||
* absent) for values equal to the default.
|
||||
*/
|
||||
export function encodeParam<T>(
|
||||
def: ParamDef<T>,
|
||||
value: T,
|
||||
mode: EncodeMode = "canonical",
|
||||
): string[] {
|
||||
if (isDeepEqual(value, def.default)) return [];
|
||||
|
||||
return def.encodePlain(value).map((plain) => wrapValue(plain, def, mode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a partial values update on top of the current search params,
|
||||
* preserving params outside the definition, applying declared `resets` and
|
||||
* omitting values equal to their defaults. A key written in the same batch is
|
||||
* never reset by another key of that batch.
|
||||
*/
|
||||
export function applyToSearchParams<Shape extends AnyShape>(
|
||||
definition: SearchParamsDefinition<Shape>,
|
||||
current: URLSearchParams,
|
||||
updates: Partial<SearchParamsValues<Shape>>,
|
||||
): { next: URLSearchParams; navigationNeeded: boolean } {
|
||||
const next = new URLSearchParams(current);
|
||||
let navigationNeeded = false;
|
||||
|
||||
const updatedKeys = definition.keys.filter((key) => key in updates);
|
||||
|
||||
const resetKeys = new Set<string>();
|
||||
for (const key of updatedKeys) {
|
||||
for (const resetKey of definition.shape[key].resets) {
|
||||
if (!(resetKey in updates)) resetKeys.add(resetKey);
|
||||
}
|
||||
}
|
||||
|
||||
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])) {
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes only the definition's keys out of a search string. Used as a
|
||||
* cheap fingerprint: it changes exactly when one of the definition's params
|
||||
* changes in the URL.
|
||||
*/
|
||||
export function pickRelevantSearch(keys: string[], search: string): string {
|
||||
const searchParams = new URLSearchParams(search);
|
||||
const picked = new URLSearchParams();
|
||||
for (const key of keys) {
|
||||
for (const value of searchParams.getAll(key)) {
|
||||
picked.append(key, value);
|
||||
}
|
||||
}
|
||||
return picked.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Param declaration helpers. `SP.param` is the canonical declaration deriving
|
||||
* the URL encoding from the value schema; `SP.json` and `SP.custom` are the
|
||||
* explicit helpers for shapes outside the derivation table.
|
||||
*/
|
||||
export const SP = {
|
||||
/**
|
||||
* Declares a param whose URL encoding is derived from the zod value
|
||||
* schema's type tree. Supported shapes: strings, numbers, booleans, string
|
||||
* and number enums/literals, same-base-type unions, arrays of those
|
||||
* (encoded as repeated keys) and a top-level `.nullable()` wrapper (`null`
|
||||
* encodes as param absent, so `default` is omitted for those). Anything else
|
||||
* is a `define()`-time error — use `SP.json` or `SP.custom` instead.
|
||||
*/
|
||||
param<S extends z.ZodType>(
|
||||
schema: S,
|
||||
opts: ParamOptions<z.output<S>>,
|
||||
): ParamDef<z.output<S>> {
|
||||
const resolved = resolveOptions(opts);
|
||||
let core: z.ZodType = schema;
|
||||
|
||||
if (core instanceof z.ZodOptional) {
|
||||
throw new Error(
|
||||
"Search params use .nullable() instead of .optional() (null encodes as param absent)",
|
||||
);
|
||||
}
|
||||
if (core instanceof z.ZodNullable) {
|
||||
if (resolved.default !== null) {
|
||||
throw new Error(
|
||||
"A .nullable() search param must have null as its default, otherwise null and the default could not be told apart in the URL",
|
||||
);
|
||||
}
|
||||
core = core.unwrap() as z.ZodType;
|
||||
}
|
||||
|
||||
if (core instanceof z.ZodArray) {
|
||||
const itemBase = deriveScalarBase(core.element as z.ZodType);
|
||||
if (!itemBase) {
|
||||
throw new Error(
|
||||
`Cannot derive an URL encoding for the array item schema of a search param (got ${describeSchema(core.element as z.ZodType)}). Use SP.json or SP.custom.`,
|
||||
);
|
||||
}
|
||||
return arrayParam(schema, core, itemBase, resolved);
|
||||
}
|
||||
|
||||
const base = deriveScalarBase(core);
|
||||
if (!base) {
|
||||
throw new Error(
|
||||
`Cannot derive an URL encoding for a search param schema (got ${describeSchema(core)}). Use SP.json or SP.custom.`,
|
||||
);
|
||||
}
|
||||
return scalarParam(schema, base, resolved);
|
||||
},
|
||||
|
||||
/** Declares the 1-based `page` param of a paginated route, as `useSearchParamPagination` expects it. */
|
||||
page(opts?: { max?: number; resets?: string[] }): ParamDef<number> {
|
||||
return SP.param(
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(opts?.max ?? DEFAULT_MAX_PAGE),
|
||||
{ default: 1, loader: true, resets: opts?.resets },
|
||||
);
|
||||
},
|
||||
|
||||
/** Declares a param encoded as `JSON.stringify` in a single value. For objects and whole-array-as-one-param values. */
|
||||
json<S extends z.ZodType>(
|
||||
schema: S,
|
||||
opts: ParamOptions<z.output<S>>,
|
||||
): ParamDef<z.output<S>> {
|
||||
const resolved = resolveOptions(opts);
|
||||
|
||||
return {
|
||||
...baseDef(resolved),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return resolved.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return resolved.default;
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(plain);
|
||||
} catch {
|
||||
return resolved.default;
|
||||
}
|
||||
const parsed = schema.safeParse(json);
|
||||
return parsed.success ? parsed.data : resolved.default;
|
||||
},
|
||||
encodePlain: (value) => [JSON.stringify(value)],
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Escape hatch: declares a param from a `z.codec(z.string(), valueSchema, ...)`
|
||||
* passed directly. The codec's `decode` may accept legacy formats while
|
||||
* `encode` always emits the canonical one.
|
||||
*/
|
||||
custom<Value>(
|
||||
codec: z.ZodType<Value, string | null>,
|
||||
opts: ParamOptions<Value>,
|
||||
): ParamDef<Value> {
|
||||
const resolved = resolveOptions(opts);
|
||||
|
||||
return {
|
||||
...baseDef(resolved),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return resolved.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return resolved.default;
|
||||
const parsed = z.safeDecode(codec, plain);
|
||||
return parsed.success ? parsed.data : resolved.default;
|
||||
},
|
||||
encodePlain: (value) => {
|
||||
const encoded = z.safeEncode(codec, value);
|
||||
if (!encoded.success || typeof encoded.data !== "string") {
|
||||
throw new Error(
|
||||
"Encoding a search param value failed; SP.custom codecs must encode every value of their type",
|
||||
);
|
||||
}
|
||||
return [encoded.data];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function resolveOptions<T>(opts: ParamOptions<T>): ResolvedParamOptions<T> {
|
||||
const { default: defaultValue, ...rest } = opts as ParamOptionsBase & {
|
||||
default?: T;
|
||||
};
|
||||
|
||||
return { ...rest, default: (defaultValue ?? null) as T };
|
||||
}
|
||||
|
||||
function baseDef<T>(
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): Pick<
|
||||
ParamDef<T>,
|
||||
"default" | "loader" | "resets" | "compress" | "timeDependent" | "decodeCache"
|
||||
> {
|
||||
return {
|
||||
default: opts.default,
|
||||
loader: opts.loader,
|
||||
resets: opts.resets ?? [],
|
||||
compress: opts.compress ?? false,
|
||||
timeDependent: opts.timeDependent ?? false,
|
||||
decodeCache: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function scalarParam<T>(
|
||||
schema: z.ZodType,
|
||||
base: ScalarBase,
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): ParamDef<T> {
|
||||
return {
|
||||
...baseDef(opts),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return opts.default;
|
||||
const plain = unwrapValue(values[0]);
|
||||
if (plain === DECODE_FAILED) return opts.default;
|
||||
const candidate = plainToScalar(plain, base);
|
||||
if (candidate === DECODE_FAILED) return opts.default;
|
||||
const parsed = schema.safeParse(candidate);
|
||||
return parsed.success ? (parsed.data as T) : opts.default;
|
||||
},
|
||||
encodePlain: (value) => [String(value)],
|
||||
};
|
||||
}
|
||||
|
||||
function arrayParam<T>(
|
||||
schema: z.ZodType,
|
||||
arraySchema: z.ZodArray,
|
||||
itemBase: ScalarBase,
|
||||
opts: ResolvedParamOptions<T>,
|
||||
): ParamDef<T> {
|
||||
const itemSchema = arraySchema.element as z.ZodType;
|
||||
|
||||
return {
|
||||
...baseDef(opts),
|
||||
decodeValues: (values) => {
|
||||
if (values.length === 0) return opts.default;
|
||||
|
||||
const plains: string[] = [];
|
||||
for (const value of values) {
|
||||
const plain = unwrapValue(value);
|
||||
if (plain !== DECODE_FAILED) plains.push(plain);
|
||||
}
|
||||
|
||||
let items = plains;
|
||||
if (plains.length === 1) {
|
||||
if (plains[0] === "") {
|
||||
items = [];
|
||||
} else if (plains[0].startsWith("[")) {
|
||||
// legacy decode fallback for JSON-encoded arrays
|
||||
try {
|
||||
const parsed = JSON.parse(plains[0]);
|
||||
if (Array.isArray(parsed)) {
|
||||
items = parsed.map((member) => String(member));
|
||||
}
|
||||
} catch {}
|
||||
} else if (itemBase === "number" && plains[0].includes(",")) {
|
||||
// legacy decode fallback for comma-joined numeric arrays
|
||||
items = plains[0].split(",");
|
||||
}
|
||||
}
|
||||
|
||||
const members: unknown[] = [];
|
||||
for (const item of items) {
|
||||
const candidate = plainToScalar(item, itemBase);
|
||||
if (candidate === DECODE_FAILED) continue;
|
||||
const parsed = itemSchema.safeParse(candidate);
|
||||
if (parsed.success) members.push(parsed.data);
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(members);
|
||||
return parsed.success ? (parsed.data as T) : opts.default;
|
||||
},
|
||||
encodePlain: (value) => {
|
||||
const items = value as unknown[];
|
||||
if (items.length === 0) return [""];
|
||||
return items.map((item) => String(item));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function plainToScalar(
|
||||
plain: string,
|
||||
base: ScalarBase,
|
||||
): string | number | boolean | typeof DECODE_FAILED {
|
||||
if (base === "string") return plain;
|
||||
|
||||
if (base === "number") {
|
||||
if (plain.trim() === "") return DECODE_FAILED;
|
||||
const parsed = Number(plain);
|
||||
return Number.isFinite(parsed) ? parsed : DECODE_FAILED;
|
||||
}
|
||||
|
||||
if (plain === "true") return true;
|
||||
if (plain === "false") return false;
|
||||
return DECODE_FAILED;
|
||||
}
|
||||
|
||||
function deriveScalarBase(schema: z.ZodType): ScalarBase | null {
|
||||
if (schema instanceof z.ZodString) return "string";
|
||||
if (schema instanceof z.ZodNumber) return "number";
|
||||
if (schema instanceof z.ZodBoolean) return "boolean";
|
||||
|
||||
if (schema instanceof z.ZodEnum) {
|
||||
return uniformTypeOf(schema.options);
|
||||
}
|
||||
if (schema instanceof z.ZodLiteral) {
|
||||
return uniformTypeOf(Array.from(schema.values));
|
||||
}
|
||||
if (schema instanceof z.ZodUnion) {
|
||||
const bases = (schema.options as z.ZodType[]).map(deriveScalarBase);
|
||||
if (bases[0] && bases.every((base) => base === bases[0])) {
|
||||
return bases[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniformTypeOf(values: unknown[]): ScalarBase | null {
|
||||
const types = new Set(values.map((value) => typeof value));
|
||||
if (types.size !== 1) return null;
|
||||
|
||||
const type = Array.from(types)[0];
|
||||
if (type === "string" || type === "number" || type === "boolean") {
|
||||
return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function describeSchema(schema: z.ZodType) {
|
||||
return schema.constructor.name;
|
||||
}
|
||||
|
||||
function toSearchParams(
|
||||
input: Request | URL | URLSearchParams,
|
||||
): URLSearchParams {
|
||||
if (input instanceof URLSearchParams) return input;
|
||||
if (input instanceof URL) return input.searchParams;
|
||||
return new URL(input.url).searchParams;
|
||||
}
|
||||
|
||||
function unknownParamsChanged(
|
||||
knownKeys: string[],
|
||||
current: URLSearchParams,
|
||||
next: URLSearchParams,
|
||||
): boolean {
|
||||
const unknownKeys = new Set<string>();
|
||||
for (const key of current.keys()) {
|
||||
if (!knownKeys.includes(key)) unknownKeys.add(key);
|
||||
}
|
||||
for (const key of next.keys()) {
|
||||
if (!knownKeys.includes(key)) unknownKeys.add(key);
|
||||
}
|
||||
|
||||
for (const key of unknownKeys) {
|
||||
if (!isDeepEqual(current.getAll(key), next.getAll(key))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function wrapValue<T>(plain: string, def: ParamDef<T>, mode: EncodeMode) {
|
||||
if (def.compress) return compressTransportValue(plain);
|
||||
|
||||
if (mode === "compact") {
|
||||
const compressed = compressTransportValue(plain);
|
||||
const escaped = escapePlainValue(plain);
|
||||
if (urlEncodedLength(compressed) < urlEncodedLength(escaped)) {
|
||||
return compressed;
|
||||
}
|
||||
}
|
||||
|
||||
return escapePlainValue(plain);
|
||||
}
|
||||
|
||||
/** Length the value takes in the URL, i.e. percent-encoded as `URLSearchParams` writes it. */
|
||||
function urlEncodedLength(value: string) {
|
||||
return new URLSearchParams([["", value]]).toString().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a plain encoded value in the compressed transport form. Any param can
|
||||
* arrive compressed like this; used by round-trip tests and share links.
|
||||
*/
|
||||
export function compressTransportValue(plain: string) {
|
||||
return `${COMPRESSED_PREFIX}${compressToBase64(plain, { urlSafe: true })}`;
|
||||
}
|
||||
|
||||
function escapePlainValue(plain: string) {
|
||||
if (!plain.startsWith(COMPRESSED_PREFIX)) return plain;
|
||||
|
||||
return `${ESCAPED_PREFIX}${plain.slice(COMPRESSED_PREFIX.length)}`;
|
||||
}
|
||||
|
||||
function unwrapValue(raw: string): string | typeof DECODE_FAILED {
|
||||
if (raw.startsWith(ESCAPED_PREFIX)) {
|
||||
return `${COMPRESSED_PREFIX}${raw.slice(ESCAPED_PREFIX.length)}`;
|
||||
}
|
||||
|
||||
if (raw.startsWith(COMPRESSED_PREFIX)) {
|
||||
const decompressed = decompressFromBase64(
|
||||
raw.slice(COMPRESSED_PREFIX.length),
|
||||
{ maxDecompressedBytes: MAX_DECOMPRESSED_VALUE_BYTES },
|
||||
);
|
||||
return decompressed === null ? DECODE_FAILED : decompressed;
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
46
apps/web-react/app/modules/search-params/store.ts
Normal file
46
apps/web-react/app/modules/search-params/store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
let historyPatched = false;
|
||||
|
||||
/**
|
||||
* Subscribes to changes of the current URL search string, from any write
|
||||
* channel: react-router navigations (which call `history.pushState` /
|
||||
* `history.replaceState` under the hood), our own `history.replaceState`
|
||||
* writes and back/forward navigation.
|
||||
*
|
||||
* Note that our own `history.replaceState` writes (`loader: false` params) are
|
||||
* invisible to react-router: `useLocation()` keeps returning the search string
|
||||
* of the last navigation. Read those params through this module's hooks.
|
||||
*/
|
||||
export function subscribe(listener: () => void) {
|
||||
patchHistoryOnce();
|
||||
listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function patchHistoryOnce() {
|
||||
if (historyPatched || typeof window === "undefined") return;
|
||||
historyPatched = true;
|
||||
|
||||
const originalPushState = window.history.pushState.bind(window.history);
|
||||
const originalReplaceState = window.history.replaceState.bind(window.history);
|
||||
|
||||
window.history.pushState = (...args) => {
|
||||
originalPushState(...args);
|
||||
notify();
|
||||
};
|
||||
window.history.replaceState = (...args) => {
|
||||
originalReplaceState(...args);
|
||||
notify();
|
||||
};
|
||||
window.addEventListener("popstate", notify);
|
||||
}
|
||||
Reference in New Issue
Block a user