Skip to content

Hydrated match loaderData overwritten during initial client load #7029

@roduyemi

Description

@roduyemi

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:

  1. Server renders with /?u=/test/ (unencoded slashes in search param)
  2. ssr-client.ts hydrates matches and clears _nonReactive.dehydrated = undefined
  3. Transitioner's useEffect calls buildLocation() which encodes the URL to /?u=%2Ftest%2F
  4. trimPathRight(latestLocation.publicHref) !== trimPathRight(nextLocation.publicHref) evaluates to true
  5. commitLocation() → router.load() → shouldSkipLoader() checks _nonReactive.dehydrated which is already
    undefined → loaders re-run
  6. 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

  1. Visit /?u=/test/ (search param with slashes)
  2. Open browser console
  3. See [loader] Running on CLIENT (BUG!) — the loader re-ran on the client
  4. See React hydration mismatch error
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions