Repository navigation
feat(router)!: group-stable segment for clientUrls() — the optimistic instance survives the commit - #852
Merged
Conversation
… instance survives the commit Every route of one clientUrls() group mount now carries a `clientGroup` key (PathOptions internal -> EntryData -> ResolvedSegment -> segment cache codec). renderSegments keys those segments by the group instead of route id + params and gives them one wrapper shape (no LoaderBoundary/RouteContentWrapper — ClientUrlsRoot renders loading() itself), and ClientUrlsRoot renders one wrapper chain in both the optimistic and the canonical state. The canonical commit therefore reconciles the destination into the position the origin held: the optimistically rendered component keeps its instance, local state typed during the wait survives, effects run once. Optimistic navigations commit in the transition lane so a read that still suspends holds the presented content. Consequence: same-route param navigations inside a group hold previous content by default (the group key is param-agnostic by construction); transition() in a group is the view-transition animation opt-in, not the hold. Server semantics are untouched — loading() still masks loaders for PPR capture and drives SSR. StreamedLoaderErrorBoundary gains a resetKey (id-params): as a surviving instance it kept rendering LoaderRedirect for /legacy after the redirect had landed on /state, so the target never rendered. It now clears the caught marker when the route or params change, the cadence the per-route remount used to provide. Design: docs/design/client-urls-optimistic-destination.md (mechanics 4, phase two).
commit: |
ivogt
added a commit
that referenced
this pull request
Sep 13, 2026
Group-stable segment for clientUrls() (#852): the optimistically rendered instance survives the canonical commit, same-route param navs inside a group hold previous content by default, and the streamed loader error boundary resets per route. Minor bump for the same-route behavior change.
7 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
0.13.0 renders a
clientUrls()destination optimistically, but each projected route is its own server segment keyed byid-params, so the canonical commit mounted the destination as a new segment and unmounted the optimistic branch. The instance the user was already interacting with was replaced: text typed into a field during the wait was lost, and effects ran twice. The design doc recorded this as the phase-one limitation and named the fix.Before / after
/items/1->/items/2)End-to-end example
Clicking Beta from Acme keeps
Sitemounted andfilterintact; the heading holds "Acme" until Beta's loader lands, then swaps. Notransition()needed for the hold.Semantics
transition()opts into the hold)transition()in a group is the animation opt-in onlyresetKey(id-params) so a marker caught for one route never leaks into the nextloading()LoaderBoundary+RouteContentWrapperfrom the segment systemSuspenseinsideClientUrlsRoot(same fallback, same reads); no server-side change,loading()still masks loaders for PPR capture and drives SSRloading()and without loadersOutletProviderOutletProvider+StreamedLoaderErrorBoundary(uniform shape; the boundary only catches marked loader errors)Code changes
src/client-urls/server-projection.ts— every materialized route getsclientGroup= the include's URL prefix ("/"at root).src/urls/pattern-types.ts(PathOptions.clientGroup, internal),src/urls/path-helper.ts,src/server/context.ts(EntryData),src/router/segment-resolution/fresh.ts+revalidation.ts,src/types/segments.ts(ResolvedSegment),src/cache/segment-codec.ts— carry the key from the DSL to the browser and through the segment cache.src/segment-system.tsx— group routes: React keycg:<group>; one wrapper shape (noLoaderBoundary/RouteContentWrapper, alwaysStreamedLoaderErrorBoundary); theid-paramsidentity is computed once and passed as the boundary'sresetKey.src/route-content-wrapper.tsx—StreamedLoaderErrorBoundaryclears its caught marker ingetDerivedStateFromPropswhenresetKeychanges. Found by the e2e: a surviving boundary kept renderingLoaderRedirectfor/legacyafter the redirect had landed on/state, so the target never rendered (also the vite-rsc-demo "moved product redirects" flakes).src/client-urls/client-root.tsx— the wrapper chain (OutletProvider+OptimisticLocationContext.Provider,Suspensewhen the route declaresloading()) is identical in the optimistic and canonical states; only prop values change.src/browser/partial-update.ts— a navigation that presented optimistically commits in the transition lane, so a read that still suspends holds the presented content instead of flashing a fallback.docs/design/client-urls-optimistic-destination.md(mechanics 4 rewritten, phase two marked shipped, the boundary-reset scar recorded),docs/client-urls.md(navigation authority, same-route section),skills/client-urls/SKILL.md(transition()row).client-urls.test.ts"same-route param navs hold previous content in a group, with or without transition()" replaces the plain-twin re-stream expectation.Why the shape is uniform client-side rather than by emitting
loading()for every group route server-side:loading()masks loaders during PPR shell capture (entryLoadingMasksLoaders), so a synthetic server-sideloading()would silently change capture for group routes without one. Keeping the segment'sloadingvalue intact and only changing whatrenderSegmentsbuilds leaves every server contract as it was.Test plan
segment-system.test.tsx"keys clientUrls() group routes by the group and gives them one wrapper shape";client-root.test.tsx"keeps the optimistic instance across the canonical commit" (typed state survives, mount effect runs once); newstreamed-loader-error-boundary.test.tsxpins the reset and the no-reset case; existing view-transition and client-urls pins green.client-urls(redirect-with-state probe, same-route hold with and withouttransition()),loader-behavior(tree-structure rule),conditional-transition,prefetch-transition,client-reverse,location-state.client-shop-*(the redirect flakes from the first run disappeared with the boundary reset; one remaining flake was a dev-server setup timeout).pnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format,check:e2e-bucketing,check:e2e-parity --strict,check:docs-api.Notes
transition()in a group no longer changes the hold, only the animation./simplifyapplied: theid-paramsidentity is computed once for the key and the reset key; theshapeLoadingindirection became direct group gates; type comments point at the segment-system branch instead of repeating it. Three of four reviewers stalled; their angles were covered by hand. Skipped: deriving the group frommountPath(a server route mounted at the same prefix would wrongly take the group key and shape), addingclientGrouptosegment-structure-assert(static per segment id).