diff --git a/.agents/skills/webjs/references/client-router-and-streaming.md b/.agents/skills/webjs/references/client-router-and-streaming.md index ac8694af9..3e7bd378e 100644 --- a/.agents/skills/webjs/references/client-router-and-streaming.md +++ b/.agents/skills/webjs/references/client-router-and-streaming.md @@ -44,12 +44,30 @@ enableClientRouter(); // turn soft navigation back on Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped. +**Per link, keep the reader's scroll offset with `data-preserve-scroll`.** A forward navigation scrolls to top, matching what a browser does and what Next and Remix 3 do. The attribute is the escape hatch for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most, because a searchParams-only navigation already morphs the deepest shared boundary and preserves hydrated component state, so the scroll is the only thing such a navigation still throws away. + +```html + +
...
+``` + +It resolves through `closest()`, so one mark on a wrapping element covers every link in it (the same walk `data-webjs-frame` uses), and `data-preserve-scroll="false"` on a nearer element opts back out. On a form the lookup starts at the submitter and falls back to the form itself, so a marked form covers its own buttons whether they sit inside it or are attached from elsewhere with `form="id"`. The fallback only fills in a missing mark, so `data-preserve-scroll="false"` on a button still opts that button out of a marked form. + +Three things it does NOT do. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with. And it is inert with JS off, where the link is a plain `` and the browser does whatever it does, so nothing about a page's correctness may depend on it. + +It carries the reader's CURRENT offset onto the destination; it does not restore the destination's remembered offset. Those are different features, and the second one is not something WebJs ships. So this is the wrong tool for a "back to the list" link, where the offset the reader wants is the one they had in the list, not the one they have in the article. + **Programmatic navigation and cache eviction.** ```js import { navigate, revalidate } from '@webjsdev/core'; await navigate('/about'); // push history await navigate('/login', { replace: true }); // replace history +await navigate('/products?sort=new', { scroll: false }); // keep the reader's offset revalidate('/products/123'); // evict one URL from the snapshot cache revalidate(); // clear the entire snapshot cache ``` @@ -70,6 +88,8 @@ It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first It does NOT reload changed component modules and cannot: `customElements.define` is once-per-tag and a module url is fetched once per document. A caller whose change touched browser code has to reload. This is exactly why the dev live-reload client calls `refreshPage` for a page or layout edit and `location.reload()` for a component edit (#1398, and see `references/runtime.md` for which dev modes get the refresh). +Keep the two scroll concerns apart. The Back/Forward restore below is the BROWSER's and has no per-link knob, because the offset it replays is one the browser recorded. The forward-navigation scroll-to-top is the router's own write, and `data-preserve-scroll` is its knob. + **Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app: - **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the restore, which is the BROWSER's (see the next bullet) and which the router protects with a suppression window while the page settles. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code. diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md index 917cb48d5..d9d1f0f95 100644 --- a/.agents/skills/webjs/references/muscle-memory-gotchas.md +++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md @@ -216,6 +216,8 @@ Remix ships a `` component, Next has a `scrollRestoration` This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code. +**One scroll reflex DOES port, and only one.** Next's `` has a WebJs spelling: `data-preserve-scroll` on the link, or on any element wrapping a group of them, and `navigate(url, { scroll: false })` programmatically. It suppresses the forward-navigation scroll-to-top, which is a write the ROUTER makes, and that is why it exists while none of the restore recipes above do: the restore is the browser's and the router is not a writer on it. Everything else in this section stands unchanged, including the rule not to hand-roll a restore. The attribute keeps the reader's CURRENT offset on a forward nav; it does not bring back the offset they once had on the destination, which is what a hand-rolled `sessionStorage` restore is usually reaching for. + ### Server-only code: the `.server.ts` boundary, not a `server-only` package Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component. diff --git a/AGENTS.md b/AGENTS.md index 394e42db4..0d7c96ed3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -419,7 +419,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open ``, close ``; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. **The cache also carries a FRAME dimension (#1407):** a link driving a `` is prefetched with the `X-Webjs-Frame` header its click will send, so the warm entry is the subtree the swap needs and the click costs no round trip. The key is URL plus frame id, so a page fragment can never be applied into a frame region nor a frame subtree into a page swap, and a frame entry is validated by its `` still being live rather than by an anchor (a subtree carries no boundary comment). The server marks the sliced response `X-Webjs-Frame: `, so an unmarked answer to a framed request is a whole document (a streamed render, or an id absent from the output) and its body is discarded rather than stored, while the REFUSAL is memoed outside the fragment cache so the link re-asks about once per TTL rather than on every hover (that memo set is capped too, so a page with many distinct refused frame links can re-ask sooner) (only a deploy or an in-place `refreshPage` drops those early, never `revalidate()`, which is the post-mutation api); a framed link to the CURRENT url is a refresh and is not prefetched at all. A non-GET `
` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). **The router ENCODES that body per the declared `enctype`** (#1307), resolved with native precedence (a submitter's `formenctype` over the form's, decided on PRESENCE rather than on the value being non-empty, #1322: a `formenctype=""` or `formmethod=""` overrides the form and then falls to its OWN invalid-value default, urlencoded and GET respectively, while `formtarget=""` is a plain string reflection with no enumerated states, so it overrides the form and means the current browsing context): `multipart/form-data` sends `FormData`, and `application/x-www-form-urlencoded`, the HTML default and therefore what a plain `` means, sends `URLSearchParams` (a `File` serializes as its name, as the platform does). It previously built `FormData` for everything, so an ordinary POST form sent a urlencoded body with JS off and a multipart body with JS on. A `text/plain` POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which keeps both paths doing the same thing. A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload. -The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override, and frames participate: a link driving a frame is warmed in that frame's own dimension), **``** partial-swap regions, **View Transitions** (opt-in via ``, where the router checks that exact `content` value and a bare tag enables nothing, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). +The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override, and frames participate: a link driving a frame is warmed in that frame's own dimension), **`data-preserve-scroll`** (per link, or per wrapping element, keeping the reader's offset instead of the default forward-nav scroll-to-top, with `navigate(url, { scroll: false })` as its programmatic twin), **``** partial-swap regions, **View Transitions** (opt-in via ``, where the router checks that exact `content` value and a bare tag enables nothing, plus `data-webjs-permanent` to persist a live element), **stream actions** (`` element-level updates, #248), and the **opt-in nav-loading indicator** (`` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2). --- diff --git a/gallery/app/features/client-router/page.ts b/gallery/app/features/client-router/page.ts index 8511c6edd..5010582f9 100644 --- a/gallery/app/features/client-router/page.ts +++ b/gallery/app/features/client-router/page.ts @@ -39,7 +39,11 @@ export default function ClientRouterExample() { Opt out app-wide with { "webjs": { "clientRouter": false } }, or per-link with data-no-router (use it for auth flows like /logout that must reset - in-memory state). + in-memory state). A forward navigation scrolls to top; per link (or per + wrapping element) data-preserve-scroll keeps + the reader where they are, for a filter or tab link that changes only part + of what they are looking at. A hash link still scrolls to its anchor, and + a frame-targeted link never scrolled anyway.

`; } diff --git a/gallery/app/features/metadata/page.ts b/gallery/app/features/metadata/page.ts index c76148691..6e2ad0030 100644 --- a/gallery/app/features/metadata/page.ts +++ b/gallery/app/features/metadata/page.ts @@ -42,7 +42,13 @@ export default function MetadataExample({ Current title source: ${topic ? '?topic=' + topic : '(default, no ?topic=)'}

-