From bf6c3638466ebe4d6658b0ff052b98b9ff1779b0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 22:49:19 +0530 Subject: [PATCH 1/3] fix: a submitter's empty formmethod/formenctype/formtarget wins, as native The form-submission algorithm resolves a submitter's override on whether the attribute is PRESENT, never on its value being truthy. `getSubmitAction` already did that, so a present-but-empty `formaction=""` correctly means submit-to-self. The three siblings used a `||` chain instead, so an empty value was falsy and silently fell through to the form's. Measured against Chromium, Firefox and WebKit at the request level: a ` - - `, container); - // The native submission this bail deliberately allows is cancelled by - // the suite's nav guard, which listens on WINDOW bubble, i.e. after the - // router's own document-bubble listener. A listener on `container` would - // run BEFORE the router and set `defaultPrevented`, so `onSubmit` would - // return at its first line and this test would pass without the router - // ever making the decision it claims to measure. - container.querySelector('button').click(); - await tick(); - assert.equal(calls.length, 0, 'the router did not take it'); - } finally { teardown(); } - }); + // The `text/plain` BAIL that used to sit here moved to + // `submit-bail-ladder.test.js` (#1322), where it is one rung of the ladder + // and is paired with a near-miss control. On its own it asserted only that no + // fetch was issued, which cannot tell a bail apart from a submission that + // never happened. The tests above are about ENCODING, which is this file's + // subject, and they stay. // ------------------------------------------------------------------------- // #1307: the dev-time submit guard. diff --git a/packages/core/test/routing/browser/submit-bail-ladder.test.js b/packages/core/test/routing/browser/submit-bail-ladder.test.js new file mode 100644 index 000000000..841a16ad2 --- /dev/null +++ b/packages/core/test/routing/browser/submit-bail-ladder.test.js @@ -0,0 +1,681 @@ +/** + * The `onSubmit` BAIL LADDER, pinned one rung at a time in a real browser + * (#1322). + * + * `onSubmit` (`packages/core/src/router-client.js`) is a ladder of guards, each + * of which declines a submission and hands it back to the browser. The ladder + * used to be "tested" in the node suite, where every test proved only that a + * submission was NOT routed. That is not a test: in that harness there is no + * `location` global and `new FormData(formElement)` throws under linkedom, so + * `preventDefault()` was unreachable for EVERY input and an ordinary POST the + * router does intercept looked exactly like a bail. Deleting the + * `data-no-router` rung outright left all 222 of those tests green. + * + * ## What replaces it + * + * Every rung is one test containing a PAIR: a bail fixture, and a near-miss + * control that differs from it by exactly the one attribute that trips the + * rung. Three positive assertions replace the old absence assertion: + * + * 1. A submit probe on WINDOW BUBBLE reads `e.defaultPrevented`, which is a + * direct read of the router's decision about this event. `false` means the + * browser is about to perform the submission natively, which is precisely + * what every bail claims. + * 2. `probe.seen.length` is asserted, so "no submission happened at all", the + * failure mode that made the old tests vacuous, cannot pass. + * 3. The near-miss control must be ROUTED in the same test, so a router that + * bailed on everything fails here. + * + * Break one rung and exactly one test reds, because only that rung's bail + * fixture carries the triggering attribute. A rung that fires too eagerly reds + * every control, which is a broad break reported broadly. + * + * Turbo tests the same ladder the same way, in a real browser + * (`src/tests/functional/form_submission_tests.js`), pairing each bail with a + * positive observation of the native effect it exists to allow. WebJs cannot + * let a real navigation happen (web-test-runner aborts the whole session, which + * is why `test/browser-nav-guard.js` exists), so the probe stands in for + * Turbo's "the response document rendered" half. Rung 7 is the one place the + * native effect IS observable without navigating, and there this borrows + * Turbo's assertion verbatim: the `` really closed. + * + * Rung 1 (router not enabled) is deliberately absent. It is already pinned + * structurally by `client-router-opt-out.test.js`, which asserts that a + * disabled router binds no document listeners at all. + */ +import { html } from '../../../src/html.js'; +import { render } from '../../../src/render-client.js'; +import { enableClientRouter, _setHardNavigate } from '../../../src/router-client.js'; + +import { assert } from '../../../../../test/browser-assert.js'; +import { installNavGuard } from '../../../../../test/browser-nav-guard.js'; + +const tick = () => new Promise((r) => setTimeout(r, 20)); + +suite('Client router: the onSubmit bail ladder (#1322)', () => { + let container, origFetch, calls, navGuard, probe, bOpen, bClose, origPath; + + /** + * A hard-navigation recorder that covers the WHOLE file, including the gaps + * `installNavGuard` cannot. + * + * A routed control's swap is async, and on a slower engine it can still be in + * flight when the test's teardown pulls the boundary comments out from under + * it. The router then (correctly) degrades and asks for a full page load + * through the `_setHardNavigate` seam, one test LATE. Every test that + * installs the guard has that late load recorded by the NEXT test's guard, so + * it was invisible until rung 7, which runs without a guard and let the real + * navigation through, aborting the whole web-test-runner session on Firefox. + * + * So the seam is held for the file's lifetime and re-armed after each guard + * is removed. These strays are not asserted on: every test here measures the + * router's DECISION about a submit event (the probe plus the fetch), not + * whether a swap landed, and the swap-application path is pinned in + * `form-action-submit.test.js`. + */ + const strayHardNavigations = []; + const armStraySeam = () => _setHardNavigate((href) => { + strayHardNavigations.push(String(href)); + }); + + suiteSetup(armStraySeam); + suiteTeardown(() => { _setHardNavigate(null); }); + + /** + * Read the router's decision on a submit event, positively. + * + * WINDOW BUBBLE is the last step of the propagation path, so this always runs + * after the router's own document-bubble listener regardless of registration + * order, and `e.defaultPrevented` read here is a direct read of what the + * router decided about THIS event. `false` means the browser is about to + * perform the submission natively, which is what every bail in the ladder + * claims. + * + * Installed BEFORE the nav guard, whose own window-bubble `preventDefault()` + * would otherwise mask that decision. Listeners on the same target in the + * same phase fire in registration order, so the order below is load-bearing. + */ + function installSubmitProbe() { + const seen = []; + const onProbe = (e) => { seen.push({ target: e.target, routed: e.defaultPrevented }); }; + window.addEventListener('submit', onProbe); + return { seen, remove() { window.removeEventListener('submit', onProbe); } }; + } + + const okHtml = () => new Response( + '

