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
11 changes: 11 additions & 0 deletions .agents/skills/webjs/references/client-router-and-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ document.addEventListener('webjs:navigation-error', (e) => {
});
```

**Observing a degradation.** Some conditions make a soft nav impossible, and the router then degrades to a full page load rather than risk a corrupt DOM (the #1015 integrity model). Every such path dispatches `webjs:navigation-fallback` on `document`, in ALL environments including production, with `detail { cause, href, willReload }`. Causes: `no-shared-boundary`, `live-boundaries-malformed`, `incoming-boundaries-malformed`, `readyState-loading`, `deploy-mismatch`, `deploy-mismatch-reload-suppressed`, `navigation-error-unrecoverable`, `revalidation-discarded`. `willReload` is false for a degradation that does NOT reload (a dropped background revalidation), so a listener can tell "this click became a document load" from "a background op was skipped". Not cancelable: by the time it fires the degradation is the only safe option. In dev a deduped console warning also prints.

```ts
document.addEventListener('webjs:navigation-fallback', (e) => {
// A full document load on a click is a UX regression worth knowing about in prod.
if (e.detail.willReload) analytics.track('router_full_load', e.detail);
});
```

**Form state.** A form submitting through the router gets `aria-busy="true"` for the in-flight duration, plus bubbling `webjs:submit-start` and `webjs:submit-end` (detail `{ form, url, ok }`) events. Style `form[aria-busy="true"]` in pure CSS or listen for the events.

## Link Prefetch
Expand All @@ -85,6 +94,8 @@ Next-style aliases work (`true` = `render`, `auto` = `viewport`, `false` = `none

A prefetch issues a real GET, so any mutating endpoint MUST be a POST or a `<form>` submission (which the router never prefetches), never a GET link. A `webjs:prefetch` event fires on `document` when a fragment lands in the cache.

**The cache is ANCHOR-VALIDATED, not just URL-keyed (#1114).** A prefetched fragment is a reduced response: the request carries `X-Webjs-Have` (the boundaries the client already holds) and the server returns only the divergent part from the deepest boundary it short-circuited on. That boundary is the fragment's ANCHOR, and the fragment applies to any live DOM that still offers it with the same route-key. So on consume the router checks the anchor, not the whole `have` string: a root-anchored fragment survives an unrelated navigation and stays a cache hit, while one anchored at `/docs` is discarded once you leave /docs, because applying it would hand the swap a tree sharing no boundary with the live DOM, which correctly degrades to a full page load. A discard costs one round-trip. The router also never prefetches the page it is already on (#1106), since that request cannot serve any later navigation and only occupies a capped cache slot; a hover's intent timer routinely fires after the click it belongs to has already swapped, which is when that happens. Both behaviours are internal; nothing to configure.

## `<webjs-frame>` Partial-Swap Regions

`<webjs-frame>` is WebJs's take on Turbo Frames, so most `<turbo-frame>` muscle memory transfers. It is a lazy, URL-addressable region that swaps on its own, driven by a link or form targeting its id, and it ships zero component JS. Use it for a region that loads or refreshes INDEPENDENTLY of a full-page navigation (a marketing widget, tabbed UI, a filtered results panel), which a page or layout cannot express.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ type ActionResult<T> =

## Client navigation: automatic, nothing to opt into

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. 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, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); scroll is restored on back/forward. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
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, marked `Vary: X-Webjs-Have` so a shared cache can never serve the reduced body to a full-page navigation); 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. A non-GET `<form>` whose target page exports an `action` is the no-JS write-path (with JS the router applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). 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), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition">`, 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
Loading
Loading