Skip to content

Commit 459c0af

Browse files
committed
docs: correct the formtarget precedence claim and sync the docs site
formtarget is a plain string reflection with no enumerated states, so an empty value has no invalid-value default to fall to; it selects the current navigable under the rules for choosing a navigable. The root AGENTS.md parenthetical lumped it in with formmethod and formenctype and so contradicted the code comment it was describing. The docs site listed the auto-skip as "target / formtarget not _self", which after the presence fix is wrong for formtarget="", and stated submitter precedence without the presence qualifier the fix made load-bearing. Also records the one sanctioned nav-guard exception in packages/core/AGENTS.md, with both conditions and the second-channel obligation it does not buy out, and drops the _onSubmit test binding the deleted ladder tests left behind.
1 parent 50496ed commit 459c0af

5 files changed

Lines changed: 21 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe
382382

383383
## Client navigation: automatic, nothing to opt into
384384

385-
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, so a `formenctype=""` / `formmethod=""` / `formtarget=""` overrides the form and then falls to its OWN invalid-value default, #1322): `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.
385+
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.
386386

387387
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).
388388

packages/core/AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,22 @@ than aborting the whole session. `setHardNavigate` is TEST-ONLY and is
258258
deliberately not re-exported from `index.js` / `index-browser.js`; tests reach
259259
it through the same direct `src/router-client.js` import they already use.
260260

261+
There is exactly ONE sanctioned exception to the MUST above, and it holds only
262+
when BOTH halves are true: the submission being measured cannot navigate at
263+
all, AND the test is measuring a native default action that the guard's own
264+
`preventDefault()` would cancel. Rung 7 of the submit bail ladder
265+
(`test/routing/browser/submit-bail-ladder.test.js`) is the case it was written
266+
for: a `method="dialog"` submission dismisses its `<dialog>` instead of
267+
navigating, and asserting `dialog.open === false` is the whole point of the
268+
test, so a guard there would cancel the very effect under test. A test taking
269+
the exception owes the second channel separately, because the exception buys
270+
out only the `preventDefault` half: that file holds a `setHardNavigate`
271+
recorder for its whole lifetime and re-arms it after each guard is removed,
272+
since an async swap from a NEIGHBOURING test can degrade during the unguarded
273+
one and a real page load there aborts the session. It did, on Firefox, before
274+
the recorder was added. If either half of the condition fails, install the
275+
guard.
276+
261277
Cross-package tests that exercise core through the SSR pipeline
262278
or scaffolds live at the repo root in `test/ssr/`,
263279
`test/scaffolds/`, etc. See [`references/testing.md`](../../.agents/skills/webjs/references/testing.md).

