Skip to content

feat(): Add opt-in raw search input for validateSearch#6946

Open
sandy2008 wants to merge 4 commits intoTanStack:mainfrom
sandy2008:main
Open

feat(): Add opt-in raw search input for validateSearch#6946
sandy2008 wants to merge 4 commits intoTanStack:mainfrom
sandy2008:main

Conversation

@sandy2008
Copy link

@sandy2008 sandy2008 commented Mar 17, 2026

Summary

Fixes #537 and #6044.

Today, validateSearch receives search params after the router’s default parsing/coercion step. That means numeric-looking values like ?folder=34324324235325352523 can be converted before validation, which breaks string schemas and can lose precision for large integer-like values.

This PR adds a non-breaking, opt-in way for validators to receive raw URL search values instead: validateSearchWithRawInput(...).

What changed

  • Added validateSearchWithRawInput(...) in router-core
  • Re-exported it from React, Solid, and Vue router packages
  • Preserved existing default search parsing behavior for all current callers
  • Updated route matching so wrapped validators receive raw decoded string values from the URL
  • Added tests covering:
    • existing numeric parsing behavior still works
    • wrapped validators preserve numeric-looking strings from the URL
  • Updated docs to clarify the default validateSearch input and document the new opt-in helper

Why this approach

Changing the default parser behavior would be breaking because existing apps rely on values like ?page=1 or ?enabled=true being coerced before validation.

This approach fixes the reported bug without changing current behavior for existing validators. Users only opt into raw string input when they need it.

Example

validateSearch: validateSearchWithRawInput(
  z.object({
    folder: z.string(),
  }),
)

Testing

Ran:

  • CI=1 NX_DAEMON=false pnpm nx run-many --target=test:unit --projects=@tanstack/router-core,@tanstack/react-router,@tanstack/zod-adapter --outputStyle=stream --skipRemoteCache
  • CI=1 NX_DAEMON=false pnpm nx run-many --target=test:types --projects=@tanstack/router-core,@tanstack/react-router,@tanstack/solid-router,@tanstack/vue-router,@tanstack/zod-adapter --outputStyle=stream --skipRemoteCache
  • CI=1 NX_DAEMON=false pnpm nx run-many --target=test:eslint --projects=@tanstack/router-core,@tanstack/react-router,@tanstack/solid-router,@tanstack/vue-router,@tanstack/zod-adapter --outputStyle=stream --skipRemoteCache

Summary by CodeRabbit

  • New Features

    • Added validateSearchWithRawInput to opt into validating raw URL search values.
    • Search parsing now accepts a pluggable parser for custom value conversion.
  • Documentation

    • Renamed and clarified the search validation parameter name and semantics.
    • Added guide examples demonstrating raw-URL validation usage.
  • Tests

    • Added tests covering raw-URL search validation and preservation of raw string values.

@changeset-bot
Copy link

changeset-bot bot commented Mar 17, 2026

⚠️ No Changeset found

Latest commit: 5f41574

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

Preserves raw URL search strings on locations, adds a validator marker and wrapper (validateSearchWithRawInput) to opt validators into receiving raw string input, makes qss.decode's value parsing pluggable, and threads raw-search data through routing and validation flows.

Changes

Cohort / File(s) Summary
Documentation
docs/router/api/router/RouteOptionsType.md, docs/router/guide/search-params.md
Clarified validateSearch parameter name (rawSearchParamssearchParams) and added an example showing validateSearchWithRawInput to validate raw URL string values (zod example).
Public API Re-exports
packages/router-core/src/index.ts, packages/react-router/src/index.tsx, packages/solid-router/src/index.tsx, packages/vue-router/src/index.tsx
Re-exported validateSearchWithRawInput from router-core in framework-specific entry points.
Query-string parsing
packages/router-core/src/qss.ts
decode gains an optional parser: (value: string) => unknown = toValue parameter so callers can control how raw string values are converted.
Search validator utilities
packages/router-core/src/searchValidator.ts
Added a raw-input marker symbol, validatorUsesRawSearchInput type guard, and validateSearchWithRawInput wrapper that marks validators to receive raw URL input.
Router core logic
packages/router-core/src/router.ts
Attach non-enumerable _rawSearch to locations, decode raw search in fast/rewrite paths, reattach _rawSearch when copying locations, and select validation input (raw-augmented vs parsed) based on validator marker.
Tests
packages/zod-adapter/tests/index.test.tsx
Added tests covering numeric-string behavior: confirms default parsed input, ensures validateSearchWithRawInput preserves raw numeric-looking strings, and that buildLocation with search: true preserves raw strings.

