Skip to content

fix(router): a clientUrls() layout() keeps its instance across navigations between its routes (#1124) - #1125

Merged
ivogt merged 1 commit into
mainfrom
fix/1124-client-urls-layout-keys
Oct 12, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/1124-client-urls-layout-keys

Conversation

@ivogt

@ivogt ivogt commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

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, a layout() wrapping several path()s was unmounted and mounted again on every navigation from one of its paths to another. Its loaders were held as their revalidate() rules said, but:

  • useState in the layout reset;
  • an input, a <details> or a scroll container lost its value, open state or scroll position;
  • an inline <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: ClientUrlsRoot keyed each layout wrapper ${route.id}-layout-${index}, so two routes under one layout() produced different keys.

Before / after

export default clientUrls(({ path, layout }) => [
  layout(Shell, () => [            // Shell holds useState + a tab bar
    path("/a", A, { name: "a" }),
    path("/b", B, { name: "b" }),
  ]),
]);
Navigation Before After
/a → /b (same layout()) Shell remounts: state resets, DOM is new Shell keeps its instance; only B mounts
into a nested layout() under Shell Shell remounts Shell stays; the nested layout mounts
to a route under a different layout() item, even with the same component remounts remounts (unchanged: it is a different layout)
Back / forward between them remounts stays
a server action, or a same-route param or search navigation stays stays (now pinned by e2e)

Consumer example

"use client";
import { useState } from "react";
import { clientUrls, Link, Outlet } from "@rangojs/router/client";

function Shell() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <nav>
        <Link to="/a">a</Link> <Link to="/b">b</Link>
      </nav>
      <Outlet />
    </div>
  );
}
function A() { return <p>a</p>; }
function B() { return <p>b</p>; }

export default clientUrls(({ path, layout }) => [
  layout(Shell, () => [path("/a", A, { name: "a" }), path("/b", B, { name: "b" })]),
]);

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 server layout() around the include() to work around this, you can move it back.

Change

  • src/client-urls/client-urls.ts: flattening gives each layout() item a key, its ordinal among sibling layout()s.
    • Ordinals are enough: React compares keys only among siblings, and a differing parent layout remounts the whole subtree anyway.
    • loader(), loading() and path() items don't count, so adding one before a layout doesn't rename its key on HMR.
    • The key is stored per route as ClientUrlRouteRecord.layoutKeys, parallel to layouts.
  • src/client-urls/types.ts: the new layoutKeys field. It is additive. The layouts shape is unchanged because ClientUrlRouteRecord is exported from @rangojs/router/client.
  • src/client-urls/client-root.tsx:
    content = createElement(OutletProvider, {
      key: route.layoutKeys[index],   // was `${route.id}-layout-${index}`
      content, pending,
      children: createElement(route.layouts[index]),
    });
    The route component keeps key: route.id, so it still mounts per route.
  • Why here: the group-keyed segment (feat(router)!: group-stable segment for clientUrls() — the optimistic instance survives the commit #852) already keeps ClientUrlsRoot mounted 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: docs/client-urls.md and skills/client-urls/SKILL.md now state the guarantee. CHANGELOG ## Unreleased has a "Fixed" entry.

Test plan

  • Unit, 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 → /b and into a nested layout, and a different layout() item with the same component mounts anew. Red before green: with the old key it fails with expected <div data-testid="shell"> to be <div data-testid="shell"> // Object.is equality.
  • Unit, src/client-urls/__tests__/client-urls.test.ts: layoutKeys per route, shared by sibling routes, with a loading() item that doesn't shift the key. The public-type key test now includes layoutKeys.
  • E2e, dev AND production, router test-app (e2e/client-urls.test.ts).
    • "a layout() keeps its DOM across navigations between its routes": a soft navigation to another group route, then Back. Red before green: with the old key, dev failed with Expected: "1", Received: "".
    • "a layout() keeps its DOM across an action and a same-route param navigation".
  • E2e, dev AND production, cloudflare-basic (e2e/client-urls.test.ts): a route switch, Back and a server action, with the layout element kept throughout.
  • All client-urls* e2e files in both apps, dev and production (HMR files excluded): router test-app 83/83, cloudflare-basic 40/40, 0 flaky
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, pnpm check:e2e-bucketing
  • node 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 a layout- prefix; both were dropped.

Notes

  • Userland coverage gap. No public testing primitive renders a clientUrls() group in the browser. renderRoute renders one component in a synthetic tree, and serveShellRequest / dispatch run 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/testing test. Extending renderRoute to mount a group and navigate inside it is a follow-up.
  • Server layouts above the group are unchanged. They are kept on navigations inside the group and skipped on actions by default; a revalidate() on that layout opts it in.

@pkg-pr-new

pkg-pr-new Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1125

commit: 87a4136

…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
ivogt force-pushed the fix/1124-client-urls-layout-keys branch from 54a47a9 to 87a4136 Compare October 12, 2026 16:48
@ivogt
ivogt merged commit de4eb9d into main Oct 12, 2026
59 checks passed
@ivogt
ivogt deleted the fix/1124-client-urls-layout-keys branch October 12, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

clientUrls(): a layout() is remounted on every move between its routes, since ClientUrlsRoot keys each layout by the route

1 participant