packages/core/test/routing/router-client.test.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile,
2929
_reactivateScripts, _activateSwappedRange, _findAnchorInPath, _activeFrameId, _resolveTargetFrameId, _onPopState,
3030
_applySwap, _prefetchCache,
3131
_snapshotCache, _LIVE_ATTRS, _blurOutgoingFocus,
32-
_onSubmit, _getSubmitMethod, _getSubmitAction, _buildSubmitFormData,
32+
_getSubmitMethod, _getSubmitAction, _buildSubmitFormData,
3333
_getSubmitEnctype, _encodeSubmitBody,
3434
_restoreOptimistic, _navToken, _bumpNavToken,
3535
_currentPageUrl, _setCurrentPageUrl, _resetWarnOnce,
@@ -98,7 +98,6 @@ before(async () => {
9898
_snapshotCache,
9999
_LIVE_ATTRS,
100100
_blurOutgoingFocus,
101-
_onSubmit,
102101
_getSubmitMethod,
103102
_getSubmitAction,
104103
_buildSubmitFormData,

website/app/docs/client-router/page.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default function ClientRouter() {
2828
<p>When the destination streams (it has a <code>Suspense</code> or <a href="/docs/suspense">&lt;webjs-suspense&gt;</a> boundary), the router applies the response PROGRESSIVELY: it swaps the shell (with the fallbacks) in immediately and advances the URL, then streams each resolved boundary into the live DOM as it arrives, fast-before-slow. So a soft navigation to a streamed page matches the initial-load experience (fallback first, content streams in) instead of buffering the whole response before the swap. A non-streaming page is unaffected (the response is read to completion and applied once). A navigation superseded mid-stream stops applying, and a mid-stream transport failure leaves the applied boundaries in place with the rest showing their fallback (non-destructive).</p>
2929
3030
<h2>Form submissions</h2>
31-
<p><code>&lt;form action="/x" method="post"&gt;</code> works exactly per the HTML spec. WebJs intercepts the <code>submit</code> event in the bubble phase (after a component's own <code>@submit</code> handler) and routes the same fetch the browser would have sent through the partial-swap pipeline. Because it runs after, a component that calls <code>e.preventDefault()</code> in <code>@submit</code> keeps the form to itself and the router leaves it alone; the same applies to <code>@click</code> on links. Submitter attributes (<code>formmethod</code>, <code>formaction</code>, <code>formenctype</code> on a clicked <code>&lt;button&gt;</code>) take precedence over the form's own per HTML5.</p>
31+
<p><code>&lt;form action="/x" method="post"&gt;</code> works exactly per the HTML spec. WebJs intercepts the <code>submit</code> event in the bubble phase (after a component's own <code>@submit</code> handler) and routes the same fetch the browser would have sent through the partial-swap pipeline. Because it runs after, a component that calls <code>e.preventDefault()</code> in <code>@submit</code> keeps the form to itself and the router leaves it alone; the same applies to <code>@click</code> on links. Submitter attributes (<code>formmethod</code>, <code>formaction</code>, <code>formenctype</code>, <code>formtarget</code> on a clicked <code>&lt;button&gt;</code>) take precedence over the form's own per HTML5, decided on whether the attribute is PRESENT rather than on its value being non-empty: <code>formmethod=""</code> really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default instead of inheriting the form's.</p>
3232
<ul>
3333
<li><strong>GET forms</strong>: <code>FormData</code> is promoted to the URL query string (replacing any existing query on <code>action</code>). The URL is then fetched and applied like a link click.</li>
3434
<li><strong>POST / PUT / PATCH / DELETE forms</strong>: <code>FormData</code> is sent as the request body. After a successful response the snapshot cache is cleared (other cached URLs may reflect stale server state).</li>
@@ -38,7 +38,7 @@ export default function ClientRouter() {
3838
<p><strong>Auto-skipped</strong> (no opt-out needed):</p>
3939
<ul>
4040
<li><code>method="dialog"</code>: browser-native <code>&lt;dialog&gt;</code> dismissal</li>
41-
<li><code>target</code> / <code>formtarget</code> <code>_self</code>: iframes, popups, named windows</li>
41+
<li>A resolved <code>target</code> that is neither empty nor <code>_self</code>: iframes, popups, named windows. A submitter's <code>formtarget</code> wins over the form's whenever the attribute is PRESENT, so <code>formtarget=""</code> brings a <code>target="_blank"</code> form back to the current context and is routed, exactly as the browser would submit it.</li>
4242
<li>Cross-origin <code>action</code></li>
4343
<li>Non-HTML extensions on the <code>action</code> URL</li>
4444
</ul>

website/app/docs/progressive-enhancement/page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export default function NewPost({ actionData }: {
177177
</p>
178178
179179
<p>
180-
"Identical by construction" is a claim about the whole submission, encoding included, and it is enforced in two places. The router resolves the effective <code>enctype</code> with native precedence (a submitter's <code>formenctype</code> over the form's) and ENCODES the body accordingly: <code>multipart/form-data</code> sends <code>FormData</code>, and <code>application/x-www-form-urlencoded</code>, which is the HTML default and therefore what a plain <code>&lt;form method="post"&gt;</code> means, sends <code>URLSearchParams</code>. Before that the router built <code>FormData</code> for everything, so an ordinary POST form sent a urlencoded body without JS and a multipart body with it. A <code>text/plain</code> POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which makes both paths do the same thing rather than one of them appear to work.
180+
"Identical by construction" is a claim about the whole submission, encoding included, and it is enforced in two places. The router resolves the effective <code>enctype</code> with native precedence (a submitter's <code>formenctype</code> over the form's, decided on whether the attribute is PRESENT rather than on its value being non-empty, so <code>formenctype=""</code> means urlencoded and not the form's declared encoding) and ENCODES the body accordingly: <code>multipart/form-data</code> sends <code>FormData</code>, and <code>application/x-www-form-urlencoded</code>, which is the HTML default and therefore what a plain <code>&lt;form method="post"&gt;</code> means, sends <code>URLSearchParams</code>. Before that the router built <code>FormData</code> for everything, so an ordinary POST form sent a urlencoded body without JS and a multipart body with it. A <code>text/plain</code> POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which makes both paths do the same thing rather than one of them appear to work.
181181
</p>
182182
<p>
183183
A submitter that BINDS its own action is refused when it also declares a <code>formmethod</code> other than post, an unparseable <code>formenctype</code>, or <code>formmethod="dialog"</code>, because those contradict the action attached to that same button. A button that binds nothing is left alone: its <code>formmethod</code> / <code>formenctype</code> is a legal native override, the author wrote it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time when a submission is carrying an identity it cannot deliver.

0 commit comments

Comments
 (0)