Skip to content

Typed search params with Standard Schema - #1040

Open
natew wants to merge 8 commits into
mainfrom
tm/oss-search-schema-1039
Open

natew wants to merge 8 commits into
mainfrom
tm/oss-search-schema-1039

Conversation

@natew

@natew natew commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Implements #1039 on the Standard Schema protocol. No Zod dependency, adapter or inspection. Owner direction and design are in the Contrast plan plans/contrast/one-search-params-schema.md. This approves building; merging and any minor release stay with review.

API

  • useSearchParams() and useSearchParams({ global }) still return a read-only URLSearchParams.
  • useSearchParams(schema, { global? }) returns [output, update]. The schema gets every query key as a string, or a string array when the key repeats. Path params are left out.
  • update(partial, { replace? }) merges into the current URL via router.push (default) or router.replace. Path params, other keys and the hash stay. null/undefined removes a key. Writable values are string, number, boolean and arrays of those (repeated keys). Fields whose output is not writable (a Date) take the schema's input type; anything else throws TypeError.
  • A rejected URL throws SearchValidationError in render, so the nearest error boundary isolates it. Async schemas throw a TypeError.
  • Link gains searchParams, typed from the destination route. It is a second overload, so existing Link typing (including template-literal hrefs) is unchanged.
  • validateSearch and validateParams now share one synchronous Standard Schema runner; their exports are unchanged.

Discovery and generated types

