Repository navigation
fix(router): a clientUrls() layout() keeps its instance across navigations between its routes (#1124) - #1125
Merged
Merged
Conversation
commit: |
…tions between its routes (#1124) ClientUrlsRoot keyed each layout wrapper `${route.id}-layout-${index}`, so a layout() shared by two routes remounted on every move between them: React state, DOM and inline Suspense were recreated. Flattening now gives each layout() item its ordinal among sibling layout()s, stored per route as ClientUrlRouteRecord.layoutKeys, and ClientUrlsRoot keys by it. React compares keys among siblings only, so a differing parent layout still remounts its subtree. Only the route component and layouts below the divergence mount anew. Pinned by unit tests (red before green) and dev + production e2e in the router test-app and cloudflare-basic: route switch, Back, a server action and a same-route param navigation all keep the layout's element.
ivogt
force-pushed
the
fix/1124-client-urls-layout-keys
branch
from
October 12, 2026 16:48
54a47a9 to
87a4136
Compare
This was referenced Oct 12, 2026
Merged
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.
A
clientUrls()layout()now keeps its instance (React state, DOM, inline Suspense) for as long as a navigation stays under it. Before this, it remounted on every move between its routes. Fixes #1124.Problem
In a
clientUrls()group, alayout()wrapping severalpath()s was unmounted and mounted again on every navigation from one of its paths to another. Its loaders were held as theirrevalidate()rules said, but:useStatein the layout reset;<details>or a scroll container lost its value, open state or scroll position;<Suspense>inside it could show its fallback again.A tab bar drawn by the group's layout was rebuilt on every tab switch, which undercuts what client route groups are for.
Cause:
ClientUrlsRootkeyed each layout wrapper${route.id}-layout-${index}, so two routes under onelayout()produced different keys.cg:<group>), soClientUrlsRootsurvives, and the per-route layout key became the only thing still remounting the layout.Before / after
/a→/b(samelayout())Shellremounts: state resets, DOM is newShellkeeps its instance; onlyBmountslayout()underShellShellremountsShellstays; the nested layout mountslayout()item, even with the same componentConsumer example
On
/a, click the button to 3, then follow the link to/b: the button still reads 3. Nothing changes in consumer code. If you moved group chrome into a serverlayout()around theinclude()to work around this, you can move it back.Change
src/client-urls/client-urls.ts: flattening gives eachlayout()item a key, its ordinal among siblinglayout()s.loader(),loading()andpath()items don't count, so adding one before a layout doesn't rename its key on HMR.ClientUrlRouteRecord.layoutKeys, parallel tolayouts.src/client-urls/types.ts: the newlayoutKeysfield. It is additive. Thelayoutsshape is unchanged becauseClientUrlRouteRecordis exported from@rangojs/router/client.src/client-urls/client-root.tsx:key: route.id, so it still mounts per route.ClientUrlsRootmounted across the group. The layout key was the last route-scoped identity in the chain. The server projection builds its records from named fields, so the wire format is unchanged.docs/client-urls.mdandskills/client-urls/SKILL.mdnow state the guarantee. CHANGELOG## Unreleasedhas a "Fixed" entry.Test plan
src/client-urls/__tests__/client-root.test.tsx(new describe "ClientUrlsRoot layout identity (clientUrls(): a layout() is remounted on every move between its routes, since ClientUrlsRoot keys each layout by the route #1124)"): a shared layout keeps its DOM node and counter across/a → /band into a nested layout, and a differentlayout()item with the same component mounts anew. Red before green: with the old key it fails withexpected <div data-testid="shell"> to be <div data-testid="shell"> // Object.is equality.src/client-urls/__tests__/client-urls.test.ts:layoutKeysper route, shared by sibling routes, with aloading()item that doesn't shift the key. The public-type key test now includeslayoutKeys.e2e/client-urls.test.ts).Expected: "1",Received: "".e2e/client-urls.test.ts): a route switch, Back and a server action, with the layout element kept throughout.client-urls*e2e files in both apps, dev and production (HMR files excluded): router test-app 83/83, cloudflare-basic 40/40, 0 flakypnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format,pnpm check:e2e-bucketingnode tools/check-bundle-guards.mjs: the cloudflare-basic router chunk is 49,527 B gzip, within the 49,536 B ratchet. The ratchet is unchanged. The first push was 31 B over with a parent-path key and alayout-prefix; both were dropped.Notes
clientUrls()group in the browser.renderRouterenders one component in a synthetic tree, andserveShellRequest/dispatchrun on the server, where client reconciliation can't be seen. So this fix is covered by internal unit tests and e2e, not by a@rangojs/router/testingtest. ExtendingrenderRouteto mount a group and navigate inside it is a follow-up.revalidate()on that layout opts it in.