Sequence Diagram(s)

sequenceDiagram
    participant URL as "URL / Raw Search"
    participant Router as "Router"
    participant QS as "qss.decode(parser)"
    participant Validator as "Search Validator"
    participant Route as "Route Handler"

    URL->>Router: incoming request with raw query string (?page=0&folder=123)
    Router->>QS: decode(rawSearch, parser?)
    QS-->>Router: parsedSearch (e.g., {page: 0, folder: "123"})
    Router->>Router: withRawSearch(location, rawSearch) (attach non-enumerable _rawSearch)
    Router->>Validator: validatorUsesRawSearchInput? (check marker)
    alt Validator requires raw input
        Router->>Validator: provide merged raw+parsed input (raw strings where applicable)
    else Validator uses parsed only
        Router->>Validator: provide parsedSearch
    end
    Validator-->>Router: validation result
    Router->>Route: deliver validated params to route handler
Loading

Estimated Code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I nibble at queries, raw strings kept tight,

Numbers that look like numbers now stay as they write.
A marker and wrapper let validators choose,
I hop through routes keeping raw search in use.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The PR successfully addresses issue #537 by implementing validateSearchWithRawInput to allow validators to receive raw decoded string values instead of parsed/coerced values, solving the numeric string validation problem.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing raw search input support: new helper function, re-exports across packages, internal routing logic updates, documentation, and tests validating the feature.
Title check ✅ Passed The title accurately summarizes the main change: adding an opt-in feature (validateSearchWithRawInput) for validators to receive raw search input.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

You can customize the tone of the review comments and chat replies.

Configure the tone_instructions setting to customize the tone of the review comments and chat replies. For example, you can set the tone to Act like a strict teacher, Act like a pirate and more.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/router-core/src/qss.ts (1)

64-67: Tighten decode API types instead of any.

Line 65 and Line 67 use any, which weakens strict-mode guarantees at the public boundary. Prefer typed URLSearchParams init input and a typed object return.

