TanStack / TanStack/router

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

Open
#7,915 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

information needed
Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with packages/router-core/src/ssr/tsrScript.ts and packages/react-start-client/src/hydrateStart.ts to trace when hydration and stream completion call c(). Review the nearby createControlledPromise usage and decide which proposed public API fits the router packages. Done means applications can observe the settled point while the existing global cleanup remains intact.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
api, frontend
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.