ok

', + { headers: { 'content-type': 'text/html', 'x-webjs-build': '' } }, + ); + + /** + * @param {() => Response} responder + * @param {{ navGuard?: boolean }} [opts] `navGuard: false` skips the shared + * navigation backstop. Rung 7 needs that: the guard's window-bubble + * `preventDefault()` cancels a `` form's own dismissal, which is + * the exact native effect that rung's positive assertion reads. It is safe + * there because a `method="dialog"` submission can never navigate, so there + * is nothing for the guard to protect against. + */ + function setup(responder, { navGuard: wantGuard = true } = {}) { + probe = installSubmitProbe(); + navGuard = wantGuard ? installNavGuard() : null; + enableClientRouter(); // idempotent + container = document.createElement('div'); + // Bracket the container with a live keyed boundary pair (#1015): the swap + // needs a shared boundary on both sides, else the router (correctly) + // degrades to a full page load, which would navigate the test page away. + bOpen = document.createComment('wj:children:/:/'); + bClose = document.createComment('/wj:children:/'); + document.body.appendChild(bOpen); + document.body.appendChild(container); + document.body.appendChild(bClose); + calls = []; + origFetch = window.fetch; + window.fetch = (url, init) => { + calls.push({ url: String(url), init: init || {} }); + return Promise.resolve(responder(String(url), init || {})); + }; + // Every control here is a REAL routed submission, which records history. + // Snapshot and restore so each test starts from the same url and the next + // test's relative actions resolve the same way. + origPath = location.pathname + location.search; + } + + function teardown() { + // `navGuard.remove()` clears the seam, so re-arm the file-wide recorder + // behind it: a swap still in flight from THIS test degrades after this + // line, and without the seam that is a real page load. + if (navGuard) navGuard.remove(); + armStraySeam(); + navGuard = null; + probe.remove(); + window.fetch = origFetch; + container.remove(); + if (bOpen) bOpen.remove(); + if (bClose) bClose.remove(); + // A routed swap replaces the bracketed range, so the boundary comments in + // the live document may be the RESPONSE's rather than the pair created + // above. Sweep any that are left, else a later test's swap sees duplicate + // boundaries, which correctly poisons the scan and degrades to a full load. + for (const node of [...document.body.childNodes]) { + if (node.nodeType === 8 && /^\/?wj:children:/.test(node.data)) node.remove(); + } + history.replaceState(null, '', origPath); + } + + /** + * The bail half of a rung: the submission reached the router, the router + * declined it, and no fetch was issued. + * + * @param {HTMLElement} form the element the submit event should have targeted + * @param {number} [index] which probe entry to read (rung 6 has two bails) + */ + function assertBailed(form, index = 0) { + assert.equal(probe.seen.length, index + 1, 'the submit event fired and reached the router'); + assert.equal(probe.seen[index].target, form, 'and it is the form under test'); + assert.equal(probe.seen[index].routed, false, + 'the router declined it, so the browser submits natively'); + assert.equal(calls.length, 0, 'and the router issued no fetch'); + } + + /** + * The control half: the near-miss form, differing by exactly the triggering + * attribute, IS routed. + * + * @param {number} index which probe entry to read + */ + function assertRouted(index) { + assert.equal(probe.seen.length, index + 1, 'the control submission also reached the router'); + assert.equal(probe.seen[index].routed, true, 'the near-miss control IS routed'); + assert.equal(calls.length, 1, 'and issues exactly one fetch'); + } + + // ------------------------------------------------------------------------- + // The floor. Without this, a router that bailed on every submission would + // keep every bail test below green. + // ------------------------------------------------------------------------- + + test('the floor: an ordinary same-origin POST with no bail attribute IS intercepted', async () => { + setup(okHtml); + try { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.equal(probe.seen.length, 1, 'the submit event fired'); + assert.equal(probe.seen[0].routed, true, 'and the router took it'); + assert.equal(calls.length, 1, 'issuing exactly one fetch'); + assert.equal(new URL(calls[0].url).pathname, '/x', 'to the form action'); + assert.equal((calls[0].init.method || 'GET').toUpperCase(), 'POST'); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 2: the event was already prevented (`router-client.js`, the + // `e.defaultPrevented` guard). + // ------------------------------------------------------------------------- + + test('rung 2: an already-prevented submit belongs to the handler that prevented it', async () => { + // The probe cannot carry this rung: the USER handler set `defaultPrevented` + // before the router ever saw the event, so the probe reads `true` for both + // halves and says nothing about who did it. What separates the two is the + // fetch. The bail form's handler runs and the router stays out of it; the + // control has no handler and is routed. Delete the rung and the bail half + // issues a fetch, which is the red. + setup(okHtml); + const ran = []; + try { + render(html` +
{ ran.push('user'); e.preventDefault(); }}> + +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assert.deepEqual(ran, ['user'], "the component's own handler ran"); + assert.equal(probe.seen.length, 1, 'the submit event fired and reached the router'); + assert.equal(probe.seen[0].target, bail, 'and it is the form under test'); + assert.equal(calls.length, 0, 'the router did not double-handle it: the user handler owns it'); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 3: the event target is not a `
`. + // ------------------------------------------------------------------------- + + test('rung 3: a submit event whose target is not a form is left alone', async () => { + // Same event type, same dispatch, same bubbling: only `target.tagName` + // differs between the two halves. A synthetic dispatch is the only way to + // aim a `submit` event at a non-form, and it is exactly what a stray + // `dispatchEvent` in app code looks like. + setup(okHtml); + try { + render(html` +
+ + +
+ `, container); + const div = container.querySelector('#not-a-form'); + const control = container.querySelector('form'); + + div.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await tick(); + assertBailed(div); + + control.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 4: `data-no-router` on the form. + // ------------------------------------------------------------------------- + + test('rung 4: a form carrying data-no-router is left to the browser', async () => { + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 5: `data-no-router` on the submitter (the per-button escape). + // ------------------------------------------------------------------------- + + test('rung 5: a submitter carrying data-no-router opts that button out', async () => { + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 6: the resolved `target` / `formtarget` is not `_self`. + // ------------------------------------------------------------------------- + + test('rung 6: a target that is not _self goes to the browser, from either level', async () => { + // Two bails in one test because the rung reads one resolved value from two + // places. The control declares `target="_self"` explicitly, which proves + // the check is on the VALUE and not on the attribute being present. + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+
+ +
+ `, container); + const [formTarget, submitterTarget, control] = container.querySelectorAll('form'); + + formTarget.querySelector('button').click(); + await tick(); + assertBailed(formTarget, 0); + + submitterTarget.querySelector('button').click(); + await tick(); + assertBailed(submitterTarget, 1); + + control.querySelector('button').click(); + await tick(); + assertRouted(2); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 7: the resolved method is `dialog`. + // + // The one rung whose native effect is observable without a navigation, so it + // gets Turbo's own assertion: the dialog really closed. + // ------------------------------------------------------------------------- + + test('rung 7: a method="dialog" submission dismisses the dialog, natively', async () => { + // NO nav guard. Its window-bubble `preventDefault()` would cancel the + // dialog's own dismissal, which is the native effect being measured. Safe + // here because a `method="dialog"` submission can never navigate. + setup(okHtml, { navGuard: false }); + try { + render(html` + +
+ +
+
+ `, container); + const dialog = container.querySelector('dialog'); + const form = container.querySelector('form'); + assert.equal(dialog.open, true, 'the dialog starts open'); + + form.querySelector('button').click(); + await tick(); + assertBailed(form); + assert.equal(dialog.open, false, + 'the browser performed the dialog dismissal the bail exists to allow'); + } finally { teardown(); } + }); + + test('rung 7 control: the same dialog with method="post" IS routed', async () => { + // Guard ON: a `method="post"` form the router failed to intercept would + // perform a real navigation and abort the whole session. The load-bearing + // half here is the probe plus the fetch; `dialog.open` staying true is the + // consistency check that the routed path does not also dismiss. + setup(okHtml); + try { + render(html` + +
+ +
+
+ `, container); + const dialog = container.querySelector('dialog'); + container.querySelector('button').click(); + await tick(); + assertRouted(0); + assert.equal(dialog.open, true, 'and the dialog was not dismissed'); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 8: the action url does not parse. + // ------------------------------------------------------------------------- + + test('rung 8: an unparseable action is left to the browser', async () => { + // `http://[` is an invalid IPv6 host, so `new URL` throws. Deleting this + // rung does not merely red the assertion below: the throw escapes + // `onSubmit` and web-test-runner reports it as an uncaught error, so the + // rung is pinned from both sides. + // + // The control is a PARSEABLE same-origin ABSOLUTE url, which is the + // tightest honest near miss: an unparseable url has no origin, so pairing + // it with a relative action would also be testing rung 9. + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 9: the action url is cross-origin. + // ------------------------------------------------------------------------- + + test('rung 9: a cross-origin action is left to the browser', async () => { + // Both halves are absolute urls differing only in origin, so the control + // proves the check is on the ORIGIN and not on the action being absolute. + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 10: the action pathname carries a non-HTML extension. + // ------------------------------------------------------------------------- + + test('rung 10: a file-download action is left to the browser', async () => { + // The pair differs only in the extension, so the control proves the rung + // reads the extension rather than bailing on every GET form. + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // Rung 11: an unsafe method with a `text/plain` enctype (#1307). + // + // The server parses multipart and urlencoded only, so there is no honest way + // to send text/plain over fetch and have the response mean anything. Bailing + // makes the JS-on and JS-off paths do the SAME thing. + // ------------------------------------------------------------------------- + + test('rung 11: a text/plain POST bails, an INVALID enctype does not', async () => { + // The sharpest available control. `enctype` is an enumerated attribute + // whose invalid-value default is urlencoded, so `nonsense` submits a + // perfectly parseable body and MUST be routed. A rung written against an + // allowlist of parseable enctypes instead of `text/plain` alone would bail + // on that working form, and this pair is what catches it. + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + assert.ok(calls[0].init.body instanceof URLSearchParams, + 'and the invalid enctype was sent as urlencoded, its invalid-value default'); + } finally { teardown(); } + }); + + test('rung 11: a submitter formenctype="text/plain" bails too', async () => { + // Native precedence: the submitter's override decides the encoding, so the + // rung has to read it there as well or a per-button text/plain would be + // sent as multipart under JS and natively without it. The control carries + // the other override the same way, so the pair pins the submitter half of + // the precedence rather than the form half a second time. + setup(okHtml); + try { + render(html` +
+ +
+
+ +
+ `, container); + const [bail, control] = container.querySelectorAll('form'); + + bail.querySelector('button').click(); + await tick(); + assertBailed(bail); + + control.querySelector('button').click(); + await tick(); + assertRouted(1); + assert.ok(calls[0].init.body instanceof FormData, + "and the control's own formenctype decided its encoding"); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // A submitter's PRESENT-BUT-EMPTY override wins, per native precedence. + // + // The form-submission algorithm asks whether the submitter HAS the attribute, + // never whether its value is truthy, so `formmethod=""` / `formenctype=""` / + // `formtarget=""` each override the form and then fall to their OWN + // invalid-value default. `getSubmitAction` already did this (a present-but- + // empty `formaction` means submit-to-self); the three siblings used a `||` + // chain, so an empty value was falsy and silently fell through to the form's. + // + // Measured against Chromium, Firefox and WebKit at the request level: a + // ` + + `, container); + // The engine's own answer, read from the IDL reflection, which applies + // the enumerated attribute's invalid-value default. This is the native + // oracle the router has to agree with, asserted in the same test rather + // than quoted from a measurement made elsewhere. + assert.equal(container.querySelector('button').formMethod, 'get', + 'the engine resolves the empty formmethod to GET'); + + container.querySelector('button').click(); + await tick(); + assertRouted(0); + assert.equal((calls[0].init.method || 'GET').toUpperCase(), 'GET', + "the button's present-but-empty formmethod wins over the form's post"); + assert.equal(new URL(calls[0].url).searchParams.get('a'), '1', + 'and the fields are promoted to the query string, as a GET submission does'); + assert.equal(calls[0].init.body, undefined, 'with no body'); + } finally { teardown(); } + }); + + test('an empty formenctype overrides the form and falls to urlencoded', async () => { + setup(okHtml); + try { + render(html` +
+ + +
+ `, container); + assert.equal( + container.querySelector('button').formEnctype, 'application/x-www-form-urlencoded', + 'the engine resolves the empty formenctype to urlencoded', + ); + + container.querySelector('button').click(); + await tick(); + assertRouted(0); + assert.ok(calls[0].init.body instanceof URLSearchParams, + "the button's present-but-empty formenctype wins over the form's multipart"); + assert.equal(calls[0].init.body.get('a'), '1', 'and the field survives the encoding'); + } finally { teardown(); } + }); + + test('an empty formtarget overrides the form and means the current context', async () => { + // The consequence is a rung-6 decision: the form alone would bail on + // `target="_blank"`, and the button's empty override brings it back. + setup(okHtml); + try { + render(html` +
+ +
+ `, container); + // `formtarget` is a plain string reflection, not an enumerated one, so + // the engine reports the empty string back. The rules for choosing a + // navigable then treat an empty name as the current navigable, which is + // why the router must NOT fall through to the form's `_blank`. + assert.equal(container.querySelector('button').formTarget, '', + 'the engine keeps the empty formtarget rather than inheriting the form'); + + container.querySelector('button').click(); + await tick(); + assertRouted(0); + } finally { teardown(); } + }); +}); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 06ac6717b..26080ce40 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -2977,7 +2977,26 @@ test('blurOutgoingFocus: no-op when active element has no blur() method', () => }); /* ==================================================================== - * Form submission: getSubmitMethod / getSubmitAction + * Form submission: the RESOLVERS only. + * + * The `onSubmit` BAIL LADDER is deliberately not tested in this file. It + * lives in `packages/core/test/routing/browser/submit-bail-ladder.test.js`, + * against a real browser (#1322). + * + * Why it cannot live here: this harness is linkedom with no `location` + * global, so `onSubmit` throws a ReferenceError at its `new URL(action, + * location.href)` line and the bare `catch` swallows it, returning before any + * later rung is reached. Stub `location` and the next wall is + * `new FormData(formElement)`, which throws under linkedom because the + * constructor's WebIDL brand check rejects a linkedom element. Either way + * `preventDefault()` is unreachable, so an ordinary same-origin POST that the + * router DOES intercept looks exactly like a bail, there is no possible + * positive control, and no change to any rung could red a test here. Nine + * tests that claimed to pin a bail used to sit below; deleting the + * `data-no-router` rung outright left every one of them green. + * + * The resolvers below are pure functions over attributes, so they are + * genuinely unit-testable and stay. * ==================================================================== */ /** Build a form element in the test document for inspection. */ @@ -3009,6 +3028,26 @@ test('getSubmitMethod: tolerates null submitter (programmatic submit)', () => { assert.equal(_getSubmitMethod(form, null), 'post'); }); +test('getSubmitMethod: a PRESENT-but-empty formmethod wins, and means GET (#1322)', () => { + // The form-submission algorithm asks whether the submitter HAS a + // `formmethod`, never whether the value is truthy, and `formmethod` is an + // enumerated attribute whose invalid-value default is GET. So this button + // submits as a GET on every engine, while the old `||` chain resolved it to + // the form's `post`: same template, two different requests with JS on and + // off, which is the divergence #1307 exists to rule out. + const form = formFrom('
'); + assert.equal(_getSubmitMethod(form, form.querySelector('button')), 'get'); +}); + +test('getSubmitEnctype: a PRESENT-but-empty formenctype wins, and means urlencoded (#1322)', () => { + // Same presence rule, landing on `enctype`'s own invalid-value default. + const form = formFrom('
'); + assert.equal( + _getSubmitEnctype(form, form.querySelector('button')), + 'application/x-www-form-urlencoded', + ); +}); + test('getSubmitEnctype: submitter formenctype overrides form enctype', () => { // Native precedence, the same rule `getSubmitMethod` follows one line up. const form = formFrom('
'); @@ -3090,110 +3129,6 @@ test('getSubmitAction: empty submitter formaction is honored (means submit-to-se assert.equal(_getSubmitAction(form, submitter), ''); }); -/* ==================================================================== - * Form submission: onSubmit filter rules - * ==================================================================== */ - -/** - * Construct a fake SubmitEvent for the given form. We can't use a real - * SubmitEvent in linkedom (it's undefined there), but onSubmit only - * reads `defaultPrevented`, `target`, `submitter`, and `preventDefault` - * - easy to fake. - */ -function fakeSubmitEvent(form, submitter) { - let prevented = false; - return { - defaultPrevented: false, - target: form, - submitter: submitter || null, - preventDefault() { prevented = true; this.defaultPrevented = true; }, - _wasPrevented() { return prevented; }, - }; -} - -test('onSubmit: ignores forms with data-no-router (lets browser submit)', () => { - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, - "data-no-router form is NOT intercepted; browser handles it natively"); -}); - -test('onSubmit: ignores forms with target=_blank (popup)', () => { - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, 'popup target left to browser'); -}); - -test('onSubmit: ignores submissions with method="dialog"', () => { - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, 'native dialog dismissal not routed'); -}); - -// NOTE on these two bail tests, and on every `onSubmit: ignores ...` test -// around them: this harness is linkedom, where `new FormData(formElement)` -// throws, so `onSubmit` cannot be driven all the way to `preventDefault()` -// here. A bail assertion therefore proves that the submission was NOT routed, -// but cannot prove it bailed for the stated REASON. The positive control (an -// ordinary POST still being intercepted, and the body actually encoded per the -// declared enctype) lives in the browser suite, in -// `packages/core/test/routing/browser/form-action-submit.test.js`, against a -// real DOM and a stubbed fetch. -test('onSubmit: an unsafe text/plain submission bails to the browser (#1307)', () => { - // The server parses multipart and urlencoded only, so there is no honest way - // to send text/plain over fetch and have the response mean anything. Bailing - // makes the JS-on and JS-off paths do the SAME thing (both a native - // text/plain POST, both answered the same way), which is the requirement. - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, 'the browser performs the submission'); -}); - -test('onSubmit: a submitter formenctype="text/plain" bails too', () => { - // Native precedence: the submitter's override decides the encoding, so the - // bail has to read it there as well or a per-button text/plain would be sent - // as multipart under JS and natively without it. - const form = formFrom('
'); - const e = fakeSubmitEvent(form, form.querySelector('button')); - _onSubmit(e); - assert.equal(e._wasPrevented(), false); -}); - -test('onSubmit: ignores cross-origin actions', () => { - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, 'cross-origin → full browser submit'); -}); - -test('onSubmit: ignores file-download actions (non-HTML extensions)', () => { - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, 'PDF action → browser handles download'); -}); - -test('onSubmit: ignores already-prevented events (server-action RPC stub got first)', () => { - const form = formFrom('
'); - const e = fakeSubmitEvent(form); - e.defaultPrevented = true; // simulate a user @submit handler already running - _onSubmit(e); - assert.equal(e._wasPrevented(), false, - "router does not double-prevent: user handler owns the event"); -}); - -test('onSubmit: ignores submitter with data-no-router (per-button escape)', () => { - const form = formFrom('
'); - const submitter = form.querySelector('button'); - const e = fakeSubmitEvent(form, submitter); - _onSubmit(e); - assert.equal(e._wasPrevented(), false, 'submitter-level opt-out'); -}); - /* ==================================================================== * restoreOptimistic: nav-token race guard * ==================================================================== */ From 50496ed76a82c77c29d20653fa24c12353f3b1c1 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 23:02:44 +0530 Subject: [PATCH 2/3] test: settle in-flight swaps before dismantling a submit test's DOM A routed submission's swap is async, so tearing the boundary comments out from under one still in flight makes it degrade after the nav guard has already restored the real hard-navigate seam. That is a genuine page reload, which aborts the whole web-test-runner session rather than failing one test. It surfaced on Firefox in form-action-submit.test.js once the text/plain bail moved out to the ladder file: that test's own tick had been acting as an accidental buffer between its two neighbours. Both files now settle first. Also records what the per-rung counterfactual actually looks like for rungs 3 and 8, which are load-bearing for a later line rather than merely a decision, so deleting either turns onSubmit into a throw. --- .../browser/form-action-submit.test.js | 38 +++++++----- .../browser/submit-bail-ladder.test.js | 60 +++++++++++++------ 2 files changed, 64 insertions(+), 34 deletions(-) diff --git a/packages/core/test/routing/browser/form-action-submit.test.js b/packages/core/test/routing/browser/form-action-submit.test.js index efdad6b79..e7da4ab5e 100644 --- a/packages/core/test/routing/browser/form-action-submit.test.js +++ b/packages/core/test/routing/browser/form-action-submit.test.js @@ -57,7 +57,15 @@ suite('Client router: bound form submissions (#1155)', () => { return Promise.resolve(responder(String(url), init || {})); }; } - function teardown() { + async function teardown() { + // Settle before dismantling. A routed submission's swap is async, so + // pulling the boundary comments out from under one still in flight makes + // it degrade AFTER `navGuard.remove()` has restored the real hard-navigate + // seam, and that is a genuine page reload, which aborts the whole + // web-test-runner session. It surfaced on Firefox once the `text/plain` + // bail moved out to the ladder file (#1322), because that test's own tick + // had been acting as an accidental buffer between the two neighbours. + await tick(); navGuard.remove(); window.fetch = origFetch; container.remove(); @@ -92,7 +100,7 @@ suite('Client router: bound form submissions (#1155)', () => { assert.equal(post.init.body.get('email'), 'a@b.com', 'FormData carries the field'); assert.equal(post.init.body.get('__webjs_action'), 'a1b2c3d4e5/signup', 'and the identity, without which the server has nothing to dispatch on'); - } finally { teardown(); } + } finally { await teardown(); } }); test("a submit button's own name/value rides along with the identity", async () => { @@ -114,7 +122,7 @@ suite('Client router: bound form submissions (#1155)', () => { const body = calls[0].init.body; assert.equal(body.get('intent'), 'publish', "the submitter's name/value is submitted"); assert.equal(body.get('__webjs_action'), 'a1b2c3d4e5/act', 'alongside the identity'); - } finally { teardown(); } + } finally { await teardown(); } }); test('a 422 HTML response is applied in place, not via a full reload', async () => { @@ -152,7 +160,7 @@ suite('Client router: bound form submissions (#1155)', () => { // The 422 body was actually applied to the live DOM (the field error is // now present), which a full reload would never achieve from a fetch stub. assert.ok(document.getElementById(marker), 'the 422 re-render body was applied in place'); - } finally { teardown(); } + } finally { await teardown(); } }); test('a 303-redirected success records the FINAL url in history (PRG)', async () => { @@ -182,7 +190,7 @@ suite('Client router: bound form submissions (#1155)', () => { } finally { // Restore history so later tests start clean. history.replaceState(null, '', before); - teardown(); + await teardown(); } }); @@ -222,7 +230,7 @@ suite('Client router: bound form submissions (#1155)', () => { assert.equal(post.init.body.get('email'), 'a@b.com', 'and the field survives the encoding'); // COUNTERFACTUAL: revert `encodeSubmitBody` to return the FormData // unconditionally and this goes red, which is what pins the fix. - } finally { teardown(); } + } finally { await teardown(); } }); test('a form declaring multipart still sends FormData', async () => { @@ -237,7 +245,7 @@ suite('Client router: bound form submissions (#1155)', () => { container.querySelector('button').click(); await tick(); assert.ok(calls[0].init.body instanceof FormData, 'multipart is still FormData'); - } finally { teardown(); } + } finally { await teardown(); } }); test("a submitter's formenctype overrides the form's, as native precedence says", async () => { @@ -253,7 +261,7 @@ suite('Client router: bound form submissions (#1155)', () => { await tick(); assert.ok(calls[0].init.body instanceof URLSearchParams, "the button's own formenctype decides the encoding"); - } finally { teardown(); } + } finally { await teardown(); } }); test('an invalid enctype is urlencoded, not passed through or treated as text/plain', async () => { @@ -271,7 +279,7 @@ suite('Client router: bound form submissions (#1155)', () => { await tick(); assert.ok(calls[0], 'the submission was still routed, not bailed'); assert.ok(calls[0].init.body instanceof URLSearchParams); - } finally { teardown(); } + } finally { await teardown(); } }); // The `text/plain` BAIL that used to sit here moved to @@ -326,7 +334,7 @@ suite('Client router: bound form submissions (#1155)', () => { `expected a submit-time console.error, saw: ${JSON.stringify(seen)}`, ); }); - } finally { teardown(); } + } finally { await teardown(); } }); test('a bound identity posting to ANOTHER url is reported (#1307)', async () => { @@ -352,7 +360,7 @@ suite('Client router: bound form submissions (#1155)', () => { `expected the submit-elsewhere report, saw: ${JSON.stringify(seen)}`, ); }); - } finally { teardown(); } + } finally { await teardown(); } }); test('the submit-elsewhere guard stays silent for a form posting to its own page', async () => { @@ -373,7 +381,7 @@ suite('Client router: bound form submissions (#1155)', () => { await tick(); assert.equal(seen.length, 0, `expected silence, saw: ${JSON.stringify(seen)}`); }); - } finally { teardown(); } + } finally { await teardown(); } }); test('the guard stays silent for a form carrying no bound identity', async () => { @@ -392,7 +400,7 @@ suite('Client router: bound form submissions (#1155)', () => { await tick(); assert.equal(seen.length, 0, `expected silence, saw: ${JSON.stringify(seen)}`); }); - } finally { teardown(); } + } finally { await teardown(); } }); test('the guard fires for text/plain but NOT for an invalid enctype', async () => { @@ -413,7 +421,7 @@ suite('Client router: bound form submissions (#1155)', () => { await tick(); assert.equal(seen.length, 0, `an invalid enctype is urlencoded and works, saw: ${JSON.stringify(seen)}`); }); - } finally { teardown(); } + } finally { await teardown(); } setup(okHtml); try { @@ -431,6 +439,6 @@ suite('Client router: bound form submissions (#1155)', () => { `expected the text/plain report, saw: ${JSON.stringify(seen)}`, ); }); - } finally { teardown(); } + } finally { await teardown(); } }); }); diff --git a/packages/core/test/routing/browser/submit-bail-ladder.test.js b/packages/core/test/routing/browser/submit-bail-ladder.test.js index 841a16ad2..6d78e8e3f 100644 --- a/packages/core/test/routing/browser/submit-bail-ladder.test.js +++ b/packages/core/test/routing/browser/submit-bail-ladder.test.js @@ -30,6 +30,14 @@ * fixture carries the triggering attribute. A rung that fires too eagerly reds * every control, which is a broad break reported broadly. * + * Two rungs are pinned from BOTH sides, and their counterfactual reads + * differently on purpose. Rungs 3 and 8 are the ones a later line depends on + * (`new FormData(x)` needs a real form; `url` needs to have parsed), so + * deleting either turns `onSubmit` into a throw rather than a wrong decision. + * That surfaces as an UNCAUGHT page error, which web-test-runner reports across + * the file rather than against the one test. Wide blast radius is the honest + * signal there: the rung is not a preference, it is load-bearing. + * * Turbo tests the same ladder the same way, in a real browser * (`src/tests/functional/form_submission_tests.js`), pairing each bail with a * positive observation of the native effect it exists to allow. WebJs cannot @@ -141,10 +149,19 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { origPath = location.pathname + location.search; } - function teardown() { + async function teardown() { + // Let any router work THIS test started settle before the DOM it operates + // on is dismantled. A routed submission's swap is async, so tearing the + // boundary comments out from under one in flight makes it land during the + // NEXT test, where it rips out that test's container and turns a clean + // per-rung failure into a cascade across every test after it. That only + // shows up under a counterfactual (a broken rung routes a submission this + // file did not expect), which is exactly when a readable failure matters + // most. + await tick(); // `navGuard.remove()` clears the seam, so re-arm the file-wide recorder - // behind it: a swap still in flight from THIS test degrades after this - // line, and without the seam that is a real page load. + // behind it: a swap still in flight after the settle above degrades after + // this line, and without the seam that is a real page load. if (navGuard) navGuard.remove(); armStraySeam(); navGuard = null; @@ -211,7 +228,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { assert.equal(calls.length, 1, 'issuing exactly one fetch'); assert.equal(new URL(calls[0].url).pathname, '/x', 'to the form action'); assert.equal((calls[0].init.method || 'GET').toUpperCase(), 'POST'); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -249,7 +266,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -261,6 +278,11 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { // differs between the two halves. A synthetic dispatch is the only way to // aim a `submit` event at a non-form, and it is exactly what a stray // `dispatchEvent` in app code looks like. + // + // This rung is load-bearing rather than tidy: without it the handler runs + // on to `new FormData(div)`, which throws + // `Failed to construct 'FormData': parameter 1 is not of type + // 'HTMLFormElement'`, so a stray dispatch would take out the page. setup(okHtml); try { render(html` @@ -279,7 +301,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -306,7 +328,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -333,7 +355,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -370,7 +392,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(2); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -402,7 +424,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { assertBailed(form); assert.equal(dialog.open, false, 'the browser performed the dialog dismissal the bail exists to allow'); - } finally { teardown(); } + } finally { await teardown(); } }); test('rung 7 control: the same dialog with method="post" IS routed', async () => { @@ -424,7 +446,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { await tick(); assertRouted(0); assert.equal(dialog.open, true, 'and the dialog was not dismissed'); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -459,7 +481,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -488,7 +510,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -517,7 +539,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { control.querySelector('button').click(); await tick(); assertRouted(1); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -555,7 +577,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { assertRouted(1); assert.ok(calls[0].init.body instanceof URLSearchParams, 'and the invalid enctype was sent as urlencoded, its invalid-value default'); - } finally { teardown(); } + } finally { await teardown(); } }); test('rung 11: a submitter formenctype="text/plain" bails too', async () => { @@ -585,7 +607,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { assertRouted(1); assert.ok(calls[0].init.body instanceof FormData, "and the control's own formenctype decided its encoding"); - } finally { teardown(); } + } finally { await teardown(); } }); // ------------------------------------------------------------------------- @@ -630,7 +652,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { assert.equal(new URL(calls[0].url).searchParams.get('a'), '1', 'and the fields are promoted to the query string, as a GET submission does'); assert.equal(calls[0].init.body, undefined, 'with no body'); - } finally { teardown(); } + } finally { await teardown(); } }); test('an empty formenctype overrides the form and falls to urlencoded', async () => { @@ -653,7 +675,7 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { assert.ok(calls[0].init.body instanceof URLSearchParams, "the button's present-but-empty formenctype wins over the form's multipart"); assert.equal(calls[0].init.body.get('a'), '1', 'and the field survives the encoding'); - } finally { teardown(); } + } finally { await teardown(); } }); test('an empty formtarget overrides the form and means the current context', async () => { @@ -676,6 +698,6 @@ suite('Client router: the onSubmit bail ladder (#1322)', () => { container.querySelector('button').click(); await tick(); assertRouted(0); - } finally { teardown(); } + } finally { await teardown(); } }); }); From 459c0af07eeb064a1b736b933dddfec9a5bd4d5c Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 8 Aug 2026 23:14:49 +0530 Subject: [PATCH 3/3] 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. --- AGENTS.md | 2 +- packages/core/AGENTS.md | 16 ++++++++++++++++ packages/core/test/routing/router-client.test.js | 3 +-- website/app/docs/client-router/page.ts | 4 ++-- website/app/docs/progressive-enhancement/page.ts | 2 +- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ecc350d2..8fa6adc84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``, 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. 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, 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 `` 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 ``, 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. 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), **``** 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/packages/core/AGENTS.md b/packages/core/AGENTS.md index 2e91f9172..ae8e36848 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -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 +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 `` 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). diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 26080ce40..ec878c5a3 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -29,7 +29,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile, _reactivateScripts, _activateSwappedRange, _findAnchorInPath, _activeFrameId, _resolveTargetFrameId, _onPopState, _applySwap, _prefetchCache, _snapshotCache, _LIVE_ATTRS, _blurOutgoingFocus, - _onSubmit, _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, + _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, _getSubmitEnctype, _encodeSubmitBody, _restoreOptimistic, _navToken, _bumpNavToken, _currentPageUrl, _setCurrentPageUrl, _resetWarnOnce, @@ -98,7 +98,6 @@ before(async () => { _snapshotCache, _LIVE_ATTRS, _blurOutgoingFocus, - _onSubmit, _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, diff --git a/website/app/docs/client-router/page.ts b/website/app/docs/client-router/page.ts index 34bd26fa5..e5d11142c 100644 --- a/website/app/docs/client-router/page.ts +++ b/website/app/docs/client-router/page.ts @@ -28,7 +28,7 @@ export default function ClientRouter() {

