Research/design record for the nested-layout partial-swap client navigation feature (SHIPPED). Recorded here per the agent-docs cleanup convention (#548): a research/design record is a labeled research issue (durable record in the body + comments), then CLOSED, never a file under agent-docs/ and never a comment buried on an unrelated PR.
No code change. Runtime how-to-use guidance for callers lives in agent-docs/advanced.md (Client router section) and the API table in AGENTS.md.
Provenance: originally agent-docs/ssr-partial-nav-design.md, removed from the directory in #552 (Closes #548). Supersedes the now-closed PR #559 (the record was first mis-filed as a research: PR before the convention settled on a labeled issue).
SSR partial navigation: design note
Status: SHIPPED (feature/nested-layout-partial-swap, 2026-05-16).
The mechanism described below is implemented and tested. This document
is preserved as the design record. Runtime reference for callers lives
in agent-docs/advanced.md (Client router section) and the framework
API table in AGENTS.md.
Motivating bug (resolved): ui-website docs sidenav lost scroll on
every link click because the docs layout sat 2 levels deep under the
root layout, beyond findLayoutShell's body-direct-child probe.
Previous workaround (now deleted): app/docs/layout.ts saved /
restored .docs-sidenav scrollTop via sessionStorage on every
webjs:navigate event. Removed in the same PR as the framework fix.
What actually shipped vs. what's below:
- The recommendation in this doc was
<webjs-frame> as the primary
primitive. During design discussion the decision evolved to make
layout-marker discovery auto-derived from folder structure, so
layout authors write nothing. <webjs-frame> ships as the escape
hatch for non-layout partial-swap regions (rare).
- The marker format is
<!--wj:children:<segment-path>--> comment
pairs (Remix v3 lineage), not the <webjs-frame>-element approach
sketched below.
- Wire-byte optimization, snapshot cache, keyed DOM diff with live-
attribute preservation, and per-segment <template id="wj-loading:...">
cloning all shipped in the same PR (originally deferred as v2+).
The original <webjs-frame>-centric sketch below is preserved as
historical context.
Goal
Preserve the DOM of any layout, at any depth, across same-origin navigations. Re-render only the deepest segment that actually changed.
Non-goals
- Per-segment data fetching (Remix v3
<Frame src> style). Wire model stays one SSR response per nav.
- React-style reconciler with full keyed-DOM diff inside the swap region. Out of scope for v1, can come later.
- Parallel routes / intercepting routes (Next.js feature). Separate design.
Background: how the four references handle this
| Framework |
Mechanism |
Wire format |
Scope decided by |
| Turbo |
<turbo-frame id="X"> (flat DOM element) |
full HTML response, server may optimize via Turbo-Frame: X header |
Innermost enclosing <turbo-frame> of the click (closest()) |
| Remix v3 |
<!--rmx:f:id-->...<!--/rmx:f--> comment markers + per-frame src |
per-frame HTML or <template id> streams |
Author-declared <Frame name="..."> + rmx-target on link |
| Next.js App Router |
Recursive FlightRouterState tuple + per-segment CacheNode tree |
RSC Flight (react-server-dom-webpack) |
Server walks the tree, returns from divergence point |
| Lit Labs |
Routes controller with outlet() + child controllers via RoutesConnectedEvent |
full template re-render (no partial scoping) |
N/A (full subtree re-render every nav) |
Closest fit to webjs's current router: Turbo. webjs already mirrors Turbo Drive (link interception, body swap, pushState, data-no-router ≡ data-turbo="false").
Recommendation
Adopt a Turbo-style frame primitive: <webjs-frame id="...">. Layouts that want partial-swap behavior wrap their replaceable region:
// app/docs/layout.ts
import { html } from '@webjsdev/core';
import { sidenav } from './sidenav.ts';
export default function DocsLayout({ children }) {
return html`
<div class="docs-grid">
${sidenav()}
<webjs-frame id="docs-content">${children}</webjs-frame>
</div>
`;
}
Algorithm: packages/core/src/router-client.js delta
Existing findLayoutShell(body) stays as a fallback. Add findActiveFrame(linkEl):
function findActiveFrame(linkEl) {
// Walk up through shadow boundaries and into light DOM via composedPath at call site.
const frame = linkEl.closest('webjs-frame');
return frame ? frame.id : null;
}
async function navigate(url, event) {
const frameId = event ? findActiveFrame(event.target) : null;
const res = await fetch(url, {
headers: frameId ? { 'X-Webjs-Frame': frameId } : {},
});
if (!res.headers.get('content-type')?.startsWith('text/html')) {
// existing fallback: full nav
window.location.href = url;
return;
}
const html = await res.text();
const incoming = Document.parseHTMLUnsafe(html);
// 1. Frame path: preferred if active frame exists in both.
if (frameId) {
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
const source = incoming.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
if (target && source) {
target.replaceChildren(...source.childNodes);
mergeHead(incoming.head);
runFrameScripts(target);
customElements.upgrade(target);
history.pushState({}, '', url);
document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url, frameId } }));
return;
}
}
// 2. Existing layout-shell path (one level deep).
const shell = findLayoutShell(document.body);
const incomingShell = shell ? findLayoutShell(incoming.body) : null;
if (shell && incomingShell && shellsMatch(shell, incomingShell)) {
swapShellContent(shell, incomingShell);
/* existing path... */
return;
}
// 3. Full body swap (existing fallback).
document.body.replaceChildren(...incoming.body.childNodes);
/* existing path... */
}
That's the entire detection delta: a querySelector keyed by the active frame's id, with the existing logic preserved as fallback.
<webjs-frame> element: ~30 lines
// packages/core/src/webjs-frame.js
import { WebComponent, html } from './index.js';
export class WebjsFrame extends WebComponent {
static properties = { id: { type: String, reflect: true } };
render() { return html`<slot></slot>`; }
}
WebjsFrame.register('webjs-frame');
Light DOM (default): no shadow boundary, no slot mechanics. The element exists purely as a swap anchor with an addressable id. Children are normal light-DOM children that the router replaces via replaceChildren.
Server side: X-Webjs-Frame request header (optional optimization)
When set, the SSR pipeline can return only the matching frame's HTML wrapped in a minimal stub document, skipping the rest of the layout chain. Wire is still plain HTML (no new format). v1 ships without this optimization. Full SSR response, client extracts what it needs. The header is forward-compat for the perf pass.
Head merging
Same as today's mergeHead: replace <title>, merge <meta> tags by name/property, append new <link>/<style> elements, dedupe.
Script handling inside the swap region
Re-execute <script> elements that match the existing one-level-shell path's runScripts logic. Idempotent registration via Class.register() makes this safe. The framework already handles customElements.define collisions.
Edge cases
| Case |
Behavior |
Click on a link inside <webjs-frame> but data-no-router |
Full browser navigation (existing semantics) |
Click on a link outside any frame, both pages share a findLayoutShell match |
Falls through to existing layout-shell path |
| Frame in old page but not in new (route change leaves the layout tree) |
Frame lookup fails → fall to layout-shell or full body swap. Correct. |
Nested <webjs-frame>s |
Innermost wins: closest('webjs-frame') returns the nearest enclosing frame. Mirrors Turbo behavior. |
| Form submission inside a frame |
Same. POST response gets the same frame-extract treatment. (Implement in form-submit path alongside link-click.) |
| Hash-fragment-only navigation |
Existing behavior. No fetch, browser handles. |
data-frame="_top" on a link |
Escapes the enclosing frame, full nav. (Turbo precedent.) |
What this fixes
- ui-website docs sidenav scroll: docs layout wraps content in
<webjs-frame id="docs-content">. Sidenav lives outside the frame. Navigation between /docs/components/a → /docs/components/b only swaps frame children. The sidenav DOM is untouched, and <aside> scroll position is preserved natively. The sessionStorage workaround in app/docs/layout.ts can be deleted.
- Any nested-layout app: the same primitive works whether the partial-swap region is 2, 3, or 5 levels deep.
- Mixed layouts: pages that don't opt-in fall through to the existing one-level shell detection or full body swap. No regression risk.
What's deliberately deferred (future passes)
- Keyed
data-key DOM diff inside the frame. Adopt Remix v3's diff-dom.ts algorithm to preserve input values, <details> open state, popover state, scroll positions on inner scroll containers across nav. Today's replaceChildren is coarse, but fine for v1 since the frame itself is preserved (outer scroll, sidenav, etc.).
X-Webjs-Frame server optimization to avoid re-rendering layouts the client already has.
- Server-pushed partial updates (turbo-stream equivalent) via
<webjs-stream action="replace" target="...">. Separate feature, useful for SSE/WebSocket-driven UI.
- Frame-scoped error boundaries. If a frame fetch 5xxs, render only the frame's
error.ts, not the whole page.
Implementation plan
packages/core/src/webjs-frame.js: new file, the custom element.
packages/core/index.js: export WebjsFrame. Re-export <webjs-frame> via auto-registration import (so any app that imports @webjsdev/core gets it).
packages/core/src/router-client.js: add findActiveFrame(), frame-swap branch in navigate(). Preserve existing findLayoutShell and full-body fallback.
packages/core/src/router-client.js (form path): apply the same frame-extract to form submit responses.
packages/server/src/dev.js: accept X-Webjs-Frame header in dev mode (no-op for v1 but adds the request signal for telemetry).
- Tests:
packages/core/test/routing/router-client.test.js: frame detection, querySelector with various ids, fallback when source frame missing.
test/e2e/nested-layout-partial-swap.test.mjs: load /docs/components/a, scroll sidenav, click /docs/components/b, assert scroll preserved AND only frame children swapped.
- Docs:
AGENTS.md: add <webjs-frame> to the public API table.
agent-docs/advanced.md: new "Frames" section under the client-router doc.
docs/ app: a new docs page showing the pattern.
packages/cli/templates/: none for v1 (frames are opt-in, no scaffold change needed).
- ui-website cleanup: remove the
sessionStorage workaround in app/docs/layout.ts after the frame lands and tests prove scroll preservation.
Open questions
- Should
<webjs-frame> ship a src="" attribute for lazy loading like turbo-frame does? Probably yes eventually, but not for v1. The motivating use case is layout-scope, not lazy data.
- Should there be a sub-element registry (something like
data-layout="docs" as a shorthand)? Keep it explicit for v1: one mechanism. Consider sugar later.
X-Webjs-Frame header naming. Webjs-Frame (like Turbo-Frame) is shorter. Either works, and v1 implements neither for response routing so the bikeshed is deferred.
References
- Turbo source:
frame_controller.js:132-148 (response parse), frame_renderer.js:5-16 (swap), link_interceptor.js:48 (innermost-frame rule)
- Remix v3 source:
packages/component/src/lib/frame.ts:1134-1146 (comment markers), diff-dom.ts:124-162 (live-attr preservation list)
- Next.js source:
packages/next/src/client/components/router-reducer/ppr-navigations.ts:230,251,292,354,486 (cache reuse vs create), walk-tree-with-flight-router-state.tsx:106-112 (server-side tree walk)
- webjs current:
packages/core/src/router-client.js (findLayoutShell, single body-children scan)
Research/design record for the nested-layout partial-swap client navigation feature (SHIPPED). Recorded here per the agent-docs cleanup convention (#548): a research/design record is a labeled
researchissue (durable record in the body + comments), then CLOSED, never a file underagent-docs/and never a comment buried on an unrelated PR.No code change. Runtime how-to-use guidance for callers lives in
agent-docs/advanced.md(Client router section) and the API table inAGENTS.md.Provenance: originally
agent-docs/ssr-partial-nav-design.md, removed from the directory in #552 (Closes #548). Supersedes the now-closed PR #559 (the record was first mis-filed as aresearch:PR before the convention settled on a labeled issue).SSR partial navigation: design note
Status: SHIPPED (feature/nested-layout-partial-swap, 2026-05-16).
The mechanism described below is implemented and tested. This document
is preserved as the design record. Runtime reference for callers lives
in
agent-docs/advanced.md(Client router section) and the frameworkAPI table in
AGENTS.md.Motivating bug (resolved): ui-website docs sidenav lost scroll on
every link click because the docs layout sat 2 levels deep under the
root layout, beyond
findLayoutShell's body-direct-child probe.Previous workaround (now deleted):
app/docs/layout.tssaved /restored
.docs-sidenavscrollTopviasessionStorageon everywebjs:navigateevent. Removed in the same PR as the framework fix.What actually shipped vs. what's below:
<webjs-frame>as the primaryprimitive. During design discussion the decision evolved to make
layout-marker discovery auto-derived from folder structure, so
layout authors write nothing.
<webjs-frame>ships as the escapehatch for non-layout partial-swap regions (rare).
<!--wj:children:<segment-path>-->commentpairs (Remix v3 lineage), not the
<webjs-frame>-element approachsketched below.
attribute preservation, and per-segment
<template id="wj-loading:...">cloning all shipped in the same PR (originally deferred as v2+).
The original
<webjs-frame>-centric sketch below is preserved ashistorical context.
Goal
Preserve the DOM of any layout, at any depth, across same-origin navigations. Re-render only the deepest segment that actually changed.
Non-goals
<Frame src>style). Wire model stays one SSR response per nav.Background: how the four references handle this
<turbo-frame id="X">(flat DOM element)Turbo-Frame: Xheader<turbo-frame>of the click (closest())<!--rmx:f:id-->...<!--/rmx:f-->comment markers + per-framesrc<template id>streams<Frame name="...">+rmx-targeton linkFlightRouterStatetuple + per-segmentCacheNodetreereact-server-dom-webpack)Routescontroller withoutlet()+ child controllers viaRoutesConnectedEventClosest fit to webjs's current router: Turbo. webjs already mirrors Turbo Drive (link interception, body swap,
pushState,data-no-router≡data-turbo="false").Recommendation
Adopt a Turbo-style frame primitive:
<webjs-frame id="...">. Layouts that want partial-swap behavior wrap their replaceable region:Algorithm:
packages/core/src/router-client.jsdeltaExisting
findLayoutShell(body)stays as a fallback. AddfindActiveFrame(linkEl):That's the entire detection delta: a
querySelectorkeyed by the active frame's id, with the existing logic preserved as fallback.<webjs-frame>element: ~30 linesLight DOM (default): no shadow boundary, no slot mechanics. The element exists purely as a swap anchor with an addressable
id. Children are normal light-DOM children that the router replaces viareplaceChildren.Server side:
X-Webjs-Framerequest header (optional optimization)When set, the SSR pipeline can return only the matching frame's HTML wrapped in a minimal stub document, skipping the rest of the layout chain. Wire is still plain HTML (no new format). v1 ships without this optimization. Full SSR response, client extracts what it needs. The header is forward-compat for the perf pass.
Head merging
Same as today's
mergeHead: replace<title>, merge<meta>tags byname/property, append new<link>/<style>elements, dedupe.Script handling inside the swap region
Re-execute
<script>elements that match the existing one-level-shell path'srunScriptslogic. Idempotent registration viaClass.register()makes this safe. The framework already handlescustomElements.definecollisions.Edge cases
<webjs-frame>butdata-no-routerfindLayoutShellmatch<webjs-frame>sclosest('webjs-frame')returns the nearest enclosing frame. Mirrors Turbo behavior.data-frame="_top"on a linkWhat this fixes
<webjs-frame id="docs-content">. Sidenav lives outside the frame. Navigation between/docs/components/a→/docs/components/bonly swaps frame children. The sidenav DOM is untouched, and<aside>scroll position is preserved natively. ThesessionStorageworkaround inapp/docs/layout.tscan be deleted.What's deliberately deferred (future passes)
data-keyDOM diff inside the frame. Adopt Remix v3'sdiff-dom.tsalgorithm to preserve input values,<details>open state, popover state, scroll positions on inner scroll containers across nav. Today'sreplaceChildrenis coarse, but fine for v1 since the frame itself is preserved (outer scroll, sidenav, etc.).X-Webjs-Frameserver optimization to avoid re-rendering layouts the client already has.<webjs-stream action="replace" target="...">. Separate feature, useful for SSE/WebSocket-driven UI.error.ts, not the whole page.Implementation plan
packages/core/src/webjs-frame.js: new file, the custom element.packages/core/index.js: exportWebjsFrame. Re-export<webjs-frame>via auto-registration import (so any app that imports@webjsdev/coregets it).packages/core/src/router-client.js: addfindActiveFrame(), frame-swap branch innavigate(). Preserve existingfindLayoutShelland full-body fallback.packages/core/src/router-client.js(form path): apply the same frame-extract to form submit responses.packages/server/src/dev.js: acceptX-Webjs-Frameheader in dev mode (no-op for v1 but adds the request signal for telemetry).packages/core/test/routing/router-client.test.js: frame detection, querySelector with various ids, fallback when source frame missing.test/e2e/nested-layout-partial-swap.test.mjs: load/docs/components/a, scroll sidenav, click/docs/components/b, assert scroll preserved AND only frame children swapped.AGENTS.md: add<webjs-frame>to the public API table.agent-docs/advanced.md: new "Frames" section under the client-router doc.docs/app: a new docs page showing the pattern.packages/cli/templates/: none for v1 (frames are opt-in, no scaffold change needed).sessionStorageworkaround inapp/docs/layout.tsafter the frame lands and tests prove scroll preservation.Open questions
<webjs-frame>ship asrc=""attribute for lazy loading like turbo-frame does? Probably yes eventually, but not for v1. The motivating use case is layout-scope, not lazy data.data-layout="docs"as a shorthand)? Keep it explicit for v1: one mechanism. Consider sugar later.X-Webjs-Frameheader naming.Webjs-Frame(likeTurbo-Frame) is shorter. Either works, and v1 implements neither for response routing so the bikeshed is deferred.References
frame_controller.js:132-148(response parse),frame_renderer.js:5-16(swap),link_interceptor.js:48(innermost-frame rule)packages/component/src/lib/frame.ts:1134-1146(comment markers),diff-dom.ts:124-162(live-attr preservation list)packages/next/src/client/components/router-reducer/ppr-navigations.ts:230,251,292,354,486(cache reuse vs create),walk-tree-with-flight-router-state.tsx:106-112(server-side tree walk)packages/core/src/router-client.js(findLayoutShell, single body-children scan)