` in the body and may not
+ // find it (an absent id was never rendered, and a streamed page's content
+ // sits in a template the swap does not descend into), so it leaves the
+ // region unchanged while the navigation around it completes. The scroll
+ // offset survives that (#1427 holds it for anything frame-scoped, this path
+ // included), so what the reader loses is the panel, not their place.
setup('/tasks?status=streamed');
try {
window.fetch = async (url, init) => {
diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs
index da6bf88e0..55e4ac617 100644
--- a/test/e2e/e2e.test.mjs
+++ b/test/e2e/e2e.test.mjs
@@ -3031,6 +3031,53 @@ describe('E2E: Blog example', { skip: !process.env.WEBJS_E2E && 'set WEBJS_E2E=1
} finally { page.off('request', onReq); }
});
+ test('frame: a frame swap holds the window scroll, while the _top breakout still scrolls to top (#1427)', async () => {
+ // The router used to gate its scroll-to-top on "is this a foreground
+ // navigation", which a frame click is (it advances the URL), so filtering
+ // a panel threw the reader back to the top of the page with the panel
+ // they had just clicked in off screen. Over the real wire here, because
+ // the offset only survives if the document is genuinely tall and the swap
+ // genuinely applied.
+ await page.goto(`${baseUrl}/frame-demo?tab=one`, { waitUntil: 'domcontentloaded', timeout: 15000 });
+ await sleep(1500);
+
+ // The page carries a 1200px spacer for the lazy deferred frame, so it
+ // scrolls. Stop well short of that frame: pulling it into view would start
+ // a self-load this case is not about.
+ const startY = await page.evaluate(() => {
+ window.scrollTo({ left: 0, top: 300, behavior: 'instant' });
+ return window.scrollY;
+ });
+ assert.equal(startY, 300, 'the page is tall enough to scroll, so the assertion below means something');
+
+ await page.evaluate(() => document.getElementById('tab-two')?.click());
+ await waitForCond(
+ () => page.evaluate(() => document.getElementById('panel-body')?.getAttribute('data-tab') === 'two'),
+ 6000,
+ () => 'the tab click should swap the frame to tab two',
+ );
+
+ const afterFrame = await page.evaluate(() => window.scrollY);
+ assert.equal(afterFrame, 300, `a frame swap must leave the window scroll alone, got ${afterFrame}`);
+
+ // The same page, the same scroll offset, a link that is NOT a frame nav.
+ // Without this the assertion above would also pass on a router that had
+ // simply stopped scrolling altogether.
+ await page.evaluate(() => document.getElementById('top-link')?.click());
+ await waitForCond(
+ () => page.evaluate(() => location.pathname === '/'),
+ 6000,
+ () => `_top should navigate to "/", got ${page.url()}`,
+ );
+ const after = await page.evaluate(() => ({
+ y: window.scrollY,
+ reachable: document.documentElement.scrollHeight - window.innerHeight,
+ }));
+ assert.ok(after.reachable > 300,
+ `the home page must be tall enough to hold the old offset, else a 0 proves nothing (max ${after.reachable})`);
+ assert.equal(after.y, 0, `a page navigation must still scroll to top, got ${after.y}`);
+ });
+
test('frame: aria-busy toggles true during the frame fetch and clears after, with start+finish events', async () => {
await page.goto(`${baseUrl}/frame-demo`, { waitUntil: 'domcontentloaded', timeout: 15000 });
await sleep(1500);
diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts
index 2d609183a..49f915f20 100644
--- a/website/app/docs/client-router/page.ts
+++ b/website/app/docs/client-router/page.ts
@@ -152,6 +152,13 @@ export default async function PostPage({ params }) {
When the user clicks "Load more", the router's closest('webjs-frame') from the click target finds #comments. The fetched response is expected to contain a <webjs-frame id="comments"> too. Only its children swap into the live frame, leaving the article body (and any reading scroll position, video playback, etc.) fully intact.
This takes precedence over the layout-marker mechanism. Most apps never need it. Only reach for it when you've identified that the auto-marker swap is wider than the actual change.
+ A frame swap never scrolls the page
+ A page navigation scrolls to top, the way a browser does. A frame swap does not: it changes one region and leaves the rest of the document standing, the reader's scroll offset included. Without that, filtering a panel below the fold would throw the reader back to the top of the page, with the panel they just clicked in off screen. The rule covers every way a frame swaps, a nested link, an external data-webjs-frame trigger, a frame-targeted form submission, and a src self-load, and it covers a #hash on a frame link too, which rides the URL without moving the viewport.
+ One thing this rule does NOT cover, because the router never sees it: a pure fragment link to a named anchor (#section), whose path and query match the page it sits on. The click handler bows out before preventDefault, so the browser performs its own native fragment jump and the window does move.
+ The empty fragment is the trap, and it goes the other way. href="#" (and href="") parse to an empty hash, and the bow-out tests that hash for truthiness, so it does not fire. The click becomes an ordinary frame navigation, which re-fetches the frame and, under this rule, leaves the window still. So a bare <a href="#">Back to top</a> placed INSIDE a frame does nothing visible. Give it a real target (href="#top"), which the bow-out honours, or a click handler that scrolls, whose preventDefault runs first and makes the router stand down.
+ Read "never scrolls" as "WebJs never writes a scroll", not as a promise the viewport cannot move. A swap that makes the panel shorter shortens the document with it, and a reader parked near the bottom is then holding an offset the document can no longer reach, so the browser clamps it. On the gallery's frames demo, filtering from All to Done at the bottom of the page moves the window from 474 to 405, exactly the 69px the document lost. The router writes no scroll there, and any DOM change that shortens a page does the same. Keeping the frame a stable height across its states avoids it.
+ The escapes are page navigations and DO scroll to top: data-webjs-frame="_top", and an id that cannot be matched to a live frame, which warns and degrades to a normal navigation. Do not read that second one as covering a response that lacks the requested frame (the webjs:frame-missing warning): there the frame resolved and the navigation stayed frame-scoped, so the offset holds and only the panel is left unchanged. Turbo's autoscroll opt-in, which scrolls the frame itself into view on swap, has no WebJs equivalent; the router simply never writes scroll for a frame.
+
External targeting (data-webjs-frame) and _top
A trigger does not have to be nested inside the frame it drives. Mirroring Turbo's data-turbo-frame, an <a> or <form> (or any ancestor of it) carrying data-webjs-frame="<id>" drives the frame with that id from anywhere in the document, resolved via getElementById. So an external nav/sidebar link or a filter form can drive a content frame it does not enclose.
<nav data-webjs-frame="results">
@@ -257,7 +264,7 @@ revalidate();
Frame links are prefetched too, in their own dimension
A link that drives a <webjs-frame> is prefetched with the same X-Webjs-Frame header its click will send, so the warm entry is the frame subtree the swap actually needs and the click is instant. Without this the hover cost a duplicate request and bought nothing, because the click needs a different response than a page-level prefetch holds.
Since the server varies that response on the request header, the cache keys an entry by URL plus frame id. A prefetched page fragment is therefore never applied into a frame region, nor a frame subtree into a full-page swap, and both can be cached for one URL at once. A frame entry is validated by its frame still being in the document rather than by a boundary anchor, because a subtree carries no boundary comment. A framed link pointing at the URL you are already on is not prefetched at all: that is a frame refresh, and a refresh must show fresh bytes. A <webjs-frame src> that loads itself stands outside this cache entirely, neither reading it nor keeping an entry it supersedes, for the same reason.
- A route that streams does not get this. The server slices the subtree only when the render did not stream, so a route with a loading.{js,ts} or a Suspense boundary answers every framed request with the whole document instead. The router will not cache that under a frame key. The swap looks for the frame inside the response body, and on a streamed page it may not be there yet: content still inside a pending boundary arrives in a <template> the swap does not descend into. Consuming such an entry would then leave the region unchanged and log a warning, while the navigation around it still completes: the URL advances and the page scrolls to top, so the reader gets a changed address over an unchanged panel. A frame sitting outside every boundary does arrive in the first flush and would be found, but the router cannot tell the two cases apart from the bytes, so it declines the body either way. On those routes the click still costs its round trip, exactly as before. The refusal is remembered, so the link re-asks about once per cache TTL rather than on every hover (the memo set is itself small and capped, so a page with many distinct refused frame links can re-ask sooner).
+ A route that streams does not get this. The server slices the subtree only when the render did not stream, so a route with a loading.{js,ts} or a Suspense boundary answers every framed request with the whole document instead. The router will not cache that under a frame key. The swap looks for the frame inside the response body, and on a streamed page it may not be there yet: content still inside a pending boundary arrives in a <template> the swap does not descend into. Consuming such an entry would then leave the region unchanged and log a warning, while the navigation around it still completes: the URL advances, though the scroll offset is left alone as on any frame nav, so the reader gets a changed address over an unchanged panel. A frame sitting outside every boundary does arrive in the first flush and would be found, but the router cannot tell the two cases apart from the bytes, so it declines the body either way. On those routes the click still costs its round trip, exactly as before. The refusal is remembered, so the link re-asks about once per cache TTL rather than on every hover (the memo set is itself small and capped, so a page with many distinct refused frame links can re-ask sooner).
One thing to expect in the network tab: because dedupe is per dimension, a page that links the same URL twice, once driving a frame and once not, warms both and issues two speculative requests where it previously issued one. The two responses genuinely differ and a click on either link needs its own, so collapsing them would leave one of the links unwarmed ahead of the click. Both stay inside the same cache cap, concurrency gate, TTL, and Save-Data gate as every other prefetch, and no new prefetch trigger is added.
Observing a degraded navigation (webjs:navigation-fallback)