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
20 changes: 20 additions & 0 deletions .agents/skills/webjs/references/client-router-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<nav data-preserve-scroll> <!-- covers every link inside -->
<a href="?sort=new">Newest</a>
<a href="?sort=top">Top</a>
<a href="/" data-preserve-scroll="false">Home</a> <!-- opts back out -->
</nav>
<form method="post" action=${saveDraft} data-preserve-scroll>...</form>
```

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 `<a>` 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
```
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ Remix ships a `<ScrollRestoration />` 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 `<Link scroll={false}>` 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.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; 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 `<webjs-frame>` 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 `<webjs-frame id>` still being live rather than by an anchor (a subtree carries no boundary comment). The server marks the sliced response `X-Webjs-Frame: <id>`, 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 `<form>` 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 `<form method="post">` 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), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition" content="same-origin">`, 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** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` 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), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition" content="same-origin">`, 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** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` 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).

---

Expand Down
6 changes: 5 additions & 1 deletion gallery/app/features/client-router/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export default function ClientRouterExample() {
Opt out app-wide with <code class="font-mono">{ "webjs": { "clientRouter": false } }</code>,
or per-link with <code class="font-mono">data-no-router</code> (use it for
auth flows like <code class="font-mono">/logout</code> that must reset
in-memory state).
in-memory state). A forward navigation scrolls to top; per link (or per
wrapping element) <code class="font-mono">data-preserve-scroll</code> 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.
</p>
`;
}
8 changes: 7 additions & 1 deletion gallery/app/features/metadata/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ export default function MetadataExample({
Current title source:
<code class="font-mono text-sm">${topic ? '?topic=' + topic : '(default, no ?topic=)'}</code>
</p>
<ul class="list-disc pl-5 mb-4">
<!-- data-preserve-scroll keeps the reader's scroll offset instead of
jumping to the top on click. These links change only the query string
of the page you are already on, so the control you just used would
otherwise scroll out from under you. It sits on the <ul> and the router
resolves it with closest(), so one mark covers every link inside; a
single link can opt back out with data-preserve-scroll="false". -->
<ul class="list-disc pl-5 mb-4" data-preserve-scroll>
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=webjs">?topic=webjs</a></li>
<li><a class="text-primary underline underline-offset-2" href="/features/metadata?topic=Routing">?topic=Routing</a></li>
<li><a class="text-primary underline underline-offset-2" href="/features/metadata">clear the param</a></li>
Expand Down
7 changes: 7 additions & 0 deletions gallery/modules/client-router/components/router-controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
// swap an <a> click does, but from an event handler (after a save, a wizard
// step, etc.). `revalidate(url?)` evicts the browser snapshot cache so the next
// visit refetches fresh HTML instead of the cached page.
// `navigate(url, { scroll: false })` is the programmatic twin of
// `data-preserve-scroll` on a link: the same soft swap, without the
// scroll-to-top. Reach for it after an in-page action that changes the URL but
// should not move the reader.
// `refreshPage(mode?)` re-renders the page you are ALREADY on and swaps the
// result in place, recording no history entry and never scrolling, so the reader
// keeps their place. 'page' (the default) morphs the deepest shared boundary, so
Expand Down Expand Up @@ -37,6 +41,9 @@ export class RouterControls extends WebComponent {
<button
@click=${() => navigate('/features/client-router/second')}
class=${buttonClass({ variant: 'secondary' })}>navigate() to page two</button>
<button
@click=${() => navigate('/features/client-router/second', { scroll: false })}
class=${buttonClass({ variant: 'secondary' })}>navigate(..., { scroll: false })</button>
<button
@click=${() => revalidate()}
class=${buttonClass({ variant: 'link', size: 'none' })}>revalidate() the snapshot cache</button>
Expand Down
2 changes: 1 addition & 1 deletion packages/core/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export { enableClientRouter, disableClientRouter, revalidate, refreshPage, loadF
// `string`, so this is non-breaking; once generated, a bogus in-app path is a
// tsserver error. The runtime is the same async function in router-client.js.
import type { Route } from './src/routes.d.ts';
export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;
export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise<void>;
// The full lit-html-parity directive set (mirrors index.js); the per-directive
// declarations live in src/directives.d.ts. `repeat` is re-exported above.
export {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/router-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Route } from './routes.js';

export function enableClientRouter(): void;
export function disableClientRouter(): void;
export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;
export function navigate(url: Route, opts?: { replace?: boolean; scroll?: boolean }): Promise<void>;
export function loadFrame(
frameEl: Element,
url: string,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/router-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export {
prefetchSuppressed as _prefetchSuppressed,
prefetchTake as _prefetchTake,
} from './router-client/prefetch.js';
export {
resolvePreserveScroll as _resolvePreserveScroll,
} from './router-client/scroll.js';
export {
snapshotCache as _snapshotCache,
} from './router-client/snapshot-cache.js';
Expand Down
Loading
Loading