Skip to content

test: pin each onSubmit bail to its own rung, not just "not routed" #1322

Description

@vivek7405

All line anchors in this issue were re-verified at HEAD e5806e24.

Problem

packages/core/test/routing/router-client.test.js carries nine tests that claim to pin an onSubmit bail (eight named onSubmit: ignores ..., one named bails). Every one of them proves only that a submission was NOT routed. None proves it bailed for the reason it names, and no change to any rung of the ladder can red any of them.

That last clause is stronger than the original report, and it was measured rather than reasoned. Two separate harness facts stack up.

Fact 1, the one the original report missed. globalThis.location is undefined when these tests run. Roughly a dozen earlier tests in the file set globalThis.location to a stub and restore it in a finally (the first save, at :1313, captured undefined), so by :3114 there is no location global at all. onSubmit reaches :875, try { url = new URL(action, location.href); }, which throws ReferenceError: location is not defined, and the bare catch { return; } at :876 swallows it. So four of the nine bail at rung 8 (the unparseable-action rung) rather than at the rung they name.

Fact 2, the one the original report described. buildSubmitFormData at packages/core/src/router-client.js:1017 does new FormData(form, submitter) inside a try, and the catch fallback does new FormData(form), which throws AGAIN under linkedom because FormData's constructor applies a WebIDL brand check to its first argument (the constructor comes from undici, the element from linkedom). Measured under Node 26 with this repo's linkedom, both calls throw TypeError: FormData constructor: Argument 1 could not be converted to: undefined., so the second throw escapes the catch. This is real, but in the current harness it is never reached, because Fact 1 returns first. Stub location and onSubmit stops returning quietly and starts THROWING out of the handler instead.

The consequence, measured. Driving _onSubmit in a faithful replica of the harness returns _wasPrevented() === false for the text/plain form, for the formenctype="text/plain" submitter, AND for an ordinary same-origin POST that the router is supposed to intercept. preventDefault() is unreachable for every shape, so there is no positive control that could exist in this file and no mutation of the ladder that could red any of the nine. A refactor deleting the data-no-router check would keep all nine green. The tests are vacuous, not merely weak.

This is NOT a standards violation and NOT a production bug. new FormData(form, submitter) is the spec'd constructor and browsers implement it; the catch exists for older Safari, which lacks the two-argument form. In a real browser the fallback's one-argument call cannot fail, and location always exists. The gap is reachable only in a non-browser DOM.

Found while writing a positive control during #1307 (PR #1317). That assertion was moved to the browser suite instead, and a comment recording the limitation sits at router-client.test.js:3136-3144. That comment is accurate about the FormData half and silent about the location half, which is why the fix looked cheaper than it is.

The rungs, as they exist in the source

onSubmit runs from packages/core/src/router-client.js:848. Its bails, in order:

Rung Anchor Bail
1 :849 router not enabled
2 :850 e.defaultPrevented already set
3 :856 event target is not a FORM element
4 :857 data-no-router on the form
5 :860 data-no-router on the submitter
6 :862-865 resolved target / formtarget is not _self
7 :868 resolved method is dialog
8 :875-876 the action url does not parse
9 :877 the action url is cross-origin
10 :878 the action pathname has a non-HTML extension
11 :898 an unsafe method with a text/plain enctype

There is no GET rung and no unbound-action rung. A same-origin GET form IS routed, and a form carrying no bound action identity is routed like any other; the unbound case is reported by warnIfActionSubmissionCannotDeliver at :888, which is observational and never bails.

Which rung each existing test actually reaches today

Test Line Claims rung Actually bails at
onSubmit: ignores forms with data-no-router (lets browser submit) :3114 4 4
onSubmit: ignores forms with target=_blank (popup) :3122 6 6
onSubmit: ignores submissions with method="dialog" :3129 7 7
onSubmit: an unsafe text/plain submission bails to the browser (#1307) :3145 11 8
onSubmit: a submitter formenctype="text/plain" bails too :3156 11 8
onSubmit: ignores cross-origin actions :3166 9 8
onSubmit: ignores file-download actions (non-HTML extensions) :3173 10 8
onSubmit: ignores already-prevented events (server-action RPC stub got first) :3180 2 2
onSubmit: ignores submitter with data-no-router (per-button escape) :3189 5 5

The five that do land on their own rung are still vacuous, because nothing distinguishes a return at rung 4 from a return at rung 8 when the terminal preventDefault() is unreachable for every input.

Rungs 1, 3, and 8 have no test at all in this list. Rung 1 is separately and honestly covered elsewhere (see below).

Design / approach

Decision: option 2. Move the ladder to the browser suite. Option 1 is rejected.

What settled it

Two measurements, both reproducible.

  1. Option 1 alone fixes nothing. Making the catch fallback total (hand-building from form.elements) does not make a single one of the four late-rung tests reach its rung, because they return at rung 8 on the missing location global before buildSubmitFormData is ever called. Verified by running _onSubmit against a replica harness with and without a globalThis.location stub: without it, every shape returns quietly at rung 8; with it, every shape throws out of buildSubmitFormData.
  2. The full prosthesis is four deep. To make the unit harness decide the ladder honestly you need a production branch that can never run in a browser, plus a location stub, plus a fetch stub, plus a history stub, because performSubmission runs past the last rung. That is simulating a browser inside a non-browser DOM in order to test a module that only ever runs in a browser. AGENTS.md is explicit that a unit test is necessary but not sufficient for a client-router change and that the headline behaviour is a browser assertion, and this is the case that rule exists for.

Prior art

Turbo is the closest analogue and it settles the shape of the replacement. Its bail ladder lives in ~/Documents/Projects/frameworks/turbo/src/observers/form_submit_observer.js (submissionDoesNotDismissDialog, submissionDoesNotTargetIFrame, the defaultPrevented guard), and every one of those bails is tested in a REAL browser under Playwright, in ~/Documents/Projects/frameworks/turbo/src/tests/functional/form_submission_tests.js:876-940. Turbo never unit-tests the ladder. More importantly, each Turbo bail test pairs the negative with a POSITIVE observation of the native effect the bail exists to allow:

  • data-turbo="false" asserts expect(page.locator("#element-id")).toBeAttached(), meaning the browser really performed the submission and the response document rendered.
  • formmethod="dialog" asserts expect(dialog).not.toHaveAttribute("open"), meaning the dialog really was dismissed.

WebJs's browser suite cannot let a real navigation happen (web-test-runner aborts the whole session, which is exactly why test/browser-nav-guard.js exists), so the native-document half of Turbo's technique is unavailable. The substitute below is equivalent in strength and is stated positively.

The replacement technique

Every ladder test is a pair inside one test body: a bail form and a near-miss control that differs from it by exactly the one attribute that trips the rung, submitted in that order into the same container.

Three positive assertions replace the single absence assertion:

  1. A submit probe on window bubble. Window bubble is the last step of the propagation path, so it runs after the router's own document-bubble listener (router-client.js:602) no matter what order the two were registered in. e.defaultPrevented read there is a direct read of the router's decision on THIS event, and false there means the browser is about to perform the submission natively, which is precisely what every bail claims. This is not an inference from an unrelated absence.
  2. probe.seen.length is asserted. A submission that never happened at all is the failure mode that made the unit tests vacuous, and asserting the probe saw exactly one event whose target is the expected form rules it out. This is the assertion that makes the whole thing non-vacuous.
  3. The near-miss control is routed. In the same test, the identical form minus the triggering attribute must produce defaultPrevented === true at the probe and exactly one fetch. Without this, a router that bailed on everything would keep the bail half green.

Breaking one rung then reds exactly one test, because only that rung's bail form carries the triggering attribute; and a rung that fires too eagerly reds every control, which is a broad break correctly reported broadly.

One rung gets Turbo's own technique verbatim. Rung 7 is tested with a real <dialog>, asserting dialog.open === false after the submit, which is the native effect and needs no probe at all.

Rung 1 is already pinned, and is not part of this work

packages/core/test/routing/client-router-opt-out.test.js:35 asserts that with window.__WEBJS_CLIENT_ROUTER__ = false the module-end auto-enable binds NO document listeners. That is a structural positive assertion, not a "not routed" absence, so rung 1 already has honest coverage. Do not add a browser test for it. Note also that with the router disabled the submit listener is removed entirely, so the if (!enabled) line at :849 is a defensive guard against a stale in-flight dispatch and is not reachable from a real event.

Alternatives considered and rejected

  • Option 1, a total buildSubmitFormData fallback. Rejected on measurement (above), not on taste. It adds a production branch that can never run in a browser AND still leaves the four late-rung tests bailing at rung 8.
  • Stubbing globalThis.location in each bail test. Rejected. It fixes Fact 1 and immediately exposes Fact 2 as a throw, so it must be combined with option 1, and then with fetch and history stubs to get a control. Four prostheses to avoid using the browser the code targets.
  • Asserting on console output from warnIfActionSubmissionCannotDeliver at :888. Rejected. The dev guard is silent in production and its messages are not a stable contract. The existing guard tests in form-action-submit.test.js:329-452 legitimately assert on it because the guard's output IS their subject; a ladder test asserting on it would be pinning the wrong thing.
  • Keeping the nine unit tests as weaker smoke assertions. Rejected. They cannot fail, so they are not smoke, they are noise, and leaving them signals coverage that does not exist. WebJs has no users and no back-compat burden, so the clean deletion is available and is correct.

Implementation plan

File NO follow-up issues for anything this work turns up. Fix every finding inside this PR. This matters here specifically. Pinning each rung is a discovery exercise, and a rung that turns out to be genuinely BROKEN in the router rather than merely untested is fixed in this PR alongside the test that caught it, with its own unit test. One such defect is already identified in step 7 below and is IN SCOPE. If a finding is genuinely too large for one PR, report it to the user as a note in the PR description. Do not open an issue for it.

Step 1. Create the ladder's home

Create packages/core/test/routing/browser/submit-bail-ladder.test.js. Plain .js with JSDoc, matching every other file in that directory. Never a .ts file under packages/.

Model its setup(responder, opts) / teardown() on the pair at packages/core/test/routing/browser/form-action-submit.test.js:41-66, which gives a <div> container bracketed by a live keyed boundary pair (wj:children:/:/ and /wj:children:/, needed so an intercepted submission swaps softly instead of degrading), a stubbed window.fetch recording { url, init } into calls, and installNavGuard() from test/browser-nav-guard.js.

Two deltas from that file:

  • setup takes { navGuard = true } = {}. When false it skips installNavGuard() and teardown tolerates the missing guard. Rung 7 needs this, and the reason goes in a comment: the guard's window-bubble preventDefault() cancels a <dialog> form's own dismissal, which is the exact native effect rung 7's positive assertion reads, and a method="dialog" form can never navigate, so there is nothing for the guard to protect against.
  • setup installs the submit probe FIRST, before installNavGuard(). Both listen on window bubble and listeners on the same target in the same phase fire in registration order, so a probe registered second would read the guard's preventDefault rather than the router's decision.

The probe:

/**
 * 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.
 */
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); } };
}

Step 2. Write the ladder floor

First test of the file: an ordinary same-origin POST with no bail attribute IS intercepted. Assert probe.seen.length === 1, probe.seen[0].routed === true, and calls.length === 1 with the expected method and url. A router that bails on everything must fail here. (form-action-submit.test.js:69 already asserts a related positive for a bound form; this one is the ladder's own floor and stays in the ladder file so the file is self-contained.)

Step 3. Write one test per rung, each a bail plus its near-miss control

Render both forms in ONE render() call so the differential is visible in the source, click the bail form's button first and the control's second, and assert on probe.seen[0] then probe.seen[1]. The control is clicked last because its successful swap replaces the container contents.

Assertion block for the bail half of every test:

assert.equal(probe.seen.length, 1, 'the submit event fired and reached the router');
assert.equal(probe.seen[0].target, form, 'and it is the form under test');
assert.equal(probe.seen[0].routed, false, 'the router declined it, so the browser submits natively');
assert.equal(calls.length, 0, 'and the router issued no fetch');

Assertion block for the control half:

assert.equal(probe.seen[1].routed, true, 'the near-miss control IS routed');
assert.equal(calls.length, 1, 'and issues exactly one fetch');

Step 4. The per-rung table

Every row is one test in submit-bail-ladder.test.js. Replaces names the unit test being deleted in step 5.

Rung Anchor Bail fixture Near-miss control Positive assertion beyond the probe Replaces
2 :850 <form method="post" action="/x"> with an at-target @submit listener calling preventDefault() the identical form with no listener the listener ran (it pushes a marker), and calls.length === 0, so the user handler owns the event :3180
3 :856 div.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) on a <div> the same synthetic event dispatched on a real <form method="post" action="/x"> same event type, same dispatch, only target.tagName differs, and only the form is routed none (rung was untested)
4 :857 <form method="post" action="/x" data-no-router> the identical form without the attribute the two forms differ by one attribute and only one of them is routed :3114
5 :860 <form method="post" action="/x"><button type="submit" data-no-router> the identical form whose button lacks the attribute :3189
6 :862-865 two bails in one test, <form target="_blank"> and a clean form with <button formtarget="_blank"> <form target="_self"> the control proves the rung reads the VALUE, not attribute presence :3122
7 :868 <dialog open><form method="dialog"><button type="submit"> submitted with setup(responder, { navGuard: false }) second test, guard ON, the same dialog with method="post" dialog.open === false after the bail (Turbo's own assertion, the native effect), and dialog.open === true plus one fetch for the control :3129
8 :875-876 <form method="post" action="http://["> <form method="post" action="${location.origin}/x"> removing the rung makes onSubmit throw, which web-test-runner reports as an uncaught error, so the rung is pinned from both sides. The tightest honest control is a parseable same-origin absolute url, since an unparseable url has no origin to match none (rung was untested)
9 :877 <form method="post" action="https://other.example.test/x"> <form method="post" action="${location.origin}/x"> both absolute, differing only in origin, so the control proves the check is on origin and not on absoluteness :3166
10 :878 <form method="get" action="/data.pdf"> <form method="get" action="/data.html"> the pair differs only in the extension :3173
11a :898 <form method="post" enctype="text/plain" action="/never"> <form method="post" enctype="nonsense" action="/never">, which is the invalid-value default and is routed as urlencoded the control is the sharpest available: an enctype the router must NOT bail on, already asserted in form-action-submit.test.js:259 :3145
11b :898 <form method="post"><button formenctype="text/plain"> the identical form whose button carries formenctype="multipart/form-data" pins the submitter half of the precedence, which is what :3156 claimed :3156

Move the existing browser test a text/plain POST is NOT routed, so both paths do the same native thing (form-action-submit.test.js:277-299) into the new file as the body of row 11a and upgrade it with the probe and the control. Leave the enctype ENCODING tests (:207-275) and the dev-guard tests (:301-452) where they are; they are about encoding and about the guard, not about the ladder.

Step 5. Delete the nine vacuous unit tests

Delete router-client.test.js:3114-3195 in full, together with the fakeSubmitEvent helper at :3103-3112, which no remaining test uses. Delete the NOTE on these two bail tests comment at :3136-3144.

The pure resolver tests immediately above (getSubmitMethod at :2989, getSubmitAction at :3072-3091, getSubmitEnctype, encodeSubmitBody at :3050-3070) STAY. They assert real return values and are the unit layer AGENTS.md requires; the ladder itself is the browser layer's job.

Replace the deleted section header and comment with this block, placed above the surviving resolver tests, so the next reader learns why the ladder is not here:

/* ====================================================================
 * 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.
 *
 * The resolvers below are pure functions over attributes, so they are
 * genuinely unit-testable and stay.
 * ==================================================================== */

Step 6. Run the counterfactual for every rung

For each of rungs 2 through 11, comment out that rung's return in packages/core/src/router-client.js, run npx wtr packages/core/test/routing/browser/submit-bail-ladder.test.js, confirm EXACTLY ONE test reds and that it is the right one, then restore the line. Rung 8's counterfactual reds as an uncaught error rather than a failed assertion, which is expected and is the correct signal. Record the ten results in the PR description. This step is the whole point of the issue and is not optional.

Step 7. The defect this exercise already found, which is IN SCOPE

getSubmitAction at :934 resolves the submitter's override with hasAttribute, precisely so a present-but-empty formaction="" wins over the form's action (there is a unit test for it at :3084). The three sibling resolvers use a || chain instead, so a present-but-empty attribute is falsy and silently falls through to the form's value:

  • getSubmitMethod at :917
  • getSubmitEnctype at :971
  • the target chain inline at :862-864

Measured against Chromium, Firefox, and WebKit at the request level: a <button type="submit" formmethod="" formenctype=""> inside <form method="post" enctype="multipart/form-data" action="/submit"> submits natively as GET /submit?a=1 with no body. The router resolves the same button as a multipart POST. That is a JS-on versus JS-off divergence of exactly the class #1307 was opened to eliminate. The same measurement shows button.formEnctype reflecting "application/x-www-form-urlencoded" and button.formTarget reflecting "" for the empty attributes, so formtarget="" should target the current browsing context while the router resolves the form's target="_blank" and bails at rung 6.

Do this, in this PR:

  1. Add three tests to submit-bail-ladder.test.js covering formmethod="", formenctype="", and formtarget="", asserting native precedence (the submitter's present-but-empty attribute wins and falls to its own invalid-value default).
  2. Fix the three resolvers to test presence with hasAttribute and then read the value, mirroring getSubmitAction:934 exactly.
  3. Add the matching unit tests beside the existing resolver tests in router-client.test.js (those resolvers are pure, so unit coverage there is honest).
  4. Sync the two doc surfaces named in the Docs section below.

If any of the three turns out to already agree with the browser, keep the test as a pinning test and skip the fix for that one. Report which in the PR description. Do not file a follow-up issue either way.

Step 8. Verify

# the ladder alone, across Chromium, Firefox and WebKit
npx wtr packages/core/test/routing/browser/submit-bail-ladder.test.js

# the file the text/plain test moved out of, to prove nothing was orphaned
npx wtr packages/core/test/routing/browser/form-action-submit.test.js

# the full browser layer
npm run test:browser

# the node layer, which does NOT include any of the above
npm test

# convention validator
npx webjs check

npm test does NOT run the browser layer. Report both results separately in the PR description.

Tests

Browser (the headline layer). packages/core/test/routing/browser/submit-bail-ladder.test.js, new, run with npm run test:browser or npx wtr <path>. Runs on Chromium, Firefox, and WebKit per web-test-runner.config.js. Eleven ladder tests plus the floor plus three empty-attribute precedence tests. Also touched: packages/core/test/routing/browser/form-action-submit.test.js loses the moved text/plain test at :277-299 and nothing else.

Counterfactual, per rung. The table in step 4 is the specification. For rungs 2 through 11, deleting that rung's return must red that rung's test and no other, and the near-miss control in that same test must stay green. Rung 8 reds as an uncaught error. This is executed manually in step 6 and the ten results are recorded in the PR description.

Counterfactual for the vacuity claim itself. Before deleting them, delete the data-no-router check at :857 and run node --test packages/core/test/routing/router-client.test.js. All 222 tests still pass. That is the defect, demonstrated, and it belongs in the PR description as the reason for the deletion.

Unit. packages/core/test/routing/router-client.test.js loses :3103-3112 and :3114-3195, keeps every resolver test, and gains the three empty-attribute resolver tests from step 7 if that fix lands. Run with npm test or node --test packages/core/test/routing/router-client.test.js.

E2E. Not in play. The ladder is a client-side decision observable entirely in the browser layer; an e2e run would add a server round trip that proves nothing extra about which rung fired.

Bun parity: NOT owed, and none is to be added. packages/core/src/router-client.js runs only in a browser (its module-end auto-enable is behind a typeof document guard, and nothing in the SSR, listener, action, or serializer path imports it). Bun is a JS runtime for the SERVER, so it never executes this module, and the browser layer runs under Playwright rather than under a runtime being compared. The path also does not match the runtime-sensitive regex in .claude/hooks/require-bun-parity-with-runtime-src.sh:62, so the hook will not fire and WEBJS_BUN_VERIFIED=1 is not needed. Adding a test/bun/** file here would be a reflex, not coverage.

Docs

For the test-only work: none. .claude/hooks/require-docs-with-src.sh fires only on staged packages/*/src paths, and a pure test change stages none. Do not use WEBJS_NO_DOC_GATE=1; there is nothing to bypass.

If the step 7 resolver fix lands, packages/core/src/router-client.js IS staged and the doc gate fires. Satisfy it properly rather than bypassing it:

  • AGENTS.md:371 already claims the enctype is "resolved with native precedence (a submitter's formenctype over the form's)". Extend that parenthetical to say a present-but-empty submitter attribute wins and falls to its own invalid-value default, which is the claim the fix restores.
  • .agents/skills/webjs/references/routing-and-pages.md:182, the submitter-precedence paragraph, gets the same clause.

No scaffold copy to sync. The agent skill lives ONCE canonically at the repo-root .agents/skills/webjs/ and webjs create copies it from there (packages/cli/lib/create.js:666).

The docs site pages that mention formenctype (website/app/docs/client-router/page.ts, website/app/docs/server-actions/page.ts, website/app/docs/progressive-enhancement/page.ts) describe the feature, not this edge case, and need no change.

Acceptance criteria

  • packages/core/test/routing/browser/submit-bail-ladder.test.js exists and covers rungs 2 through 11, one test per rung (two for rung 11), each pairing a bail fixture with a near-miss control that differs by exactly the triggering attribute
  • Every bail test asserts POSITIVELY: the probe saw exactly one submit event, its target is the form under test, and defaultPrevented is false at window bubble, meaning the browser performs the submission. No test in the file asserts only that a fetch did not happen
  • The rung 7 test asserts dialog.open === false, the native effect, matching Turbo's own assertion for the same rung
  • The ladder floor test proves an ordinary same-origin POST IS intercepted, so a router that bails on everything fails
  • Deleting any one of rungs 2 through 11 reds exactly one test and no other, verified for all ten and recorded in the PR description
  • The nine vacuous unit tests at router-client.test.js:3114-3195, the fakeSubmitEvent helper at :3103-3112, and the :3136-3144 comment are deleted, and the replacement comment from step 5 explains where the ladder went and why it cannot live in that file
  • The resolver unit tests in router-client.test.js still pass unchanged
  • formmethod="", formenctype="", and formtarget="" on a submitter are pinned by tests, and any divergence from native precedence is FIXED in this PR, not filed as a follow-up
  • No follow-up issue was filed for anything this work turned up
  • npm test green
  • npm run test:browser green on Chromium, Firefox, and WebKit
  • npx webjs check clean
  • If packages/core/src/router-client.js was touched, AGENTS.md:371 and .agents/skills/webjs/references/routing-and-pages.md:182 are updated and WEBJS_NO_DOC_GATE=1 was not used

Out of scope

  • Do not make buildSubmitFormData (router-client.js:1017) total. That is the rejected option 1. Leave the try and its one-argument catch fallback exactly as they are; the fallback exists for older Safari and is correct.
  • Do not stub globalThis.location or FormData in router-client.test.js to keep the ladder in the unit layer. That is the same rejected approach wearing a different hat.
  • Do not assert on console output from warnIfActionSubmissionCannotDeliver (:888) in any ladder test. The dev guard is silent in production and its messages are not a stable contract. The existing guard tests in form-action-submit.test.js:301-452 stay untouched.
  • Do not add a browser test for rung 1. client-router-opt-out.test.js:35 already pins it structurally.
  • Do not touch the onClick ladder. It has the same shape and possibly the same problem. If you notice something there, say so in the PR description and leave the code alone.
  • Do not change the rung ORDER or add a rung. The only source change sanctioned here is the step 7 resolver fix, scoped to getSubmitMethod:917, getSubmitEnctype:971, and the target chain at :862-864.
  • Do not restructure form-action-submit.test.js beyond removing the one moved test at :277-299.
  • Do not add a test/bun/** test. See the Tests section for why none is owed.
  • Do not add a .ts file anywhere under packages/. That directory is plain .js with JSDoc.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions