Repository navigation
Description
What happens
In a clientUrls() definition, a layout() that wraps several path()s is unmounted and mounted again whenever the user navigates from one of its paths to another. Its loaders are held as their revalidate() rules say, but its DOM and its React state are recreated:
useStatein the layout resets;- an input, a
<details>or a scroll container in the layout loses its value, its open state or its scroll position; - an inline
<Suspense>inside the layout can show its fallback again; - an animation or a view transition on the layout's chrome restarts.
This undercuts what client route groups are for: "high navigation frequency inside one layout, mostly tab/param/filter switches". A tab bar drawn by the layout is torn down and rebuilt on every tab switch.
Why
ClientUrlsRoot (src/client-urls/client-root.tsx, line 198, on main and in 0.24.0) builds the wrapper chain with a key that includes the route:
for (let index = route.layouts.length - 1; index >= 0; index--) {
const layoutKey = `${route.id}-layout-${index}`;
content = createElement(OutletProvider, {
key: layoutKey,
content,
pending,
children: createElement(route.layouts[index], { key: layoutKey }),
});
}Two routes under the same layout() get different keys, such as a-layout-0 and b-layout-0, so React treats the layout as a new element.
Reproduction
"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 reads 0 again, and the layout's elements are new nodes: an attribute set on the <div> before the navigation is gone after it.
Expected
A layout() keeps its instance while a navigation stays under it, as a server layout() around the include() mount does across group navigations (the client-urls guide, "Outer layouts and middleware across group navigations"). Only what changed, the route component (key: route.id) and any layout below the point where the two routes' chains differ, should mount anew.
Suggested fix
Key each layout by its identity in the definition, not by the route: for example its position path in the projection (0, 0.1, ...) or an id given to each layout() when the projection is built. Two routes then share the keys of the layouts they share, and React reconciles them.
A test could pin it in dev and production: a counter in a layout keeps its value, and the layout's element keeps an attribute set before the navigation, across a move between two of its paths and across a search-only change.
Workaround
Draw the shared chrome in a server layout() around the include() of the group, with its own loader and revalidate() rule. It keeps its DOM across navigations inside the group, but it moves chrome that belongs to the group out of it.
Docs
The client-urls guide says same-route navigations reconcile because group segments are keyed by the group (cg:<group>), but it says nothing about layouts across different routes of the group. Once fixed, a line there saying that a layout() keeps its instance across its paths would make the guarantee explicit.
Seen with @rangojs/router 0.24.0; the code is the same on main.
Activity
fix(router): a clientUrls() layout() keeps its instance across naviga…
fix(router): a clientUrls() layout() keeps its instance across naviga…
fix(router): a clientUrls() group layout that reads its own loader ke…
fix(router): a clientUrls() group layout that reads its own loader ke…
fix(router): a clientUrls() group layout that reads its own loader ke…
fix(router): a clientUrls() group layout that reads its own loader ke…