Type-safe signature diff
 export function decode(
-  str: any,
+  str: ConstructorParameters<typeof URLSearchParams>[0],
   parser: (value: string) => unknown = toValue,
-): any {
+): Record<string, unknown> {

As per coding guidelines, **/*.{ts,tsx}: Use TypeScript strict mode with extensive type safety.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/qss.ts` around lines 64 - 67, The public decode
function currently uses `any` for its `str` parameter and return type; tighten
the API by changing `str` to `URLSearchParamsInit` (the allowed inputs for
URLSearchParams) and give the function a concrete return type such as
`Record<string, unknown>` (or `Record<string, string | string[]>` if you want to
preserve multi-value semantics). Keep the `parser` parameter as `(value: string)
=> unknown` (or narrow it to a generic if desired), update the `decode`
signature accordingly, and ensure any internal usage of `decode` and the
`toValue` parser matches the new types so callers and strict-mode type checks
are satisfied.
packages/router-core/src/searchValidator.ts (1)

36-39: Consider adding a brief comment explaining the as never cast.

The cast validator(input as never) is necessary because TypeScript cannot narrow validator to ValidatorFn in the else branch, but a brief inline comment would help future maintainers understand why this cast is safe.

📝 Suggested enhancement
+  // Fallback: wrap function validators
+  // The `as never` cast is safe because we've ruled out object validators above
   const wrapped = ((input: unknown) =>
     validator(input as never)) as TValidator & RawSearchInputMarked
   wrapped[rawSearchInputMarker] = true
   return wrapped
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/searchValidator.ts` around lines 36 - 39, Add a
brief inline comment above the `validator(input as never)` cast in the `wrapped`
creation to explain why `as never` is used and why it is safe: note that
TypeScript cannot narrow `validator` to `ValidatorFn` in this branch, but we
know at runtime the `validator` accepts the input shape so forcing the cast is
safe; reference the `wrapped` variable, `validator` function, and
`rawSearchInputMarker` assignment so maintainers can find the context quickly.
packages/zod-adapter/tests/index.test.tsx (1)

4-12: Fix import sorting.

ESLint reports that imports should be sorted alphabetically. Link should come before validateSearchWithRawInput.

🔧 Suggested fix
 import {
   createMemoryHistory,
   createRootRoute,
   createRoute,
   createRouter,
   Link,
   RouterProvider,
   validateSearchWithRawInput,
 } from '@tanstack/react-router'

Note: The current order appears correct (Link at line 9 comes before validateSearchWithRawInput at line 11). The ESLint error may be a false positive or related to the position of createMemoryHistory which was added. Please verify the actual import order in your editor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/zod-adapter/tests/index.test.tsx` around lines 4 - 12, The import
list is failing ESLint's alphabetical order rule; reorder the named imports so
they are alphabetized (e.g., createMemoryHistory, createRootRoute, createRoute,
createRouter, Link should come before RouterProvider and
validateSearchWithRawInput, with validateSearchWithRawInput after Link), update
the import statement that contains
createMemoryHistory/createRootRoute/createRoute/createRouter/Link/RouterProvider/validateSearchWithRawInput
accordingly, and re-run the linter or run eslint --fix to confirm the sorting
issue is resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/router-core/src/router.ts`:
- Around line 1502-1516: The lightweight matching path (matchRoutesLightweight)
and buildLocation are still using coerced location.search rather than the
raw-input selection used in the full match, so routes with validateSearch +
strict can lose raw string values; refactor the raw-input selection into a
shared helper (e.g., reuse the logic from validatorUsesRawSearchInput and the
rawLocationSearch/parentStrictSearch merge) and call that helper from both the
full-match branch (where validateSearch(...) uses searchValidationInput) and
from matchRoutesLightweight/buildLocation so validateSearch always receives the
same raw vs coerced input selection.

---

Nitpick comments:
In `@packages/router-core/src/qss.ts`:
- Around line 64-67: The public decode function currently uses `any` for its
`str` parameter and return type; tighten the API by changing `str` to
`URLSearchParamsInit` (the allowed inputs for URLSearchParams) and give the
function a concrete return type such as `Record<string, unknown>` (or
`Record<string, string | string[]>` if you want to preserve multi-value
semantics). Keep the `parser` parameter as `(value: string) => unknown` (or
narrow it to a generic if desired), update the `decode` signature accordingly,
and ensure any internal usage of `decode` and the `toValue` parser matches the
new types so callers and strict-mode type checks are satisfied.

In `@packages/router-core/src/searchValidator.ts`:
- Around line 36-39: Add a brief inline comment above the `validator(input as
never)` cast in the `wrapped` creation to explain why `as never` is used and why
it is safe: note that TypeScript cannot narrow `validator` to `ValidatorFn` in
this branch, but we know at runtime the `validator` accepts the input shape so
forcing the cast is safe; reference the `wrapped` variable, `validator`
function, and `rawSearchInputMarker` assignment so maintainers can find the
context quickly.

In `@packages/zod-adapter/tests/index.test.tsx`:
- Around line 4-12: The import list is failing ESLint's alphabetical order rule;
reorder the named imports so they are alphabetized (e.g., createMemoryHistory,
createRootRoute, createRoute, createRouter, Link should come before
RouterProvider and validateSearchWithRawInput, with validateSearchWithRawInput
after Link), update the import statement that contains
createMemoryHistory/createRootRoute/createRoute/createRouter/Link/RouterProvider/validateSearchWithRawInput
accordingly, and re-run the linter or run eslint --fix to confirm the sorting
issue is resolved.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ad2f3b39-b2b5-400d-b1b1-6f6c94b30c32

📥 Commits

Reviewing files that changed from the base of the PR and between 13ef4c2 and 1dd05cb.

📒 Files selected for processing (10)
  • docs/router/api/router/RouteOptionsType.md
  • docs/router/guide/search-params.md
  • packages/react-router/src/index.tsx
  • packages/router-core/src/index.ts
  • packages/router-core/src/qss.ts
  • packages/router-core/src/router.ts
  • packages/router-core/src/searchValidator.ts
  • packages/solid-router/src/index.tsx
  • packages/vue-router/src/index.tsx
  • packages/zod-adapter/tests/index.test.tsx

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/zod-adapter/tests/index.test.tsx`:
- Around line 4-12: The import members in the declaration containing
createMemoryHistory, createRootRoute, createRoute, createRouter, Link,
RouterProvider, and validateSearchWithRawInput are not alphabetically ordered
and fail the ESLint sort-imports rule; reorder the named imports in that single
import statement to alphabetical order (e.g., createMemoryHistory, createRoute,
createRootRoute, createRouter, Link, RouterProvider, validateSearchWithRawInput)
so the import line with those symbols satisfies the linter.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d421b1c5-0fa1-4660-9655-0a1f95079435

📥 Commits

Reviewing files that changed from the base of the PR and between 1dd05cb and e0f0000.

📒 Files selected for processing (2)
  • packages/router-core/src/router.ts
  • packages/zod-adapter/tests/index.test.tsx

@sandy2008 sandy2008 changed the title Add opt-in raw search input for validateSearch feat(): Add opt-in raw search input for validateSearch Mar 18, 2026
@github-actions
Copy link
Contributor

Bundle Size Benchmarks

  • Commit: b065aee927c4
  • Measured at: 2026-03-18T05:35:28.206Z
  • Baseline source: history:6651473d028a
  • Dashboard: bundle-size history
Scenario Current (gzip) Delta vs baseline Raw Brotli Trend
react-router.minimal 87.88 KiB +272 B (+0.30%) 276.78 KiB 76.32 KiB ▁▁▁▁▁▁▁▅▆▆▆█
react-router.full 90.90 KiB +262 B (+0.28%) 287.00 KiB 78.84 KiB ▁▁▁▁▁▁▁▆▆▆▆█
solid-router.minimal 37.40 KiB +275 B (+0.72%) 112.10 KiB 33.54 KiB ▁▁▁▁▁▁▁▆▆▆▆█
solid-router.full 41.63 KiB +276 B (+0.65%) 124.91 KiB 37.31 KiB ▁▁▁▁▁▁▁▆▆▆▆█
vue-router.minimal 53.23 KiB +249 B (+0.46%) 152.12 KiB 47.85 KiB ▁▁▁▁▁▁▁▆▆▆▆█
vue-router.full 57.95 KiB +268 B (+0.45%) 167.05 KiB 51.90 KiB ▁▁▁▁▁▁▁▆▆▆▆█
react-start.minimal 102.24 KiB +257 B (+0.25%) 324.79 KiB 88.46 KiB ▁▁▁▁▁▁▁▇▇▇▇█
react-start.full 105.57 KiB +239 B (+0.22%) 334.60 KiB 91.18 KiB ▁▁▁▁▁▁▁▇▇▇▇█
solid-start.minimal 51.49 KiB +256 B (+0.49%) 158.54 KiB 45.41 KiB ▁▁▁▁▁▁▁▇▇▇▇█
solid-start.full 56.75 KiB +228 B (+0.39%) 174.04 KiB 49.92 KiB ▁▁▁▁▁▁▁▇▇▇▇█

Trend sparkline is historical gzip bytes ending with this PR measurement; lower is better.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Numeric strings in search params issue

1 participant