Skip to content

fix(router-core): canonicalize the search string on publicHref in parseLocation - #8450

Open
Maher-Reven wants to merge 2 commits into
TanStack:mainfrom
Maher-Reven:fix/8448-publichref-canonical-search
Open

Maher-Reven wants to merge 2 commits into
TanStack:mainfrom
Maher-Reven:fix/8448-publichref-canonical-search

Conversation

@Maher-Reven

@Maher-Reven Maher-Reven commented Sep 16, 2026

Copy link
Copy Markdown

🎯 Changes

Fixes #8448.

parseLocation has two branches. The fast one builds publicHref from the re-stringified search:

const searchStr = this.options.stringifySearch(parsedSearch)
return {
  href: pathname + searchStr + hash,
  publicHref: pathname + searchStr + hash,   // canonical
  ...
}

The other one — taken whenever a rewrite is configured, and also when the pathname needs encoding — returns the raw history href instead:

url.search = searchStr
return {
  href: url.href.replace(url.origin, ''),   // canonical
  publicHref: href,                          // raw, straight from history
  ...
}

So with any rewrite set, even a no-op one, landing on a url whose search is not in the router's canonical form (?q=a%2Ab, ?q=two%20words, ?a=1&&b=2) leaves publicHref non-canonical while href is already canonical. The transitioner compares that against what buildLocation produces for the same location, sees a difference, commits a location change, and every matched loader runs a second time right after hydration.

Nothing is visible — the address bar does not change and no navigation happens — so the only symptom is a duplicate server-function call on every landing. As the reporter puts it, ad networks encode * and spaces differently from stringifySearch, so in their app this fired on every paid-ad landing.

The fix

Only the query component is re-stringified. The path and hash are left exactly as history gave them.

I first tried the other option the issue suggests — sending the canonical url back through executeRewriteOutput, mirroring how buildLocation derives publicHref. That is wrong here, and the existing suite catches it:

  • An output rewrite is not the inverse of the input one. With basepath: '/other' and a path that does not carry that basepath, the output rewrite prepends it again and publicHref becomes /other/app/public instead of /app/public.
  • It makes a user's output rewrite run during parsing. Three existing tests assert the rewrite call log, and it went from ['input:/public'] to ['input:/public', 'output:/internal'] — an observable side effect in user code.

Re-stringifying only the query avoids both: no assumption that the rewrite round-trips, and no user callback invoked during parse.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with the relevant test commands, or tests do not apply to this pull request.
  • I fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Tests

A regression test in packages/router-core/tests/rewrite.test.ts asserts a parsed landing location matches the built one. It fails on main:

Expected: "/posts?q=a*b"
Received: "/posts?q=a%2Ab"

and passes with this change.

  • @tanstack/router-core: 126 files, 3246 passed, 4 expected-fail.
  • @tanstack/react-router: 73 files, 1011 passed, 1 skipped (after building router-core; without that build these fail to resolve @tanstack/router-core on a clean checkout too).
  • tsc --noEmit on router-core is clean, and both changed files are Prettier-formatted.

Disclosure: developed with AI assistance (Claude Code); the diagnosis, the discarded first approach and the final diff were reviewed before submitting.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed rewritten landing URLs so their public links use the canonical search-string format.
    • Prevented unnecessary location updates and repeated data loading immediately after hydration when search parameters use an equivalent, non-canonical format.
    • Preserved the existing path and hash while normalizing only the query string.

…seLocation

The fast path in `parseLocation` builds `publicHref` from the re-stringified
search, so a landing url whose search is not in the router's canonical form
still produces a canonical `publicHref`. The other branch — taken whenever a
rewrite is configured, and also when the pathname needs encoding — returned the
raw history href instead.

With any rewrite set, even a no-op one, landing on `?q=a%2Ab`, `?q=two%20words`
or `?a=1&&b=2` therefore left a non-canonical search on `publicHref` while
`href` was already canonical. The transitioner compares that against what
`buildLocation` produces, sees a difference, commits a location change, and
every matched loader runs a second time right after hydration. Nothing is
visible: the address bar does not change and no navigation happens, so the
only symptom is a duplicate server-function call per landing.

Only the query component is re-stringified. The path and hash are left exactly
as history gave them, because an output rewrite is not necessarily the inverse
of the input one — round-tripping the url through `executeRewriteOutput` here
would both double a basepath and make a user's `output` rewrite run during
parsing, which is observable.

Fixes TanStack#8448
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Advanced

Run ID: 72ce79e4-4144-401e-9b5f-b4f5de49a814

📥 Commits

Reviewing files that changed from the base of the PR and between 1ca361b and 4bf83a4.

📒 Files selected for processing (3)
  • .changeset/public-href-canonical-search.md
  • packages/router-core/src/router.ts
  • packages/router-core/tests/rewrite.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

The router now canonicalizes the search component of publicHref during parseLocation while preserving the path and hash. A rewrite test verifies parity with buildLocation, and a patch changeset documents the fix.

Changes

Canonical search handling

Layer / File(s) Summary
Canonicalize publicHref search
packages/router-core/src/router.ts
Adds withCanonicalSearch and uses it in parseLocation to re-stringify only the query component.
Validation and release metadata
packages/router-core/tests/rewrite.test.ts, .changeset/public-href-canonical-search.md
Adds coverage for canonical search handling with rewrites and records a patch release for @tanstack/router-core.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: schiller-manuel

Merge Risk: ⚪ Minimal · up to 4bf83

The router now canonicalizes rewritten location searches consistently, with regression coverage for the reported hydration mismatch.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: canonicalizing the search string in publicHref during parseLocation.
Description check ✅ Passed The description follows the required template. It explains the motivation and implementation, completes the checklist, records release impact and the changeset, and provides regression and validation …
Linked Issues check ✅ Passed The changes satisfy issue #8448. parseLocation now canonicalizes only the search component of publicHref when rewrites apply. The path and hash remain unchanged. The regression test covers a no-op…
Out of Scope Changes check ✅ Passed The reviewed changes are limited to the parseLocation fix, its focused regression test, and a patch-release changeset for @tanstack/router-core. These changes directly support issue #8448. No unre…
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 2 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

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.

parseLocation keeps the raw publicHref when rewrite is set, so a non-canonical search string re-runs every loader after hydration

1 participant