diff --git a/.agents/skills/webjs/references/routing-and-pages.md b/.agents/skills/webjs/references/routing-and-pages.md index 9cbaeed7d..0512c1300 100644 --- a/.agents/skills/webjs/references/routing-and-pages.md +++ b/.agents/skills/webjs/references/routing-and-pages.md @@ -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 ` - - `, 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. @@ -343,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 () => { @@ -369,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 () => { @@ -390,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 () => { @@ -409,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 () => { @@ -430,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 { @@ -448,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 new file mode 100644 index 000000000..6d78e8e3f --- /dev/null +++ b/packages/core/test/routing/browser/submit-bail-ladder.test.js @@ -0,0 +1,703 @@ +/** + * 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. + * + * 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 + * 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; + } + + 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 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; + 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 { await 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 { await 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. + // + // 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` +
+ + +
+ `, 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await 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 { await teardown(); } + }); +}); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 06ac6717b..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, @@ -2977,7 +2976,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 +3027,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 +3128,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 * ==================================================================== */ 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.