Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ html`<webjs-frame id="activity">…contents…</webjs-frame>`

On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree. A link that drives a frame participates in link prefetch like any other, in that frame's own dimension (#1407), so a hovered or viewport-warmed frame link swaps on click with no round trip. A `<webjs-frame src>` SELF-load is the exception: it neither reads nor keeps that cache, since asking a frame to load its own src is a freshness request rather than a hover being followed. See the prefetch section above for the frame dimension's rules.

**A frame swap never moves the window scroll.** A page navigation scrolls to top, the way a browser does; a frame swap changes one region and leaves the rest of the document standing, the reader's scroll offset included. That holds for a nested link, an external `data-webjs-frame` trigger, a frame-targeted form submission, and a `src` self-load alike, and it holds for a `#hash` on a frame link too, which rides the URL without moving the viewport. It does NOT cover a pure fragment link to a NAMED anchor (`#section`, same path and query), because the router never sees one: the click handler bows out before `preventDefault`, so the browser does its own native fragment jump and the window moves.

The EMPTY fragment is the trap, and it goes the other way. `href="#"` (and `href=""`) parse to an empty `URL.hash`, and the bow-out tests the hash for truthiness, so it does not fire: the click is an ordinary frame nav that re-fetches the frame and, under this rule, leaves the window still. So a bare `<a href="#">Back to top</a>` INSIDE a frame does nothing visible. Give it a real target (`href="#top"`), which the bow-out then honours, or a click handler that scrolls (its `preventDefault` runs first and the router stands down).

The escapes are page navigations and DO scroll: `data-webjs-frame="_top"`, and an id `resolveTargetFrameId` cannot match to a live frame, which warns and degrades to a normal nav. 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 nav 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.

**Read "never moves" as "WebJs never writes one", not as a guarantee 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. Measured 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 wrote no scroll there (verified with every scrolling API instrumented), and any DOM change that shortens a page does the same thing. Keeping the frame a stable height across its states avoids it entirely.

**External targeting.** A trigger does not have to be nested inside the frame. An `<a>` or `<form>` carrying `data-webjs-frame="<id>"` drives that frame from anywhere (an explicit `data-webjs-frame` wins over the enclosing-frame default). `data-webjs-frame="_top"` is a reserved token forcing a full-page navigation that breaks out of the frame.

**Self-loading.** Give a frame a `src` and it self-fetches (through the same swap path).
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/router-client/fetch-apply.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,26 @@ export async function fetchAndApply(href, frameId, recordHistory, optimisticStat
// (b) a cache-miss popstate: modern browsers fire scroll-
// restoration themselves before dispatching popstate, so
// leaving scroll alone preserves the browser-native UX.
if (recordHistory) {
//
// And never for a FRAME-scoped response (#1427). `recordHistory` means "a
// foreground navigation the reader initiated", which a frame click is (it
// advances the URL, deliberately), so a frame swap used to fall into the
// page-navigation scroll by omission rather than by decision. A frame swaps
// ONE region and leaves the rest of the document standing, the scroll offset
// included, so the router writes no scroll for it: on a page whose frame sits
// below the fold, scrolling to top throws the region the reader just clicked
// in off screen. Turbo, which `<webjs-frame>` is modelled on, likewise never
// scrolls on a frame navigation (its `autoscroll` opt-in is a separate
// feature WebJs does not have). This is the same rule `restoreGeneration` in
// `scroll.js` already applies when deciding what ends a scroll-restore
// window, so the two now agree on what a frame nav is.
//
// The hash branch is excluded too. A `#anchor` on a frame link is no more a
// request to move the document viewport than the frame swap itself is, and
// one rule ("a frame swap never moves the window") beats two. `_top` and an
// unresolvable `data-webjs-frame` id both resolve to a null `frameId` in
// `resolveTargetFrameId`, so they stay page navigations and still scroll.
if (recordHistory && !frameId) {
// Use the final URL (after any server-side redirect) so hash
// anchors point at the document we actually rendered.
const url = new URL(finalUrl);
Expand Down
21 changes: 20 additions & 1 deletion packages/core/test/routing/browser/frame-missing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ suite('Client router: <webjs-frame> frame-missing contract (#251)', () => {
// pipeline. A click-driven frame nav records history, so an implementation
// that returned early on the sentinel would stop advancing the URL here,
// which nothing else in the suite would notice.
test('a frameless response still advances the URL, because the sentinel only reports', async () => {
test('a frameless response advances the URL and holds the scroll offset', async () => {
setup();
// Observe the history CALL rather than reading `location` afterwards.
//
Expand All @@ -171,16 +171,35 @@ suite('Client router: <webjs-frame> frame-missing contract (#251)', () => {
const pushed = [];
const origPush = history.pushState;
history.pushState = function (...args) { pushed.push(String(args[2])); };
// The SCROLL half of the same contract (#1427). This path keeps a truthy
// `frameId` all the way through, so the guard holds the offset even though
// nothing was applied: the reader gets a changed address over an unchanged
// panel, still in their place. The docs say so on both surfaces and nothing
// pinned it, which is how the claim it replaced went stale in the first
// place. The spacer is what gives the assertion teeth, since a document
// that cannot hold an offset reports 0 either way.
const spacer = document.createElement('div');
spacer.style.height = '3000px';
document.body.appendChild(spacer);
try {
window.fetch = () => htmlResponse(
'<!doctype html><html><head></head><body><h1 id="login">Login</h1></body></html>'
);
window.scrollTo({ left: 0, top: 400, behavior: 'instant' });
assert.equal(window.scrollY, 400,
'precondition: the page holds an offset, so a 0 below is the router moving it');

document.getElementById('frame-link').click();
await settle();
assert.equal(pushed.length, 1, 'the frame-missing return still records history');
assert.match(pushed[0], /\/no-frame-here$/, 'and it advanced to the navigation target');
assert.equal(window.scrollY, 400,
'a frame-missing response leaves the window where the reader put it');
} finally {
history.pushState = origPush;
// Reset before the next case: nothing else in this file expects an offset.
window.scrollTo({ left: 0, top: 0, behavior: 'instant' });
spacer.remove();
teardown();
}
});
Expand Down
Loading
Loading