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
2 changes: 2 additions & 0 deletions .agents/skills/webjs/references/routing-and-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ Three responses that are not the happy path:

The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.

A submitter's own `formmethod` / `formenctype` / `formtarget` overrides the form's on PRESENCE, not on the value being non-empty, and the client router resolves them the same way (#1322). So `<button type="submit" formmethod="">` really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default rather than inheriting the form's `method="post"`.

Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.

## Error, loading, and 404 boundaries
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe

## 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. **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. 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): `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 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. 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.
Comment thread
vivek7405 marked this conversation as resolved.

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" 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
16 changes: 16 additions & 0 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,22 @@ than aborting the whole session. `setHardNavigate` is TEST-ONLY and is
deliberately not re-exported from `index.js` / `index-browser.js`; tests reach
it through the same direct `src/router-client.js` import they already use.

There is exactly ONE sanctioned exception to the MUST above, and it holds only
Comment thread
vivek7405 marked this conversation as resolved.
when BOTH halves are true: the submission being measured cannot navigate at
all, AND the test is measuring a native default action that the guard's own
`preventDefault()` would cancel. Rung 7 of the submit bail ladder
(`test/routing/browser/submit-bail-ladder.test.js`) is the case it was written
for: a `method="dialog"` submission dismisses its `<dialog>` instead of
navigating, and asserting `dialog.open === false` is the whole point of the
test, so a guard there would cancel the very effect under test. A test taking
the exception owes the second channel separately, because the exception buys
out only the `preventDefault` half: that file holds a `setHardNavigate`
recorder for its whole lifetime and re-arms it after each guard is removed,
since an async swap from a NEIGHBOURING test can degrade during the unguarded
one and a real page load there aborts the session. It did, on Firefox, before
the recorder was added. If either half of the condition fails, install the
guard.

Cross-package tests that exercise core through the SSR pipeline
or scaffolds live at the repo root in `test/ssr/`,
`test/scaffolds/`, etc. See [`references/testing.md`](../../.agents/skills/webjs/references/testing.md).
Expand Down
34 changes: 26 additions & 8 deletions packages/core/src/router-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -859,9 +859,13 @@ function onSubmit(e) {
const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
if (submitter && submitter.hasAttribute('data-no-router')) return;

const target = (submitter && submitter.getAttribute('formtarget'))
|| form.getAttribute('target')
|| '';
// Presence, not truthiness, exactly as `getSubmitAction` resolves the action
// (#1322). The form-submission algorithm asks whether the submitter HAS a
// `formtarget`, so a present-but-empty one overrides the form and then means
// the current browsing context, per the rules for choosing a navigable.
const target = (submitter && submitter.hasAttribute('formtarget'))
? (submitter.getAttribute('formtarget') || '')
: (form.getAttribute('target') || '');
if (target && target !== '_self') return;

const method = getSubmitMethod(form, submitter);
Expand Down Expand Up @@ -911,14 +915,21 @@ function onSubmit(e) {
* Method resolution: submitter's `formmethod` wins over form's `method`.
* Returns lowercase.
*
* Resolved on PRESENCE, not truthiness (#1322), the same way `getSubmitAction`
* below resolves the action. The form-submission algorithm reads the
* submitter's `formmethod` if the submitter HAS one, so a present-but-empty
* `formmethod=""` overrides the form and then falls to its own invalid-value
* default, GET. Resolving with `||` instead sent a multipart POST for a button
* every engine submits as `GET`, which is a JS-on versus JS-off divergence.
*
* @param {HTMLFormElement} form
* @param {HTMLElement | null} submitter
*/
function getSubmitMethod(form, submitter) {
const v = (submitter && submitter.getAttribute('formmethod'))
|| form.getAttribute('method')
|| 'get';
return v.toLowerCase();
const v = (submitter && submitter.hasAttribute('formmethod'))
? (submitter.getAttribute('formmethod') || '')
: (form.getAttribute('method') || '');
return (v || 'get').toLowerCase();
}

/**
Expand Down Expand Up @@ -965,12 +976,19 @@ function normalizeEnctype(raw) {
* `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
* resolves it the same way, in `core/drive/form_submission.js`.
*
* On PRESENCE, not truthiness (#1322), for the reason spelled out on
* `getSubmitMethod`: a present-but-empty `formenctype=""` overrides the form
* and normalizes to urlencoded, its own invalid-value default, so a button on
* a multipart form sends urlencoded with JS on exactly as it does with JS off.
*
* @param {HTMLFormElement} form
* @param {HTMLElement | null} submitter
*/
function getSubmitEnctype(form, submitter) {
return normalizeEnctype(
(submitter && submitter.getAttribute('formenctype')) || form.getAttribute('enctype'),
(submitter && submitter.hasAttribute('formenctype'))
? submitter.getAttribute('formenctype')
: form.getAttribute('enctype'),
);
}

Expand Down
Loading
Loading