When the destination streams (it has a Suspense or <webjs-suspense> 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).

Form submissions

-

<form action="/x" method="post"> works exactly per the HTML spec. WebJs intercepts the submit event in the bubble phase (after a component's own @submit handler) and routes the same fetch the browser would have sent through the partial-swap pipeline. Because it runs after, a component that calls e.preventDefault() in @submit keeps the form to itself and the router leaves it alone; the same applies to @click on links. Submitter attributes (formmethod, formaction, formenctype on a clicked <button>) take precedence over the form's own per HTML5.

+

<form action="/x" method="post"> works exactly per the HTML spec. WebJs intercepts the submit event in the bubble phase (after a component's own @submit handler) and routes the same fetch the browser would have sent through the partial-swap pipeline. Because it runs after, a component that calls e.preventDefault() in @submit keeps the form to itself and the router leaves it alone; the same applies to @click on links. Submitter attributes (formmethod, formaction, formenctype, formtarget on a clicked <button>) take precedence over the form's own per HTML5, decided on whether the attribute is PRESENT rather than on its value being non-empty: formmethod="" 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.

  • GET forms: FormData is promoted to the URL query string (replacing any existing query on action). The URL is then fetched and applied like a link click.
  • POST / PUT / PATCH / DELETE forms: FormData is sent as the request body. After a successful response the snapshot cache is cleared (other cached URLs may reflect stale server state).
  • @@ -38,7 +38,7 @@ export default function ClientRouter() {

    Auto-skipped (no opt-out needed):

    • method="dialog": browser-native <dialog> dismissal
    • -
    • target / formtarget_self: iframes, popups, named windows
    • +
    • A resolved target that is neither empty nor _self: iframes, popups, named windows. A submitter's formtarget wins over the form's whenever the attribute is PRESENT, so formtarget="" brings a target="_blank" form back to the current context and is routed, exactly as the browser would submit it.
    • Cross-origin action
    • Non-HTML extensions on the action URL
    diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts index a07318850..0af393b2e 100644 --- a/website/app/docs/progressive-enhancement/page.ts +++ b/website/app/docs/progressive-enhancement/page.ts @@ -177,7 +177,7 @@ export default function NewPost({ actionData }: {

    - "Identical by construction" is a claim about the whole submission, encoding included, and it is enforced in two places. The router resolves the effective enctype with native precedence (a submitter's formenctype over the form's) and ENCODES the body accordingly: multipart/form-data sends FormData, and application/x-www-form-urlencoded, which is the HTML default and therefore what a plain <form method="post"> means, sends URLSearchParams. Before that the router built FormData for everything, so an ordinary POST form sent a urlencoded body without JS and a multipart body with it. A text/plain 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. + "Identical by construction" is a claim about the whole submission, encoding included, and it is enforced in two places. The router resolves the effective enctype with native precedence (a submitter's formenctype over the form's, decided on whether the attribute is PRESENT rather than on its value being non-empty, so formenctype="" means urlencoded and not the form's declared encoding) and ENCODES the body accordingly: multipart/form-data sends FormData, and application/x-www-form-urlencoded, which is the HTML default and therefore what a plain <form method="post"> means, sends URLSearchParams. Before that the router built FormData for everything, so an ordinary POST form sent a urlencoded body without JS and a multipart body with it. A text/plain 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.

    A submitter that BINDS its own action is refused when it also declares a formmethod other than post, an unparseable formenctype, or formmethod="dialog", because those contradict the action attached to that same button. A button that binds nothing is left alone: its formmethod / formenctype 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.