Skip to content

Expose a public "hydration settled" signal (the state $_TSR.c() already computes internally) #7915

Description

@joseortiz9

The gap

router-core's injected SSR bootstrap script already tracks exactly the state applications need,
but keeps it private:

// packages/router-core/src/ssr/tsrScript.ts
self.$_TSR = {
  h() { this.hydrated = true;    this.c() },   // called by react-start's hydrateStart()
  e() { this.streamEnded = true; this.c() },   // called when the SSR stream ends
  c() { this.hydrated && this.streamEnded && (delete self.$_TSR, delete self.$R.tsr) },
  p(cb) { this.initialized ? cb() : this.buffer.push(cb) },
  buffer: [],
}
// packages/react-start-client/src/hydrateStart.ts
async function hydrateStart() {
  const router = await hydrateStart()
  window.$_TSR?.h()
  return router
}

c() is the moment "hydration is complete and the SSR stream has ended" — the point after which
it is safe to touch router-adjacent global state. Today that moment is computed and then used only
to delete the global. There is no public way to observe it. The only signal available to an app is
polling for typeof window.$_TSR === "undefined", which is both a private-API dependency and racy.

Note this is strictly weaker than useEffect/onMount: a component can mount and run effects while
sibling Suspense boundaries are still hydrating from a stream that hasn't ended. "Settled" is a
document-level fact, and only the router knows it.

Why we need it

@tanstack/history monkey-patches history.pushState/replaceState globally, and Transitioner
subscribes to it. So any history.replaceState call — including one that is purely cosmetic and
touches no route-owned state — triggers a full router.load() (matchRoutes + beforeLoad).

If that lands inside the streaming-hydration window, the resulting re-render interleaves with
Suspense boundaries that are still hydrating, and React throws
#418 / #419. We reproduced this 100% deterministically on a
production build: an authed full-document load carrying an invalid modal query param, where our
modal layer's cleanup effect scrubbed the param via replaceState during hydration.

Our modal state lives in the URL, and the controller writes it with a replaceState captured from
the module body of the client entry — before createBrowserHistory installs the patch — precisely
so cosmetic URL writes don't fire loaders. That works for user-initiated opens and closes. What it
cannot solve is when it is safe to perform the first write on a document that is still hydrating.
We currently just never scrub on the initial sync and leave the invalid param inert in the URL until
the next navigation cleans it. A settle signal would let us do the correct thing instead.

This generalizes past our case: analytics that rewrite URLs, consent banners that strip tracking
params, anything that wants to mutate history exactly once after load without paying for a
router.load() mid-hydration.

Proposed API

Any one of these would be enough — listed by how little surface they add:

  1. Promise on the routerrouter.hydrationSettled: Promise<void>, resolving where c() fires
    (already resolved for a client-side navigation into a fresh document).
  2. Router optioncreateRouter({ onHydrationSettled: () => void }).
  3. Exported helperwhenHydrationSettled(): Promise<void> from @tanstack/react-router, so
    it can be awaited without a router reference.

(1) composes best with effects: useEffect(() => { router.hydrationSettled.then(scrub) }, []).

The internal change looks small — have c() resolve a controlled promise (createControlledPromise
is already used in this file's neighbourhood) before deleting the global, and keep the deletion
as-is.

Happy to open a PR if you'd take one, and to say which shape you'd prefer.

Versions

@tanstack/react-router 1.170.18, @tanstack/react-start 1.168.32, @tanstack/router-core 1.171.15

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions