Which project does this relate to?
Start
Describe the bug
Description:
When a search param contains characters that get URL-encoded differently on server vs client (e.g. slashes), the Transitioner's useEffect detects a URL mismatch and fires commitLocation → router.load(), which re-runs loaders
on the client and causes a React hydration mismatch.
The root cause:
- Server renders with /?u=/test/ (unencoded slashes in search param)
- ssr-client.ts hydrates matches and clears _nonReactive.dehydrated = undefined
- Transitioner's useEffect calls buildLocation() which encodes the URL to /?u=%2Ftest%2F
- trimPathRight(latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref) evaluates to true
- commitLocation() → router.load() → shouldSkipLoader() checks _nonReactive.dehydrated which is already
undefined → loaders re-run
- React hydration mismatch (server rendered with SSR data, client re-renders with fresh loader data)
The URLs /?u=/test/ and /?u=%2Ftest%2F are semantically identical — buildLocation() should produce the same
encoding as the server, or the Transitioner should normalise both URLs before comparing.
Workaround:
Strip special characters in validateSearch so server and client produce the same canonical URL:
validateSearch: (search) => {
const { u, ...rest } = search
const stripped = u?.split('/').filter(Boolean).pop()
return { ...rest, u: stripped }
}
Your Example Website or App
https://stackblitz.com/edit/vitejs-vite-kh2mrzsf?file=README.md
Steps to Reproduce the Bug or Issue
pnpm install && pnpm dev
- Visit /?u=/test/ (search param with slashes)
- Open browser console
- See [loader] Running on CLIENT (BUG!) — the loader re-ran on the client
- See React hydration mismatch error
- Compare with /?u=test (no slashes) — works correctly
Expected behavior
Expected behavior:
Loaders should not re-run during hydration. buildLocation() should preserve the original URL encoding from the server, or the Transitioner's URL comparison should normalise encoding before comparing (e.g. decode both sides).
Screenshots or Videos
N/A
Platform
- @tanstack/react-router: 1.168.3
- @tanstack/react-start: 1.167.9
- @tanstack/router-plugin: 1.167.6
- react: 19.1.4
- react-dom: 19.1.4
- OS: macOS
- Browser: Chrome
- Bundler: vite 7.3.1
Additional context
The bug is in the Transitioner's useEffect
(https://github.com/TanStack/router/blob/main/packages/react-router/src/Transitioner.tsx#L60):
const nextLocation = router.buildLocation({
to: router.latestLocation.pathname,
search: true, params: true, hash: true, state: true,
_includeValidateSearch: true,
});
if (trimPathRight(router.latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref)) {
router.commitLocation({ ...nextLocation, replace: true });
}
latestLocation.publicHref is /?u=/test/ (from the server) but nextLocation.publicHref is /?u=%2Ftest%2F (from
buildLocation). These are the same URL with different encoding.
Which project does this relate to?
Start
Describe the bug
Description:
When a search param contains characters that get URL-encoded differently on server vs client (e.g. slashes), the Transitioner's useEffect detects a URL mismatch and fires commitLocation → router.load(), which re-runs loaders
on the client and causes a React hydration mismatch.
The root cause:
undefined → loaders re-run
The URLs /?u=/test/ and /?u=%2Ftest%2F are semantically identical — buildLocation() should produce the same
encoding as the server, or the Transitioner should normalise both URLs before comparing.
Workaround:
Strip special characters in validateSearch so server and client produce the same canonical URL:
Your Example Website or App
https://stackblitz.com/edit/vitejs-vite-kh2mrzsf?file=README.md
Steps to Reproduce the Bug or Issue
pnpm install && pnpm devExpected behavior
Expected behavior:
Loaders should not re-run during hydration. buildLocation() should preserve the original URL encoding from the server, or the Transitioner's URL comparison should normalise encoding before comparing (e.g. decode both sides).
Screenshots or Videos
N/A
Platform
Additional context
The bug is in the Transitioner's useEffect
(https://github.com/TanStack/router/blob/main/packages/react-router/src/Transitioner.tsx#L60):
latestLocation.publicHref is /?u=/test/ (from the server) but nextLocation.publicHref is /?u=%2Ftest%2F (from
buildLocation). These are the same URL with different encoding.