generateRouteTypes parses each route file, its ancestor layouts and their static relative or tsconfig-path imports with oxc, finds useSearchParams(schema) calls and writes SearchParamTypes into routes.d.ts as typeof import('./file')['name'], so TypeScript derives the shape. No app module is evaluated and no schema internals are read. Editing a scanned file regenerates the types (checked live: adding and removing a component updates the route's keys).

Choices to review

  • Flat keys (?page=2, arrays as repeated keys), not the issue's file-name prefix or dot-notation. Prefixes would break existing URLs and renames. Two components on one route using the same key get the intersection of their types. The owner has not picked key naming; this is the default.
  • getPathFromState fix. The router read repeated keys as arrays but wrote arrays as comma-joined text. It now writes repeated keys (unit test added). This changes URLs written for array params from a=1,2 to a=1&a=2.
  • Link's params object still comma-joins arrays through resolveHref; searchParams uses repeated keys. Left as is.
  • Discovery limits: the schema must be an exported const (or imported from one) or a package export; inline schemas and dynamic or template hrefs stay untyped, and an unassociated call logs a one-time warning. Route type generation previously read file names only; it now also reads route sources.
  • Schemas must accept string | string[] where a key may repeat.
  • No function-updater form for update.

Proof

  • Probe across Zod 4, Valibot, ArkType and a hand-written schema: input vs output types, update types, default, transform, invalid, async, and serialization edges (null/undefined removal, empty array, special characters, key collision, hash, repeated unrelated keys, rejected objects/Dates). tsc and runtime both clean.
  • Web flow in tests/test (Playwright, 28 checks): defaults, push and replace history, no remount, unrelated keys, arrays as repeated keys, back/forward, hash, malformed value breaking only its boundary, typed link hrefs, dynamic route with path param. Type errors confirmed for misspelled keys, wrong values and object-href forms.
  • oxlint clean; packages/one unit tests pass except package-exports (vite-native-hmr dist not built in the worktree), packedClosure and the calendar ios test (the last also fails on main).
  • Native: no native build. Covered only by the existing unit/protocol tests. Unverified on native: whether useParams includes extra keys there, and update from an unfocused screen.

natew added 3 commits October 24, 2026 06:40
useSearchParams(schema) returns [output, update] and validates the url with any Standard Schema. Route type generation finds the schemas components pass to the hook and types Link searchParams per destination. The router now writes array params as repeated keys.

Validation: focused probe across zod, valibot, arktype and a hand-written schema (types and runtime), web flow with 28 interaction checks against the tests app, oxlint, unit tests in packages/one (remaining failures are unbuilt vite-native-hmr dist, native closure and a calendar test that also fails on main). No native build.
The schema input keeps only keys in the route's own href query, so a layout
no longer sees its child's path params (id, catch-all slug) as query, and a
page no longer picks up a sibling tab's stale path param or the '#' key.
An unfocused screen no longer carries the focused route's hash on update.
The item fixture gets generateStaticParams so build:web passes. Adds layout,
catch-all and tabs fixtures. Docs note the repeated-key URL change, Link
params comma-joining, and the async schema TypeError.

Validation: real-router chromium probe against one dev (before: 6 failures,
after: 20/20 pass); bun run build:web in tests/test passes; tsc --noEmit in
packages/one and oxlint on touched files clean. Not run: test:prod, native.

Team-Machine-Session: r125501
useSearchParams(schema, { global: true }) in a mounted unfocused page
filtered the active params by the page's own href and updated the page's own
path. Global now takes params, href and pathname from the store route info.
Adds a global reader to the tab one fixture.

Validation: real-router chromium probe, new case failed before (read page 1
with key a, update wrote /tabs/one?a=1&page=2), 24/24 pass after; build:web,
tsc --noEmit in packages/one and oxlint clean. Not run: test:prod, native.

Team-Machine-Session: r125501
@natew

natew commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

The approved feature is included in the held One minor batch #1027 on tm/macos-resume at f129407d405971e87020a698faa66a4d72827194, preserving the original authors through three focused picks:

  • 34de23710d → abd23a2131
  • b344b8b7e4 → 6314681e1f
  • c95c352b8b → f129407d40

The full feature patch identity is unchanged. Only the two root export files combined pre-existing release-branch context automatically. The existing reviewer has been asked to check that overlap through the original review chain.

Validation on the containing branch: normal One package build and typecheck pass; existing routing/native-engine checks report 10 passed and one pre-existing skipped test. Generated declarations remain clean. No new browser or native runtime validation is claimed here.

This PR remains the source and review record. Delivery is through the held minor batch, rather than a separate main patch. Broader feature and stable-release approval remain unchanged.

@redbar0n

redbar0n commented Oct 25, 2026 •

Copy link
Copy Markdown

Awesome! I recommend renaming validateSearch to validateSearchParams for clarity and coherence.

natew added 5 commits October 24, 2026 18:08
…aders

A server transform gives route modules the search schemas found in the route and its layouts. The server validates the query before the loader (400 on failure) and passes searchParams. Query parsing no longer assigns __proto__ keys.

Validation: tsc; typed-routes vitest; dev server and one build + one serve against tests/test (transformed values, 400 with no loader call, composition, hot edit and deletion, hooks/URL behavior); tests/test/tests/search-params-loader.test.ts passes. Worker and Vercel paths not run.
Team-Machine-Session: r126840
Validated searchParams now reach ssr loaders on client navigation and refetch.
The query travels as __one_search; loader state and preload keys include it.

Validation: tsc --noEmit in packages/one; vitest for useLoader, cleanUrl, createHandleRequest, workerHandler and router (196 passed); tests/test search-params-loader (3 tests, headless Chromium, dev and prod servers) passed. Worker handler and Vercel paths, native and ssg not run.
Team-Machine-Session: r126840
The generated Vercel handler reads __one_search and validates through the shared
runtime. A rejected query answers a loader request with a 400 marker; client
navigation loads the url as a document and refetch rejects.

Validation: tsc --noEmit in packages/one; vitest for useLoader, cleanUrl, createHandleRequest, workerHandler, router, vercel (196 passed); tests/test search-params-loader (3 tests, headless Chromium, dev and prod servers) passed; generated Vercel handler run locally against the built tests/test server dist with VERCEL=1 (loader valid/invalid/no query, document valid/invalid). No Vercel deploy, dynamic route rewrite, worker, native or ssg run.
Team-Machine-Session: r126840
Fix the stale source and declaration comments identified by independent review; include the native production initial-load limitation.

Validation: diff check passed; comment-only changes in source and emitted declaration, runtime unchanged from approved3317a56e7f.
Team-Machine-Session: r116738
…test

Wait for a hydrated marker before the first click and for the url query after the data changes, replacing the fixed sleep.

Validation: search-params-loader test passed in tests/test under TEST_ENV=dev and prod, and once on a cold dev server in an isolated consumer of canary 2.0.0-0.canary.1792904062387.
Team-Machine-Session: r126840

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants