[RFC] Enable React ViewTransition and fix the useSyncExternalStore issue #8188
matclayton
started this conversation in
RFCs
Replies: 2 comments 3 replies
|
Posting this here mainly as food for thought, feel free to close it or pull it to pieces. We're looking deeply at INP issue right now and Router's useSyncExternalStore is frustrating blocker I'd love to remove, after many claude/codex sessions we have what we believe is a working prototype as laid out above, what are the core teams thought? As sadly I dont know the internals well enough to be confident in all the effects of such a change. |
1 reply
|
fine grained selectors must continue to work. so "should the first implementation preserve selector-level" is an absolute yes. |
2 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC: publish router state to React as concurrent render frames
The problem
React's
<ViewTransition>never fires across a TanStack Router navigation, andit is not because the navigation is missing a transition. It already has one:
Transitioner.tsxoverridesrouter.startTransitionto callReact.startTransition, and the client loader commits every set of matchesthrough that override.
The update lands on the wrong lane. Every reactive router read goes through
useStore→useSyncExternalStoreWithSelector→useSyncExternalStore, andReact schedules those at a hardcoded
SyncLane, from the store's ownsubscription callback:
The lane is a constant, and the callback runs after the
startTransitionscopehas exited. React does this deliberately: an external store cannot produce a
previous snapshot on demand, so old and new UI cannot render concurrently
without tearing. The cost is that the update carrying a new route is never a
transition, and
<ViewTransition>only fires for transitions.There is a second consequence, independent of animation and arguably more
serious: because consumers read mutable head atoms, a component rendering
while a navigation is in flight observes the route being prepared, not the one
on screen. A menu, toast, or modal opened during a slow navigation renders
against a route the user cannot see.
The proposal
Publish router state to React as render frames: complete, immutable
snapshots with a monotonic
frameId, staged inside the Router's ownstartTransitionand committed only when React acknowledges the exact frame itrendered. Behind an opt-in router option; default off.
router-corestays additive — aframeIdon each aggregate state, a widened_renderedacknowledgement, and a presented_stateformatchRoute. Noshared signature changes, so Solid and Vue are untouched.
The interesting part is the React adapter, because a frame has to answer two
different questions at once.
Two scopes, two slots, and per-consumer state
A frame means different things depending on where you are standing. Outside the
route tree, "the router state" is what is on screen. Inside the route subtree
being prepared, it is what is being prepared. So there are two scopes: a root
scope that advances only on commit, and a presentation scope for the route
subtree that can also carry a staged successor.
Scope identity is stable for the router's lifetime, so a scope in Context
invalidates nobody. Each scope holds its two publications in separate slots,
committedandstaged, and each consumer records in React state which oneits own render is presenting:
React versions that state per tree. The notification that offers a staged frame
is sent from inside the Router's
startTransition, so it keeps the transitionlane and lands in the work-in-progress tree only. The tree the user is still
looking at keeps its previous
frameId, resolves tocommitted, and an urgentupdate there cannot pull the staged route into view.
A consumer accepts the offer only if its own selection changed. One that
declines simply stays resolved to
committed, where its selection is identicalby definition. That is the whole of the selector story — no changing Context
value, no global invalidation.
Progress is not route content
statusandisLoadingare deliberately exempt. They are overlaid onto everyslot in both scopes, so a spinner sees the navigation whether it sits above the
route tree or inside the route being left.
locationandmatchesare neveroverlaid, so the exemption cannot surface a route the user cannot see.
This narrows the original draft's invariant that a render may not combine values
from different publications. Progress is now explicitly live; route content is
not.
Invariants
frameId.location,matches, params, search —from exactly one publication.
status,isLoading) is exempt from 2 and tracks thehead in every scope.
whatever order the loaders resolve in.
changed.
in the still-visible route that re-renders for an unrelated reason continues
to observe the committed publication.
installation of its subscription.
What it costs
Correctness and selector parity looked like a trade-off, and three prototypes
each gave up one of them. Measured, not argued:
pending ?? committedThey only conflicted because one mutable answer was serving two questions, in
two trees. Splitting the slots and letting React version the choice resolves it.
Selector-call counts across the existing
store-updates-during-navigationcases are at or below the store path, never higher:
And it does what it set out to do. Counting real
document.startViewTransitioncalls on a two-route app on React canary:
startTransition(control)router.navigate()insidestartTransition<Link>navigationThe control row is the point: same elements, same names, same browser, same
React build — only the trigger differs. Mid-navigation the browser is animating
::view-transition-group/old/new(article-image-2), so it is a real shared-elementmorph, not just a transition firing.
Open problems
Three, stated plainly.
Mount-time isolation. Invariant 9 holds for a consumer that is already
mounted. A consumer that mounts during a staged navigation has no prior state
and no way to tell which tree is rendering it, so it seeds from
staged ?? committed. If an urgent update in the committed tree mounts a newrouter-state consumer mid-navigation, that first render can still read the
staged frame. Closing it appears to require the frame to arrive through a
changing Context value, which costs invariant 8. Whether both can hold at once
is the open question.
Two fixes rest on the mechanism, not a failing test. Recording the committed
selection outside render, and re-reading when the subscription is installed,
both address windows that
act()never opens: it flushes passive effects at itsboundaries, so a publication cannot land between a consumer's render and its
effects, and a staged render is committed rather than discarded. Every other
guarantee above has a test that fails without it. These two do not, and I would
rather say so than let them look proven.
Breadth under a large route tree has not been profiled in production, and
whether this should eventually delegate to a native React concurrent-store
primitive — if one arrives — is genuinely open. That is why the proposal is an
option rather than a new default.
Status
Implementation, tests and a changeset: mixcloud#1, against
TanStack Router
main. Verified acrossrouter-core,react-router,solid-routerandvue-router— lint, types on TS 5.6–7.0, unit, build, andboth e2e suites.
Reproduction and demo: mixcloud/router-transitions-poc —
mainis thefailure, the PR applies the branch as pnpm patches and measures the result.
All reactions