fix: a submitter's empty formmethod/formenctype/formtarget wins, as native - #1352
Conversation
…ative 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 `<button type="submit" formmethod="" formenctype="">` inside a `<form method="post" enctype="multipart/form-data" action="/submit">` submits natively as `GET /submit?a=1` with no body, while the router resolved it as a multipart POST. Same template, two different requests with JS on and with JS off, which is the class of divergence #1307 exists to eliminate. Found while pinning the onSubmit bail ladder for #1322.
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.
|
Design rationale: why the probe reads window bubble, and the one rung it cannot carry The whole file hinges on where the probe listens. Window bubble is the last step of the propagation path, so it runs after the router's own document-bubble listener no matter which of the two was registered first, and Rung 2 is the one rung the probe cannot carry, and it is worth stating why rather than leaving the odd-looking assertion block to be discovered. That rung's whole subject is an event the USER already prevented, so On the issue's rejected option 1, making |
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.
vivek7405
left a comment
There was a problem hiding this comment.
Read the whole diff against the ladder it replaces. The source fix is right: all three resolvers now match the form-submission algorithm's presence test and line up with getSubmitAction and with the renderer's own ABSENT sentinel, and the new tests assert the engine's IDL reflection alongside the router's behaviour, so the native oracle is in the test rather than quoted from somewhere else. The ladder's probe ordering is the load-bearing part of the whole file and it is correct.
What I would keep an eye on is the docs half, which is where everything I found sits. The presence rule is a fiddly one to state, and the root file stated it wrong for formtarget, the one of the three that is not an enumerated attribute. The docs site was not touched at all and carried a line this change makes false. There was also a leftover binding from the deletion, and rung 7's nav-guard exception is real but was unrecorded in the rule it deviates from.
All four are fixed in 459c0af0; the per-line threads say which is which.
Closes #1322
Summary
The client router's
onSubmitis a ladder of guards, each declining a submission and handing it back to the browser. Nine unit tests claimed to pin those bails and none of them could fail. In that harness there is nolocationglobal, soonSubmitthrows aReferenceErroratnew URL(action, location.href)and the barecatchswallows it; stublocationand the next wall isnew FormData(formElement), which throws under linkedom because the constructor's WebIDL brand check rejects a linkedom element. Either waypreventDefault()was unreachable for every input, so an ordinary POST the router DOES intercept looked exactly like a bail. Demonstrated rather than argued: deleting thedata-no-routerrung outright left all 222 tests in that file green.Every rung now lives in a real browser, in
packages/core/test/routing/browser/submit-bail-ladder.test.js, one test per rung, each pairing a bail fixture with a near-miss control that differs by exactly the attribute that trips the rung. A submit probe on window bubble readse.defaultPrevented, which is a direct read of the router's decision about that event, andprobe.seen.lengthis asserted so "no submission happened at all" cannot pass. Rung 7 borrows Turbo's own assertion for the same rung: the<dialog>really closed.Writing the ladder turned up a real bug, fixed here. A submitter's
formmethod/formenctype/formtargetoverride the form on PRESENCE, never on the value being truthy.getSubmitActionalready did that, which is why a present-but-emptyformaction=""correctly means submit-to-self; the three siblings used a||chain, so an empty value was falsy and fell through to the form's. All three diverged from every engine, and all three are fixed. The three new tests are written so the engine's own IDL reflection (button.formMethodand friends) is asserted alongside the router's behaviour, so the native oracle is in the test rather than quoted from a measurement made elsewhere.What changed
packages/core/test/routing/browser/submit-bail-ladder.test.js: the floor, rungs 2 to 11, and the three empty-attribute precedence tests. Sixteen tests, green on Chromium, Firefox and WebKit.packages/core/src/router-client.js:getSubmitMethod,getSubmitEnctype, and the target chain inonSubmitnow resolve onhasAttribute, mirroringgetSubmitAction.onSubmittests inrouter-client.test.js, thefakeSubmitEventhelper nothing else used, and the note that described half the problem. The section header now explains where the ladder went and why it cannot live there. The resolver tests stay and gained two rows for the empty-attribute rule.text/plainbail out ofform-action-submit.test.jsinto the ladder, where it has a control. That file's ENCODING and dev-guard tests are untouched.formtargetclaim.formtargetis 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.website/app/docs/client-router/page.tslisted the auto-skip as "target/formtargetnot_self", which the presence fix makes false forformtarget="".packages/core/AGENTS.mdnow records the one nav-guard exception rung 7 takes, with both its conditions and the second-channel obligation it does not buy out.teardownbefore dismantling. Measured:form-action-submit.test.json Firefox failed roughly 1 run in 3 without this and is 5 for 5 with it, whileorigin/mainis 4 for 4 (so the flake was latent, not pre-existing in effect).Per-rung counterfactual
Deleting each rung's
returninrouter-client.js, one at a time, and running the ladder file:defaultPreventedFORMdata-no-routerdata-no-router_selfdialogtext/plainRungs 3 and 8 are the two a later line DEPENDS on rather than merely a decision the router makes, so deleting either turns
onSubmitinto a throw instead of a wrong verdict, and web-test-runner reports an uncaught page error across the file rather than against one test. For rung 3 that throw isTypeError: Failed to construct 'FormData': parameter 1 is not of type 'HTMLFormElement', which is worth knowing on its own: without that rung a straydispatchEvent(new Event('submit'))in app code takes out the page. The wide blast radius is the honest signal there, and #1322 anticipated it for rung 8.Run on Chromium; the rung logic reads attributes and urls with no engine-specific behaviour, and the file itself is green on all three.
Docs
AGENTS.md, the client-navigation paragraph: the enctype precedence claim now says it is decided on presence and names the invalid-value-default consequence..agents/skills/webjs/references/routing-and-pages.md: the same rule, stated for all three submitter overrides.Test plan
npx wtr packages/core/test/routing/browser/submit-bail-ladder.test.js: 16/16 on Chromium, Firefox and WebKitnpx wtr packages/core/test/routing/browser/form-action-submit.test.js: 13/13, and 5 consecutive clean Firefox runsnpm run test:browser: 838 Chromium, 828 Firefox, 838 WebKit, 0 failednpm test: 4106 of 4112 locally. The 5 failures are alltest/bun/listener.test.mjs, which asserts the Bun listener's redirect path, fails identically on a cleanmaincheckout on this machine, and is green in CI. Local Node version, not this diff.npx webjs check: clean onexamples/blogand onwebsitedata-no-routerrung left all 222 unit tests green, which is the reason for the deletionBun parity: N/A.
router-client.jsruns only in a browser and nothing in the SSR, listener, action, or serializer path imports it, so Bun never executes it.E2E: N/A. The ladder is a client-side decision observable entirely in the browser layer.