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:
- Promise on the router —
router.hydrationSettled: Promise<void>, resolving where c() fires
(already resolved for a client-side navigation into a fresh document).
- Router option —
createRouter({ onHydrationSettled: () => void }).
- Exported helper —
whenHydrationSettled(): 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
The gap
router-core's injected SSR bootstrap script already tracks exactly the state applications need,but keeps it private:
c()is the moment "hydration is complete and the SSR stream has ended" — the point after whichit 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 whilesibling 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/historymonkey-patcheshistory.pushState/replaceStateglobally, andTransitionersubscribes to it. So any
history.replaceStatecall — including one that is purely cosmetic andtouches 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
replaceStateduring hydration.Our modal state lives in the URL, and the controller writes it with a
replaceStatecaptured fromthe module body of the client entry — before
createBrowserHistoryinstalls the patch — preciselyso 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
historyexactly once after load without paying for arouter.load()mid-hydration.Proposed API
Any one of these would be enough — listed by how little surface they add:
router.hydrationSettled: Promise<void>, resolving wherec()fires(already resolved for a client-side navigation into a fresh document).
createRouter({ onHydrationSettled: () => void }).whenHydrationSettled(): Promise<void>from@tanstack/react-router, soit 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 (createControlledPromiseis 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-router1.170.18,@tanstack/react-start1.168.32,@tanstack/router-core1.171.15