Skip to content

feat: make a bound form submitter self-sufficient, and honour the authored enctype #1307

Description

@vivek7405

The issue title was changed alongside this rewrite, so the title and this body agree.

Line anchors below are against origin/main at 51556a50, which is where the
implementer branches. Where a hunk has to be lifted off the abandoned branch
feat/submitter-needs-bound-form (HEAD ae2c6607, PR #1314), that branch is
named explicitly. Re-verify every anchor before editing, since main moves.

This issue has been redirected. Its previous body planned a DETECTION
strategy (a webjs check rule, a dev guard, telemetry) for a silent failure.
That work is implemented on feat/submitter-needs-bound-form and open as
PR #1314. The decision now is that detection is the wrong answer and the shape
should simply be made to WORK. The Problem statement below keeps the substance
of the old one, corrected where it was stale. Everything after it is new.

Problem

<button formaction=${action}> binds a server action to one submit button
(#1207). The renderer replaces the formaction= hole in place with the button's
own name="__webjs_action" value="<hash>/<fn>" pair, which is the one channel a
browser submits for the pressed button alone, and the dispatcher takes the LAST
__webjs_action entry in DOM order.

Today that button is only half a submission. It carries the identity and nothing
else. method="post" and enctype="multipart/form-data" are supplied on the
FORM's start tag by bindFormActionStartTag
(packages/core/src/form-action.js:677), which SSR has already emitted by the
time it reaches the button. So a bound submitter is only usable inside a form
that is itself bound, and assertSubmitterFormIsBound
(packages/core/src/form-action.js:376) refuses it anywhere else.

That refusal cannot be enforced where it matters. SSR reads a linear byte stream
one template at a time, and a COMPONENT renders its own template in a separate
pass seeded 'unknown' with no view of the host page
(packages/core/src/render-server.js:523 skips the check under 'unknown').
The client has its own version of the limit
(packages/core/src/render-client.js:904, asked once per element and skipped
while the fragment is detached). A button inside a component is the ordinary
shape, so the fallback has to bind, and the refusal then never fires for the
case it exists for.

What the residual actually does in production, measured rather than reasoned.
Rendering html with a bound submitter inside a component, inside a bare
<form>, emits:

<form><row-btn data-wj-host><!--webjs-hydrate--><button name="__webjs_action" value="abc123/doThing">Go</button></row-btn></form>

Three outcomes follow, and only one of them is loud.

  1. An unbound <form> with no method, the dominant case, is a silent 200.
    The browser submits a GET to the page's own url with __webjs_action in the
    QUERY STRING, and the page simply re-renders. No action runs, no 405, no log.
    With JavaScript on the outcome is identical, because performSubmission
    promotes a safe-method body to the query string
    (packages/core/src/router-client.js:1440).
  2. An unbound <form method="post"> actually WORKS. The submitter's own
    name/value pair carries the identity into the body and the action runs.
  3. An unbound form with an unparseable enctype is a bare 405.
    looksLikeFormSubmission (packages/server/src/form-dispatch.js:212)
    accepts only multipart/form-data and
    application/x-www-form-urlencoded, so a text/plain POST is answered by
    methodNotAllowed() at the top of runFormAction
    (packages/server/src/form-dispatch.js:264) before its body is read.

Correction to the previous body. It put the bind-nothing 405 at
form-dispatch.js:241, then at :293. Neither is right on current main. The
405 for a body carrying no identity is inside runFormAction, after the body is
parsed; methodNotAllowed() itself is defined at form-dispatch.js:239. The
previous body also asserted that the residual is answered with a 405 in the
dominant case. It is not. It is a 200.

A second correction, and this one is to the previous body's PREMISE rather
than to its line anchors.
The Part B rule (assertSubmitterSubmission, which
refuses a formmethod or formenctype on ANY submitter inside a bound form) is
justified in the source and on every doc surface on the grounds that either
attribute "works under JS (the router posts FormData)" and is "a bare 405
without it" (packages/core/src/form-action.js:59-64, and AGENTS.md:486
states the generalization outright). That is true of formenctype and FALSE
of formmethod, and it was never true.
Verified against origin/main:
getSubmitMethod (packages/core/src/router-client.js:610) already honours the
submitter's formmethod with native precedence, and performSubmission
(:1446-1456) treats get and head as safe, promotes the body into the query
string and sends null as the body. So
<form action=${fn}><button formmethod="get"> produces a GET carrying
__webjs_action in the query string with JavaScript ON, and exactly the same
GET with it OFF. Both paths lose the identity identically and neither runs the
action. The works-one-way-only argument holds only for formenctype, which the
router genuinely ignores until step 4 below. This is recorded because that
argument is the STATED reason the Part B rule exists, so leaving it unchallenged
is how someone reinstates the rule later on reasoning that was half wrong from
the start.

A second, independent defect in the same code path. The client router builds
every submission body as FormData (buildSubmitFormData,
packages/core/src/router-client.js:640) and sends it with no explicit
content type (init.body = body at
packages/core/src/router-client.js:2319), so fetch always derives
multipart/form-data. The authored enctype and formenctype are never
read.
An author who writes
<form enctype="application/x-www-form-urlencoded"> gets urlencoded with
JavaScript off and multipart with JavaScript on. The two paths disagree, which
is exactly what progressive enhancement is supposed to rule out. This is
unrelated to the submitter question and is fixed in the same change, because
both live in onSubmit.

The shape of the fix. Detection was the previous answer. Making the button
self-sufficient is the right one, it is what every comparable framework does,
and it deletes an entire class of failure rather than reporting it.

Design / approach

The rule, in one sentence

The renderer supplies submission attributes at the level where the action is
bound, and never overrides what the author wrote at that same level.

  • <form action=${fn}> supplies method="post" and
    enctype="multipart/form-data" on the FORM start tag where the template
    supplies neither, plus the hidden __webjs_action identity field. This is
    today's behaviour and is unchanged.
  • <button formaction=${fn}> NEWLY supplies formmethod="post" and
    formenctype="multipart/form-data" on the BUTTON where the template supplies
    neither, alongside the name="__webjs_action" value="<id>" pair it already
    emits.

A bound submitter then works inside a bound form, an unbound form, a
method="get" form, or a form with no method at all. The failure class this
issue was filed about ceases to exist. Nothing has to infer anything about any
other element, so the four-state form-scope machinery that existed only to serve
the cross-element refusal is deleted outright.

What the renderer still refuses, and why the line falls there

The surviving line is: refuse a SAME-ELEMENT contradiction, never a rule about
the author's other elements.
A same-element contradiction has no correct
behaviour to fall back to. A cross-element rule always does, namely whatever
native HTML would have done.

Survives.

  • A BOUND form declaring its own method="get", or an enctype outside
    PARSEABLE_ENCTYPES (packages/core/src/form-action.js:616). Same element,
    straight contradiction with the binding.
  • A BOUND submitter declaring its own formmethod other than post, a
    formenctype outside PARSEABLE_ENCTYPES, or formmethod="dialog" (which
    dismisses a <dialog> and never submits, so the bound action could never run).
  • The submitter must be a real submit control. <input type="image"> submits
    name.x and name.y coordinates so the identity never arrives;
    <input type="submit"> would have to put the action id in its value, which
    on that control is also its visible label.
  • A bound submitter carrying its own name, value, form, or a static
    formaction. Each occupies a channel the binding needs.
  • Every .prop spelling on a bound form (.method / .enctype / .encoding)
    and on a bound submitter (.name / .value / .formAction / .formMethod /
    .formEnctype). A .prop hole is DROPPED at SSR and applied for real in the
    browser, where all of those are reflected IDL attributes, so the form would
    submit one way with JavaScript and another way without it. This is a
    progressive-enhancement rule and it is not negotiable.
  • A quoted action="${fn}" or formaction="${fn}", and action=${fn} on a tag
    that is not <form>. Stringifying a function would write a server action's
    source into the served HTML.
  • A second action hole on one form, and a plain action="/url" alongside a
    bound hole.

Dies.

  • assertSubmitterFormIsBound (packages/core/src/form-action.js:376) and the
    whole "the enclosing form must be bound" refusal. Its premise is gone: the
    button no longer needs anything from the form.
  • assertBoundFormSubmitters (packages/core/src/form-action.js:1024), the
    client-side sweep that enforced the same thing.
  • The "Part B" rule as it applies to PLAIN, non-binding submitters. A
    <button formmethod="get"> inside a bound form is a legal native override
    that means exactly what the author typed, and a user who can override
    formmethod and formenctype in native HTML must be able to do it in WebJs
    too. The diagnostic value is not lost: the dev-time client guard carried over
    from PR feat: resolve form-submitter boundness in webjs check and make the residual loud #1314 logs at submit time when a submission carries an identity it
    cannot deliver.
  • The .prop refusal on a PLAIN submitter inside a bound form, which was
    reachable only through the Part B branch
    (packages/core/src/form-action.js:952 gates it). What it caught is the
    ordinary native-property rule (SSR drops a .prop on a native element, the
    browser applies it), which this codebase already accepts everywhere else and
    documents as such at packages/core/src/form-action.js:1059-1066 for an
    unbound form's .method.
  • The four-state form scope ('none' / 'unbound' / 'bound' / 'unknown')
    and the cannot-tell compromise. Checked, and it is not load-bearing for
    anything that survives.
    The surviving form-level rules are per-tag and read
    pendingActionCount and pendingPropAttrs, never formScope
    (assertConvergentBoundForm at packages/core/src/form-action.js:727 is
    called from packages/core/src/render-server.js:228 with per-tag state only).
    formScope has exactly two consumers, the Part B branch at
    packages/core/src/render-server.js:239 and the boundness check at :523,
    and both die. So the parameter is removed from render, renderTemplate,
    streamRender, streamTemplate, the renderToString options, the
    SuspenseCtx typedef, and packages/server/src/ssr.js:2112 and :2175.
  • The entire submitter-needs-bound-form webjs check rule and its scanner
    support, which exist only on PR feat: resolve form-submitter boundness in webjs check and make the residual loud #1314 and never reached main.

Prior art

React and Next. React's SSR emits a submit button's submission attributes
from the action's own encoding descriptor. pushFormActionAttribute at
/home/vivek/Documents/Projects/frameworks/next.js/packages/next/src/compiled/react-dom/cjs/react-dom-server.browser.development.js:1399-1451
pushes name, formAction, formEncType, formMethod and formTarget onto
the button, from getCustomFormFields (:1373). For a server action those
fields are literally
{ name: "$ACTION_ID_<id>", method: "POST", encType: "multipart/form-data" }
(packages/next/src/compiled/react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.development.js:847-852).
pushStartForm (react-dom-server.browser.development.js:2447-2495) is the
structurally identical function one level up for the form. React therefore does
exactly what this redesign proposes, on both elements, and its submitter never
depends on its form. React also warns, once, when an author writes
formEncType or formMethod alongside a function formAction ("React provides
those automatically. They will get overridden.", :1421), which is the same
same-element-contradiction rule proposed above, expressed as a warning rather
than a refusal.

Turbo. /home/vivek/Documents/Projects/frameworks/turbo/src/core/drive/form_submission.js
resolves a submission with native precedence throughout: getMethod (:266)
reads the submitter's formmethod before the form's method, getFormAction
(:246) reads the submitter's formaction before the form's action, and
getEnctype (:271) reads the submitter's formenctype before the form's
enctype. It HONOURS the resolved enctype on the wire: buildResourceAndBody
(src/http/fetch_request.js:215-226) sends URLSearchParams for urlencoded and
the raw FormData otherwise. It carries the pressed submitter by appending its
name and value manually (buildFormData, :219). Two things transfer
directly. First, Turbo never throws at a form shape. The observer
(src/observers/form_submit_observer.js:30-46) simply declines to
preventDefault, so the browser performs the native submission, and a
submission it cannot use becomes an Error handed to the delegate, which
console.errors it (src/core/drive/navigator.js:109-110). Second, Turbo
diverges from the platform in one place worth not copying: entriesExcludingFiles
(src/http/fetch_request.js:229) DROPS File entries under urlencoded, where
the platform serializes the file's name.

Rails. extra_tags_for_form
(/home/vivek/Documents/Projects/frameworks/rails/actionview/lib/action_view/helpers/form_tag_helper.rb:1038-1065)
sets html_options["method"] itself in every branch: absent becomes post, and
a verb HTML cannot express becomes post plus a hidden _method field
(method_tag, actionview/lib/action_view/helpers/url_helper.rb:786). It never
refuses. button_to
(actionview/lib/action_view/helpers/url_helper.rb:296-350) is the canonical
answer to "a button that runs an action": it wraps the button in its OWN form
carrying method, action, the hidden _method, and the CSRF token, so the
button is self-sufficient by construction. The transferable rule is that the
helper supplies what the transport needs and carries in a hidden field whatever
HTML cannot express, which is precisely the shape of WebJs's __webjs_action.

Remix. Remix v2's <Form> honours the authored encType, defaulting to
application/x-www-form-urlencoded
(/home/vivek/Documents/Projects/frameworks/remix-v2/docs/components/form.md:46-53),
and useSubmit accepts four encodings including text/plain
(docs/hooks/use-submit.md:73), which is to say the DOM-form path is restricted
to the two parseable encodings and text/plain exists only on the imperative
API. On the server, Remix 3 accepts only multipart/* and
application/x-www-form-urlencoded
(/home/vivek/Documents/Projects/frameworks/remix/packages/form-data-middleware/src/lib/form-data.ts:69-78),
matching looksLikeFormSubmission exactly.

Five rules distilled, and where WebJs lands.

  1. Bind the transport where you bind the action. React, Rails and this redesign
    all do it; WebJs today does not, and that is the bug.
  2. Resolve submitter over form with native precedence for method, action and
    enctype. Turbo does it; WebJs already does for method and action
    (getSubmitMethod at packages/core/src/router-client.js:610,
    getSubmitAction at :624) and does NOT for enctype, which is fixed here.
  3. Send what the author declared. Turbo and Remix both do; WebJs does not, and
    that is the second defect fixed here.
  4. Bail to the platform rather than throwing. Turbo's stance, adopted here for
    every cross-element case. This is the one place the redesign knowingly
    parts company with Turbo
    , and only for same-element contradictions, where
    there is no platform behaviour to fall back to: a .prop spelling of
    formmethod cannot be made to agree between a renderer that drops it and a
    browser that reflects it, so refusing is the only outcome that is not a
    progressive-enhancement bug.
  5. Carry what HTML cannot express in a hidden field or a name/value pair.
    Rails's _method, React's $ACTION_ID_, WebJs's __webjs_action. Unchanged.

Decisions settled, with what settled them

Do NOT emit formaction on a bound submitter. React emits
formAction="" (react-dom-server.browser.development.js:1447, from
customFields.action || "") so its button always targets the current document.
WebJs will not, for a reason already recorded in this repo: an empty URL in
action or formaction is an HTML conformance error, which is why
bindFormActionStartTag omits the attribute rather than emitting action=""
(packages/core/src/form-action.js:661-663) and why
test/scaffolds/scaffold-template-validation.test.js:222-244 bans the shape
outright with a dedicated guard. Consequence, stated so nobody discovers it
later: a bound submitter inside a form that declares its own action="/x"
submits to /x. That is native precedence honoured (the author supplied a
target at the form level and none on the button), and the action still RUNS
there, because the identity travels in the body and is dispatched by whatever
page route receives the POST. What differs is which page re-renders on a 422.
Leaving the form's action off, which is the ordinary shape, keeps the
submission on the current page.

Do NOT implement a text/plain encoder. Bail to the browser instead. Four
things settle it. The HTML specification itself calls the text/plain payload
not reliably interpretable by computer. looksLikeFormSubmission
(packages/server/src/form-dispatch.js:212) accepts only multipart and
urlencoded, so a text/plain POST is a bare 405 before its body is read. Remix
3's middleware makes the identical restriction. And Turbo, which enumerates
plain in FetchEnctype (src/http/fetch_request.js:40-44), never encodes it:
buildResourceAndBody falls through and sends FormData anyway, which is the
same JavaScript-on divergence WebJs has today. So the router gains a BAIL: an
effective enctype of text/plain on an unsafe method returns from onSubmit
without calling preventDefault, and the browser performs the native
submission. Both paths then behave identically, which is the actual requirement.

Normalize the enctype the way an enumerated attribute is normalized.
enctype has three keywords and an invalid-value default of
application/x-www-form-urlencoded, so enctype="nonsense" natively means
urlencoded and must be treated as such. Only an exact, ASCII-case-insensitive
match on text/plain bails, and only an exact match on multipart/form-data
sends FormData. Everything else is urlencoded.

formtarget on a bound submitter stays unrefused. React only warns about it.
WebJs does not refuse a target on a bound FORM either, so refusing
formtarget on the button would be asymmetric, and the router already bails on
any non-_self target (packages/core/src/router-client.js:579-582), so the
JavaScript-on and JavaScript-off paths already agree. Left alone deliberately.

Alternatives considered and rejected

  • Keep the detection approach of PR feat: resolve form-submitter boundness in webjs check and make the residual loud #1314. Rejected because a webjs check
    rule, a dev console error and two telemetry codes all report a failure that
    does not need to exist. Roughly 1900 lines of scanner, rule and test exist to
    describe a shape that four attributes make work.
  • Make the cannot-tell fallback refuse instead. Rejected on the grounds
    already recorded at packages/core/src/form-action.js:363-371. It rejects
    ordinary shapes, and at SSR a refused component is isolated, so production
    returns 200 with the button silently gone. Strictly worse than the bug.
  • Have SSR pass the real form scope into the component pass. Rejected. It
    makes the DSD pass depend on parsing its own emitted output for HTML
    structure, it cannot help the client at all, and it is unnecessary once the
    button needs nothing from the form.
  • Emit formaction="" for React parity. Rejected above on this repo's own
    conformance stance.
  • Encode text/plain on the fetch. Rejected above.
  • Refuse a bound submitter inside a form carrying a static action. Rejected
    because it is a cross-element rule, which reintroduces exactly the
    cannot-tell inference this change deletes, and because native precedence
    already gives the author's declared target.

Implementation plan

Step 0. Branch fresh off origin/main; PR #1314 is superseded

Recommendation, for the owner to confirm: supersede #1314 rather than merging
it.
Disposing of a PR is the owner's call, so the implementer does NOT run
gh pr close and proceeds on the new branch either way. The recommendation is
weighed against merging #1314 first and deleting the obsolete parts in a
follow-on PR, and that alternative loses on every axis. The
submitter-needs-bound-form rule is the bulk of #1314 (about 480 lines in
packages/server/src/check.js, about 420 in packages/server/src/js-scan.js,
and 1045 lines of test across two new files) and every line of it is obsoleted
here, so merging then deleting puts a large dead subsystem into main's history
for one PR cycle and turns the second PR into a confusing mass delete. Worse,
#1314 rewrote AGENTS.md invariant 12 and four skill references to TEACH the
detection model, and WebJs's end users are overwhelmingly AI agents reading
exactly those files, so merging would publish guidance the very next PR
contradicts. AGENTS.md's own standing rule settles the tie anyway: WebJs has no
users yet, so prefer a clean break over a shim.

Cut the branch with git worktree add -b feat/self-sufficient-form-submitter ../webjs-self-sufficient-submitter origin/main, then npm run worktree:link inside it.

Carry forward from feat/submitter-needs-bound-form (ae2c6607), by path.
Read each hunk with git show ae2c6607 -- <path> or gh pr diff 1314 --repo webjsdev/webjs.

Path What to take
packages/server/src/js-scan.js ONLY the matchClosingBrace context-stack rewrite (the function is at :638 on main). A ${ incremented a depth counter that was never decremented, so a class body containing a template hole was unmatchable. Latent today because every caller passes a masked source. Take the fix and its doc-comment note; leave trailingActionAttr, classifyActionHole and scanHtmlFormScopes behind.
packages/core/src/router-client.js The warnOnce level parameter and warnIfActionSubmissionCannotDeliver plus its one call site in onSubmit. Adapt the message text per step 6.
packages/server/src/form-dispatch.js The whole telemetry block: reportedFormFingerprints, FINGERPRINT_CAP, firstSighting, routeKeyOf, resetFormReportDedupe, reportFormActionMissing, reportFormSubmittedAsGet, and the reportFormActionMissing call at the bind-nothing 405.
packages/server/src/dev.js The reportFormSubmittedAsGet call inside the page-render GET branch (:2292 on main) and the added import.
examples/blog/modules/feedback/components/publish-button.ts The component, with its doc comment rewritten per step 8.
examples/blog/app/feedback/triage-split/page.ts The route, with its form changed from bound to UNBOUND per step 8.
test/e2e/e2e.test.mjs The /feedback/triage-split case, strengthened per Tests.
test/bun/form-action-dispatch.mjs The onError collector and the two telemetry assertions.
packages/server/test/routing/form-dispatch.test.js The telemetry tests.
packages/core/test/routing/browser/form-action-submit.test.js The two dev-guard tests, with the text/plain expectation adapted per Tests.
website/app/docs/deployment/page.ts The paragraph documenting the two err.code values.
.agents/skills/webjs/references/built-ins.md The onError diagnostics sentence, minus the sentence attributing the GET fingerprint to a cannot-tell submitter.
website/app/docs/suspense/page.ts The unrelated one-line accuracy fix about why .fallback is read inline. Independent and correct; take it.

Leave behind entirely. packages/server/src/check.js (rule registration and
checkSubmitterNeedsBoundForm), packages/server/test/check/submitter-needs-bound-form.test.js,
packages/server/test/scanner/html-form-scopes.test.js (except the single
matchClosingBrace case, relocated per Tests), the scanHtmlFormScopes and
classifyActionHole scanner, and every doc edit that teaches the detection
model. packages/server/src/check.js's form-action-not-a-get-action loop stays
exactly as main has it, since classifyActionHole was extracted only for the
deleted rule.

Both commit hooks fire on this change. require-docs-with-src.sh blocks a
commit staging public packages/*/src with no doc surface alongside, and
require-bun-parity-with-runtime-src.sh blocks runtime-sensitive source with no
test/bun/** test. Both are satisfied by the Docs and Tests sections below;
neither escape hatch is needed.

Owner decision required, and deliberately not an implementer task. How
#1314 is finally disposed of (closed unmerged, left open as a record, or merged
first) is the owner's call, which is why it appears here as a recommendation and
nowhere in the acceptance criteria. Whichever the owner picks, everything below
is done on the new branch and none of it depends on the answer.

Step 1. The submitter half of the binding, in packages/core/src/form-action.js

1a. Delete assertSubmitterFormIsBound (:354-384) entirely. Its premise is
gone. Remove the import from packages/core/src/render-server.js:8 and
packages/core/src/render-client.js:9.

1b. Delete assertBoundFormSubmitters (:985-1036) entirely, including its
long doc comment. Remove the import from packages/core/src/render-client.js:9.

1c. Narrow assertSubmitterSubmission (:446-513) to the bound case and
rename it assertSubmittableSubmitter,
for symmetry with assertSubmittableForm
(:625), which is the exact same rule one level up. It loses the opts
parameter entirely. Today it reads:

export function assertSubmitterSubmission(tag, formMethod, formEnctype, opts) {
  const bound = !!(opts && opts.bound);
  const retargeted = !!(opts && opts.retargeted);
  ...
  if (isDialog) {
    if (!bound) return;
    throw new Error(...);
  }
  if (retargeted) return;
  if (method != null && !/^post$/i.test(method)) { throw ... }
  if (enctype != null && !PARSEABLE_ENCTYPES.has(enctype.toLowerCase())) { throw ... }
}

After this change it reads:

/**
 * The submitter twin of `assertSubmittableForm`: refuse a BOUND submitter whose
 * own `formmethod` / `formenctype` contradicts the action it binds.
 *
 * Only a bound submitter reaches here (#1307). A PLAIN submitter's own
 * `formmethod="get"` inside a bound form is a legal native override that means
 * exactly what the author typed, so the renderer leaves it alone and the
 * dev-time client guard reports at submit time if the submission then cannot
 * carry an identity it is holding.
 *
 * @param {string} tag lowercased owner tag, for the message
 * @param {string | null} formMethod the submitter's `formmethod`, or null
 * @param {string | null} formEnctype the submitter's `formenctype`, or null
 */
export function assertSubmittableSubmitter(tag, formMethod, formEnctype) {
  const method = formMethod == null ? null : String(formMethod);
  const enctype = formEnctype == null ? null : String(formEnctype);
  if (method != null && /^dialog$/i.test(method)) {
    throw new Error(
      `[webjs] formaction=\${action} on <${tag}> cannot be combined with `
      + `formmethod="dialog", which dismisses a <dialog> instead of submitting, `
      + `so the bound action would never run. Drop one of the two.`,
    );
  }
  // ... the untrimmed non-post refusal and the unparseable-enctype refusal,
  // both unchanged in text apart from naming the SUBMITTER's own attributes
  // rather than the enclosing form's.
}

The untrimmed comparison comment at :489-494 stays verbatim: it is the reason
formmethod=" post " is refused rather than accepted, and that reason is
unchanged.

1d. Add resolveBoundSubmitterAttrs, mirroring resolveBoundFormAttrs
(:779).
Place it immediately after that function so the pair reads together.

/**
 * THE decision both renderers make about a bound SUBMITTER, in one place, the
 * twin of `resolveBoundFormAttrs` (#1307).
 *
 * Sharing it is the point, for the same reason the form version is shared: SSR
 * reaches it with the attributes parsed off the start tag it just emitted, the
 * client reaches it with the values its template would have emitted, and the
 * predicate is literally the same function.
 *
 * Returns what to INJECT for each attribute, or null to leave the author's own
 * value alone. Throws when a value cannot submit at all.
 *
 * @param {string | typeof ABSENT | null} formMethod
 * @param {string | typeof ABSENT | null} formEnctype
 * @returns {{ formMethod: string | null, formEnctype: string | null }}
 */
export function resolveBoundSubmitterAttrs(formMethod, formEnctype) {
  const hasMethod = formMethod !== ABSENT && formMethod != null;
  const hasEnctype = formEnctype !== ABSENT && formEnctype != null;
  assertSubmittableSubmitter('button', hasMethod ? formMethod : null, hasEnctype ? formEnctype : null);
  return {
    formMethod: hasMethod ? null : 'post',
    formEnctype: hasEnctype ? null : 'multipart/form-data',
  };
}

Take the owner tag as a first parameter rather than hardcoding 'button', so
the message names the real tag.

1e. Simplify assertSubmitterStartTag (:944). With Part B deleted it is
only ever called for a bound submitter, so shape.bound and the entire
else if (!isSubmitterType(...)) return; branch at :970-974 go, along with the
bound argument threaded through assertConvergentSubmitter (:410), whose
bound parameter and its unbound filter branch (:420-422) also go: it now
always uses isSubmitterReflectedProp.

1f. Add bindSubmitterStartTag, mirroring bindFormActionStartTag (:677).

/**
 * Rewrite a bound submitter's START TAG (#1307), the twin of
 * `bindFormActionStartTag`.
 *
 * The identity was already written at the hole, in place of the `formaction=`
 * the author spelled. What is added here is the rest of the submission, because
 * only at the `>` is the whole start tag known and an attribute the author wrote
 * AFTER the binding still counts:
 *   - `formmethod="post"` when absent, so the submission carries a body
 *     whatever the enclosing form declares.
 *   - `formenctype="multipart/form-data"` when absent, so a file input works on
 *     the no-JS path.
 *
 * No `formaction` is emitted. An empty URL is a conformance error, the same
 * reason `bindFormActionStartTag` omits `action` rather than writing
 * `action=""`, so the submission targets whatever the enclosing form targets,
 * which for the ordinary unbound-and-action-less form is the page's own url.
 *
 * @param {string} startTag the emitted start tag, ending in `>`
 * @param {string} tag lowercased owner tag
 * @param {{ duplicateAction?: boolean, propAttrs?: string[] }} shape
 * @returns {string} the rewritten start tag
 */
export function bindSubmitterStartTag(startTag, tag, shape) { ... }

It calls assertSubmitterStartTag first, then parses with parseStartTagAttrs
(:1112), resolves with resolveBoundSubmitterAttrs using the ABSENT
sentinel (:760) for a missing attribute, and splices the injections before the
closing > exactly as bindFormActionStartTag does at :686-693.

1g. Export applyResolvedAttr (:871) so reconcileSubmitterAction can use
it, and rename its first JSDoc parameter from form to el. Its behaviour is
unchanged and is already correct for any element.

1h. Add releaseSubmitterAttrs, the submitter twin of releaseFormAction's
attribute half (:1073).
When a submitter's action hole stops resolving to an
action, the formmethod / formenctype the framework supplied must come off
with the identity, or a released button keeps attributes SSR does not emit for
the same template. Recompute rather than remember, exactly as the form version
does: an attribute is the framework's precisely when the template supplies
nothing for it on this pass.

Step 2. Emit on both SSR state machines, in packages/core/src/render-server.js

2a. The buffered machine. closeBoundFormTag at :237-247 reads today:

    if (submitterTag != null) {
      assertSubmitterStartTag(out.slice(tagStart), submitterTag, { bound: true, duplicateAction, propAttrs: submitterProps });
    } else if (formScope === 'bound' && !isCloseTag && (currentTag === 'button' || currentTag === 'input')) {
      // Part B (#1207): an ordinary submitter inside a bound form ...
      assertSubmitterStartTag(out.slice(tagStart), currentTag, { bound: false, propAttrs: submitterProps });
    }

After this change it reads:

    if (submitterTag != null) {
      // #1307: the submitter carries its whole submission, so `formmethod` and
      // the enctype are injected here rather than inherited from a form the
      // renderer may not even be able to see. Symmetric with the `>` rewrite
      // `bindFormActionStartTag` does for a bound form.
      out = out.slice(0, tagStart)
        + bindSubmitterStartTag(out.slice(tagStart), submitterTag, { duplicateAction, propAttrs: submitterProps });
    }

The Part B else if is deleted with it.

2b. Delete the boundness check at :523, which reads
if (formScope !== 'unknown') assertSubmitterFormIsBound(formScope === 'bound', currentTag);,
together with its four-line comment at :519-522. The identity injection two
lines below (out = out.slice(0, attrStart) + \name="${FORM_ACTION_FIELD}" value="${escapeAttr(subId)}"``)
is unchanged.

2c. The streamed machine, identically. :2041-2045 is the byte-identical
twin of 2a and :2253 of 2b. Both machines must change or #1154 repeats,
which is exactly the failure assertSubmitterStartTag's doc comment
(packages/core/src/form-action.js:922-927) records.

2d. Delete formScope end to end. Remove the parameter from render
(:69), renderTemplate (:165), streamRender (:1894) and
streamTemplate (:1987) and from every recursive call that threads it; remove
formScope from the SuspenseCtx typedef (:34) and from the renderToString
options typedef (:46); remove the ctx.pending.push({ id, promise, formScope })
entries and the batch.map(async ({ id, promise, formScope }) consumer; remove
the formScope = 'bound' / 'unbound' / close-tag transitions at :232,
:236, :298 and their streamed twins at :2035, :2039, :2087; and remove
the 'unknown' seeding in the injectDSD component pass. A grep -c formScope
on the file must go from 56 to 0.

2e. packages/server/src/ssr.js. Drop formScope from the pending typedef
at :2112 and from the resume call at :2175.

Step 3. The same emission client side, in packages/core/src/render-client.js

3a. Generalize buildFormActionRecord (:652) to capture the submitter's own
submission attributes.
The record already tracks methodParts, enctypeParts,
staticMethod and staticEnctype, but the capture loop at :701 filters
if (name !== 'method' && name !== 'enctype') continue;, so a submitter's arrays
are always empty. Choose the attribute pair by isForm:

  const methodAttr = isForm ? 'method' : 'formmethod';
  const enctypeAttr = isForm ? 'enctype' : 'formenctype';

and use them in the filter and in the two getAttribute reads at :731-732.
Everything downstream (effectiveFormAttr at :1129, the ABSENT sentinel,
the attr-mixed statics handling) is already correct and needs no change.

3b. Rewrite the tail of reconcileSubmitterAction (:869). Delete the
enclosing-form block at :895-911 (the enclosingForm call, the
submitterActionBindings gate, the isFormBound query and the
assertSubmitterFormIsBound call) together with its comment. Replace the
assertSubmitterSubmission(...) call at :912-917 and the three writes at
:918-920 with:

  const resolved = resolveBoundSubmitterAttrs(
    el.localName,
    effectiveFormAttr(rec.methodParts, rec.staticMethod, values),
    effectiveFormAttr(rec.enctypeParts, rec.staticEnctype, values),
  );
  el.removeAttribute('formaction');
  applyResolvedAttr(el, 'formmethod', authoredFormMethod, resolved.formMethod);
  applyResolvedAttr(el, 'formenctype', authoredFormEnctype, resolved.formEnctype);
  el.setAttribute('name', FORM_ACTION_FIELD);
  el.setAttribute('value', id);
  submitterActionBindings.set(el, id);

reconcileSubmitterAction needs values to compute those, so thread it in the
way reconcileFormAction already receives its two resolved attributes from
reconcileFormActions (:777-780). Resolving them in reconcileFormActions
and passing the pair down keeps reconcileSubmitterAction's signature shaped
like its form sibling; either is fine, but do it once and do it the same way for
both.

3c. Delete the now-unreachable bookkeeping. enclosingForm (:823-844),
formActionCandidates (declared :114, written :1189-1192, read only at
:909), the boundForms array (:758, :783) and the
for (const form of boundForms) assertBoundFormSubmitters(form); sweep (:801).
Keep the two-pass for (const pass of [true, false]) ordering at :759. Its
stated motivation was the boundness read, which is gone, but it also makes a
form's release run before its submitters reconcile, and reordering it is not
part of this change.

3d. releaseSubmitterAction (:815) takes back the framework's
formmethod / formenctype alongside the identity, via releaseSubmitterAttrs
from step 1h.

Step 4. Honour the authored enctype, in packages/core/src/router-client.js

4a. Add the normalizer, beside NON_HTML_EXTENSIONS:

/**
 * The three enctype keywords, plus the normalization a browser applies. Both
 * the missing-value and the invalid-value default of the `enctype` enumerated
 * attribute are `application/x-www-form-urlencoded`, so `enctype="nonsense"`
 * really does mean urlencoded and must be sent as such.
 *
 * @param {string | null | undefined} raw
 * @returns {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'}
 */
function normalizeEnctype(raw) {
  const v = String(raw || '').trim().toLowerCase();
  if (v === 'multipart/form-data') return 'multipart/form-data';
  if (v === 'text/plain') return 'text/plain';
  return 'application/x-www-form-urlencoded';
}

/**
 * Effective enctype with native precedence: the submitter's `formenctype` wins
 * over the form's `enctype`, exactly as `getSubmitMethod` resolves the method.
 * Turbo resolves it the same way (core/drive/form_submission.js:271).
 *
 * @param {HTMLFormElement} form
 * @param {HTMLElement | null} submitter
 */
function getSubmitEnctype(form, submitter) {
  return normalizeEnctype(
    (submitter && submitter.getAttribute('formenctype')) || form.getAttribute('enctype'),
  );
}

4b. Add the bail and thread the enctype through onSubmit (:564). The
current ladder is: router disabled, defaultPrevented, target is not a form,
data-no-router on the form, data-no-router on the submitter, a
formtarget / target that is not _self, method === 'dialog', an
unparseable action url, cross-origin, a non-HTML file extension. That ladder
matches Turbo's own (src/observers/form_submit_observer.js:30-46 plus
Session#willSubmitForm, src/core/session.js:296) on every rung Turbo has,
and WebJs is stricter on the target rung (Turbo routes a target that names no
iframe; WebJs bails on anything that is not _self). The one rung Turbo LACKS
is the enctype, and it is added here:

  const enctype = getSubmitEnctype(form, submitter);
  // `text/plain` is a legal native encoding the server cannot parse, and there
  // is no honest way to send it over `fetch` and have the response mean
  // anything. Bail to the browser rather than silently sending multipart, which
  // is what made the same form behave one way with JS and another way without
  // it. Turbo enumerates the encoding and then sends FormData anyway
  // (http/fetch_request.js:215), which is the divergence this avoids. A safe
  // method ignores enctype entirely, per the HTML form-submission algorithm.
  const isSafeMethod = method === 'get' || method === 'head';
  if (!isSafeMethod && enctype === 'text/plain') return;

Place it immediately after the method === 'dialog' check.

4c. Encode the body per the effective enctype. Add beside
buildSubmitFormData (:640):

/**
 * Encode a submission body the way the declared enctype says to (#1307).
 *
 * `multipart/form-data` sends the `FormData` and lets `fetch` write the
 * boundary. `application/x-www-form-urlencoded`, which is the HTML default and
 * therefore what an ordinary `<form method="post">` means, sends
 * `URLSearchParams`, and `fetch` writes the content type. A `File` entry
 * serializes as its NAME, which is what the platform's urlencoded serializer
 * does. (Turbo drops file entries entirely here,
 * http/fetch_request.js:229, which loses a field the no-JS path sends.)
 *
 * @param {FormData} formData
 * @param {'application/x-www-form-urlencoded' | 'multipart/form-data'} enctype
 * @returns {FormData | URLSearchParams}
 */
function encodeSubmitBody(formData, enctype) {
  if (enctype === 'multipart/form-data') return formData;
  const params = new URLSearchParams();
  for (const [k, v] of formData) params.append(k, typeof v === 'string' ? v : v.name);
  return params;
}

Call it in onSubmit between the body build and performSubmission, and pass
the result through. performSubmission (:1440) needs no signature change
beyond accepting the union type: its safe-method promotion at :1448-1456
already iterates entries with typeof v === 'string' ? v : v.name, which works
for both, and fetchAndApply (:2254) assigns init.body = body at :2319
with no explicit content type, which is correct for both. Update the two JSDoc
@param {FormData} body lines to the union.

A bound form is unaffected: it carries an explicit
enctype="multipart/form-data", and after step 1 a bound submitter carries
formenctype="multipart/form-data", so both resolve to multipart exactly as
today.

Step 5. The carried matchClosingBrace fix, in packages/server/src/js-scan.js

Take the context-stack rewrite of matchClosingBrace (:638 on main) from
ae2c6607 verbatim. The old body kept one flat depth and one str flag, and
a ${ inside a template literal incremented depth with nothing ever
decrementing it. The new body keeps a stack of { tpl, depth } frames, so a
template hole pushes a code frame and its } pops back to the template. It
ships as its own commit, ahead of the rest, since it is an independent defect
fix in a shared scanner.

Step 6. The dev-time client guard, carried and adapted

Take warnIfActionSubmissionCannotDeliver and the warnOnce level parameter
from ae2c6607. Two adaptations.

The non-POST branch is unchanged in substance and is now MORE reachable, since a
plain <button formmethod="get"> inside a bound form is legal after this change
and is exactly the shape that loses the identity to the query string.

The text/plain branch's message must change. It currently says the shape
breaks only the no-JS path "because with JS the router posts FormData and
ignores the attribute entirely". After step 4b that is no longer true: the
router bails and the browser performs the same native text/plain submission,
so BOTH paths are a 405. Rewrite the message to say so, and keep the
text/plain-only test (not the PARSEABLE_ENCTYPES allowlist), because
enctype="nonsense" normalizes to urlencoded and submits fine.

Step 7. Telemetry, carried unchanged in substance

Take the form-dispatch.js block and the dev.js call site from ae2c6607.
Both codes survive the redesign with clearer meanings.

  • WEBJS_FORM_ACTION_MISSING is unchanged: a parseable form body carrying no
    identity, answered with a 405.
  • WEBJS_FORM_SUBMITTED_AS_GET no longer fires for a cannot-tell submitter,
    because there is no longer such a thing. It now fires for the shape this
    change deliberately legalizes, namely a plain submitter whose own
    formmethod="get" promotes a bound form's hidden identity field into the
    query string. Update the function's doc comment to say that instead of the
    old cause.

Both stay detect-only, both keep the per-process dedupe keyed on code, method
and matched ROUTE with a 256-entry cap, and both keep carrying field NAMES and
never values.

Step 8. Dogfood: make /feedback/triage-split the shape the change fixes

Carry both files, then change the page's form from bound to UNBOUND, which is
the whole point. examples/blog/app/feedback/triage-split/page.ts currently
renders <form action=${saveDraft} class="flex flex-col gap-3">. It becomes a
bare <form class="flex flex-col gap-3"> with NO action and NO method, and both
buttons bind their own action:

        <div class="flex gap-2">
          <button id="save" formaction=${saveDraft} class="border rounded px-3 py-1">Save draft</button>
          <publish-button></publish-button>
        </div>

That is a completely unbound, method-less form with two self-sufficient
submitters, one written inline and one rendered by a component, which is exactly
the shape SSR could never resolve and which now simply works.
/feedback/triage keeps the bound-form-plus-bound-submitter shape, so both are
covered. Rewrite both doc comments: they currently explain the cannot-tell
fallback and name the deleted check rule.

Tests

npm test does NOT run browser, e2e or Bun. Run npm run test:browser,
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs and
node scripts/run-bun-tests.js yourself and report the results.

Six local failures in this repo are environmental (three elision
differential, two Bun listener, one blog HTTP) and reproduce with the source
reverted. Do not chase them.

The headline test

test/e2e/e2e.test.mjs, inside the
E2E: form actions (no-JS + enhanced) block at :3108. With
setJavaScriptEnabled(false), load /feedback/triage-split, assert the served
markup gives the component-rendered Publish button
name="__webjs_action", a <hash>/publishDraft value, formmethod="post" and
formenctype="multipart/form-data", then perform a NATIVE submit and assert the
action actually RAN (the PRG redirect lands and the effect is visible). That one
assertion is the whole redesign. The existing case at :3429 asserts only that
the identity is carried; extend it to assert the run.

The counterfactual. Delete the two injected attributes from
bindSubmitterStartTag and this test must go red, because the unbound,
method-less form falls back to a GET and nothing runs. State that in the test's
comment so a later reader knows what it is pinning.

Unit, renderer (SSR)

packages/core/test/rendering/form-action-binding.test.js (58 tests). Change:

  • :262-264 asserted a bare <button formaction=${fn}> throws
    "requires the enclosing to also be bound". It now RENDERS, and asserts
    the four emitted attributes.
  • :267 (bound form plus bound submitter) keeps passing, and gains the two new
    attributes in its expected markup.
  • :349-380 keeps every submitter refusal that survives, and keeps the
    formmethod="get" / formmethod="PATCH" / formenctype="text/plain" rows,
    which are BOUND-submitter contradictions and still throw.
  • :555 (bound plus formmethod="dialog") unchanged.
  • :562-571 ("a submitter retargeted by a static formaction keeps its own
    method") is a PLAIN submitter case that Part B used to reason about. It still
    renders untouched, so keep it and update its comment.
  • :643-720 is the cannot-tell block. Every refusal in it inverts to an
    acceptance: no form at all, a sibling form, and <form method="post"> all now
    render the bound submitter. :734-751 ("the 'unbound' state is what refuses
    inside a COMPONENT's own form") is deleted with the state it names.
  • ADD: a bound submitter whose template supplies formmethod="post" explicitly
    keeps the author's value and gains only the enctype; the same for
    formenctype="multipart/form-data".
  • ADD: streamed twins of the acceptance rows under
    drain(renderToStream(tpl, { ssr: false })), matching the existing pattern at
    :194 and :236, so a change that touches only one state machine goes red.
  • ADD: a plain <button formmethod="get"> inside a bound form renders
    untouched, which is the Part B deletion pinned as behaviour rather than left
    implicit.

packages/core/test/rendering/form-action-binding-client.test.js (55 tests),
the client twin, same list. Specifically :512 (submitter non-POST formmethod)
stays for BOUND submitters, :649-656 ("a submitter whose enclosing form is
resolvable and UNBOUND is still refused") inverts to an acceptance, :665
(unparseable submitter enctype inside a bound form) becomes bound-only, :678
("dialog and retargeted submitters left alone") stays, and :834 ("the
enclosing-form verdict does not change between renders") is deleted with the
verdict.

packages/core/test/rendering/form-action-attr-guard.test.js (37) and
form-action-attr-guard-client.test.js (20) pin the source-leak guard, which is
untouched. Run as regression; expect no edits.

Unit, differential parity (browser)

packages/core/test/rendering/browser/ssr-client-parity.test.js is the file a
matrix change most easily breaks silently. Three tables:

  • ACCEPTS (:417) gains the rows that moved out of the other two: a bound
    submitter with no form at all, inside an unbound form, and inside
    <form method="post">. Each must render byte-identically on both renderers,
    which is what proves SSR and the client inject the same two attributes.
  • REFUSES (:480) loses three rows: 'submitter inside an unbound form',
    'unparseable submitter enctype' and 'non-POST submitter method' (the last
    two are the Part B plain-submitter rows). It KEEPS every bound-submitter and
    every form-level row. Add the bound-submitter equivalents of the two Part B
    rows so the refusal is still pinned where it survives.
  • SSR_ONLY_REFUSES (:509-527) becomes EMPTY and is DELETED, along with its
    long explanatory comment. That comment documents "the ONE asymmetry in Support formaction=${action} for per-button actions, and refuse unparseable submitter enctype #1207",
    and this change removes it. Replace it with two sentences in the file header
    recording that the asymmetry existed and why it is gone, so nobody
    reintroduces it.

packages/core/test/rendering/browser/form-action-guard.test.js (13) asserts
real-DOM submittability. :223 ("a bound submitter overrides the form action,
last-wins in DOM order") and :251 and :275 gain assertions for the two new
attributes. :306 ("formmethod="dialog" survives on a plain submitter inside a
bound form") stays and is now covered by a broader rule rather than a carve-out.

Unit, client router

packages/core/test/routing/router-client.test.js. :2807-2828
(getSubmitMethod) and :2830-2848 (getSubmitAction) are unchanged and are
the model for the new cases. ADD a getSubmitEnctype block asserting native
precedence (submitter over form), the missing-value default, and the
invalid-value default (enctype="nonsense" resolves to urlencoded). ADD an
onSubmit case asserting the text/plain bail leaves defaultPrevented false,
beside the existing dialog bail at :2887. ADD an encodeSubmitBody case
asserting a File entry serializes as its name under urlencoded and survives
whole under multipart.

packages/core/test/routing/browser/form-action-submit.test.js (6). :195
("a submission that cannot deliver its action logs once, and still submits")
carries over unchanged. :253 pins the enctype warning to text/plain only;
keep the pinning and update the assertion about what happens with JavaScript on,
since the router now bails instead of posting FormData. ADD the real proof of
the enctype fix: a <form method="post"> with no enctype submits a urlencoded
body over fetch, and one with enctype="multipart/form-data" submits
multipart, read off the stubbed fetch init.

Unit, server

packages/server/test/routing/form-dispatch.test.js. :204 (last-wins
identity) and :522 (a GET-declared action) are unchanged. The two #1307
diagnostics tests carry over from ae2c6607; update the
WEBJS_FORM_SUBMITTED_AS_GET fixture to the shape that now produces it (a bound
form plus a plain formmethod="get" submitter) rather than the deleted
cannot-tell shape.

packages/server/test/testing/submit-form.test.js:143 asserts an unbound form
is reported rather than silently submitted. Unchanged, and worth re-running:
submitForm builds its own body, so it is unaffected by the router change.

packages/server/test/elision/form-action-elision.test.js and
packages/server/test/check/form-action-not-a-get-action.test.js are unchanged
and are regression only. form-action-not-a-get-action in particular must stay
byte-identical, since classifyActionHole is not being extracted here.

Deleted, because their subject never reaches main:
packages/server/test/check/submitter-needs-bound-form.test.js (32 tests) and
packages/server/test/scanner/html-form-scopes.test.js (18 tests). Relocate ONE
case from the latter, matchClosingBrace walks past a template hole at :98,
into a new packages/server/test/scanner/match-closing-brace.test.js, following
the sibling naming in that directory (mask-comments.test.js,
redact-to-placeholders.test.js). Give it a counterfactual too: the old flat
depth counter must fail the case.

Bun parity (mandatory)

SSR and action dispatch are both runtime-sensitive surfaces, so this is part of
the task.

  • test/bun/form-action-submitter-parity.test.mjs is the primary file. :26
    ("formaction=${fn} on unbound button throws actionable refusal") inverts to an
    acceptance asserting the two new attributes. :48-55 (the submitter refusal
    table) keeps every bound-submitter row. :94 ("outside a bound form" holding
    formenctype="text/plain" through untouched) stays. ADD the headline
    cross-runtime assertion: a bound submitter inside a bare <form> emits
    identical markup on Node and on Bun, attributes included.
  • test/bun/form-action-guard.mjs plus .test.mjs. :169 asserts
    method="post" is forced on the form; add the submitter twin. The leak-guard
    table at :83-101 is unchanged.
  • test/bun/form-action-dispatch.mjs plus .test.mjs. :76 asserts the form's
    forced method="post"; :173 already covers a bound submitter under an
    unbound form, and its expectation flips from "the shape the diagnostics
    describe" to "the submission runs the action". The onError collector and the
    two telemetry assertions carry over from ae2c6607.

Layers that do NOT apply, and why

  • webjs check. No rule is added and none is changed. The rule this issue
    once proposed is deleted with its premise.
  • webjs doctor. Nothing here is a project-health check, so no
    webjs.doctor.gate code is added.
  • Smoke (test/examples/*/smoke/*). The blog route this adds is covered by
    the e2e that motivates it; a smoke test would only re-assert that it boots.
  • Config schema and type-drift. No webjs.* key changes, so WebjsConfig,
    the JSON Schema and the reader lockstep are untouched.
  • test/scaffolds/scaffold-template-validation.test.js:244. The
    empty-action guard scans SCAFFOLD SOURCE, not renderer output, and this change
    emits no empty attribute, so it stays green untouched. Re-run it as the
    regression proof of the no-formaction="" decision.

Docs

Every surface below states a rule this change falsifies. Invoke the
webjs-doc-sync skill. README.md has zero formaction hits, confirmed, and
needs no edit.

The one that matters most.
.agents/skills/webjs/references/muscle-memory-gotchas.md is the file an agent
reads first, and WebJs's end users are overwhelmingly AI agents building apps.
The refusal table at :98-106 is where the new rule has to read cleanly.

  • :99 currently reads that formaction=${fn} inside an UNBOUND form the
    renderer can see is refused, "because method="post" and the enctype are
    forced on the FORM's start tag, which SSR has already emitted by the time it
    reaches the button". DELETE the row. Its whole justification is gone.
  • :100 currently reads that the same shape inside a form the renderer CANNOT
    see binds anyway, and points at submitter-needs-bound-form. DELETE the row.
  • Replace both with ONE row whose answer is "no, it BINDS, wherever it is",
    reading: a bound submitter carries its whole submission. The renderer emits
    formmethod="post" and formenctype="multipart/form-data" on the button
    itself alongside the identity, so it works inside a bound form, an unbound
    form, a method="get" form, or a form with no method at all. It has no
    requirement on its enclosing form.
  • :104-106 (the Part B rows for a PLAIN submitter's formmethod /
    formenctype inside a bound form) invert from refused to allowed, with the
    reason: a native override means what the author typed, and a submission that
    then cannot carry the identity is reported by the dev-time console error
    rather than refused at render.
  • :101-103 (own name / value, non-submit control, form="other") are
    unchanged.
  • :55, :63, :129, :140 and :144 are prose around the table. :129
    shows the component-in-a-form example and :140-144 is the whole
    "run webjs check and it catches this for you" section, which is deleted
    outright: the section describes a rule that no longer exists and a failure
    that no longer happens. Replace it with a short paragraph saying the shape now
    simply works, and keep one sentence on the surviving diagnostic, namely the
    dev console error when a submission holds an identity it cannot deliver.
  • :85 mentions "unsupported formaction= shapes" generically and stays.

AGENTS.md invariant 12 (:486). The single longest edit. Delete, from the
"Refused on a submitter" list, the clause "an enclosing form that is not itself
bound (method and the enctype are forced on the form's start tag, too late to
add from the button)". Delete the entire "Refused on ANY submitter inside a
bound form, whether or not it binds" sentence and its two carve-outs. Delete the
whole "Boundness is BEST EFFORT in both renderers" passage and everything after
it, including the webjs check paragraph, the residual paragraph and the
telemetry paragraph, which were added on feat/submitter-needs-bound-form and
never reach main. In their place, state the new rule in one sentence: the
renderer supplies submission attributes at the level where the action is bound
and never overrides what the author wrote at that same level, so a bound form
gains method="post" plus enctype="multipart/form-data" and a bound submitter
gains formmethod="post" plus formenctype="multipart/form-data", which makes
the submitter self-sufficient inside any form. Add the one consequence: no
formaction is emitted (an empty url is a conformance error), so the
submission targets whatever the enclosing form targets. Keep the sentences about
?method=${false} versus method=${null}, the inert encoding= attribute, and
the bare <form method="post"> 405, all of which stay true.

AGENTS.md html hole table (:240). The action= / formaction= row
mentions "(#1155, #1207: ... submitters must be actual submit controls and
cannot carry their own name, value, form, or static formaction
attributes ...)". Add #1307 to the issue list and one clause noting the button
also receives its own formmethod and formenctype.

.agents/skills/webjs/SKILL.md.

  • :108 is invariant 12's condensed twin and needs the same edit: drop
    "inside a bound form", drop "formmethod="get" or an unparseable
    formenctype on ANY submitter in a bound form", drop the whole
    "The ONE shape that does not throw" sentence and the submitter-needs-bound-form
    reference, and add that the submitter carries its own formmethod and
    enctype.
  • :238 (a submitter must be a submit control and cannot carry its own
    name / value) is unchanged.
  • :240 is the "putting a formaction=${fn} submitter in a COMPONENT and
    forgetting to bind the <form>" gotcha. DELETE it. It is no longer a gotcha.

.agents/skills/webjs/references/data-and-actions.md. :138 and the
submitter bullet at :149. The bullet currently says "Bind the enclosing
form
, because method="post" and the enctype are supplied on the form's start
tag and a per-button action cannot retrofit them", then explains the cannot-tell
fallback and the check rule. Replace the whole bullet: a bound submitter is
self-sufficient, the enclosing form needs no binding, and each button in a form
may bind a different action independently.

.agents/skills/webjs/references/routing-and-pages.md. :128 and :182.
:182 says formaction=${fn} "is supported only on a <button> inside a bound
form". Drop "inside a bound form"; keep the rest of the refusal list, which
survives.

.agents/skills/webjs/references/optimistic-ui.md:105 says per-button
bindings go "inside a bound form". Drop that qualifier.

.agents/skills/webjs/references/built-ins.md:236 carries the onError
diagnostics sentence from ae2c6607. Take it, minus the clause attributing
WEBJS_FORM_SUBMITTED_AS_GET to a cannot-tell submitter, replaced by the shape
that now produces it.

packages/core/AGENTS.md:41 and packages/server/AGENTS.md:40 state the
form-action module's contract. Both need the submitter-supplies-its-own-submission
rule and the removal of the boundness requirement.
packages/server/AGENTS.md:88 documents js-scan.js; it needs only the
matchClosingBrace context-stack note from ae2c6607, with the
scanHtmlFormScopes and classifyActionHole sentences dropped.

Docs site.

  • website/app/docs/progressive-enhancement/page.ts. :127 and :172 (a form
    binding nothing gets a 405) stay true for a hand-written
    <form method="post">. :176 is the whole "A per-button action needs its
    enclosing form bound" paragraph and is REWRITTEN to say the opposite, with the
    reason. :180 is the "a button's own formmethod / formenctype can defeat
    the form" paragraph; rewrite it to say a bound button carries its own, and a
    PLAIN button's override is honoured as native, and note that the client router
    now sends the declared enctype so the two paths agree byte for byte.
  • website/app/docs/server-actions/page.ts. :430 and :475-476 describe the
    submitter rules; drop "inside a form that is itself bound". :478 is the
    entire "The enclosing form has to be bound, and that is the one requirement
    the renderer cannot always enforce" paragraph. DELETE it and replace with the
    self-sufficiency rule.
  • website/app/docs/troubleshooting/page.ts. :50 mentions formaction
    spelling and stays. :58-60 is the symptom entry "clicking a
    formaction=${action} button reloads the same page with a 200 and nothing was
    written". DELETE the entry: the symptom is fixed, and a troubleshooting page
    keyed by symptom must not list one that can no longer occur. :62-65 is the
    submitter formmethod / formenctype entry; rewrite it to cover the one
    residual, a PLAIN submitter's own override defeating the form's binding, and
    name the dev console error as the way to see it.
  • website/app/docs/ssr/page.ts:168 and
    website/app/docs/components/page.ts:652 both say a function under action=
    or formaction= is never serialized. Unchanged, verified.
  • website/app/docs/deployment/page.ts:202 takes the two err.code entries
    from ae2c6607, with the same correction to what produces the GET one.
  • website/app/docs/file-storage/page.ts:65 describes the multipart enctype the
    framework emits on a bound form. Add that a bound submitter now emits it too,
    so an upload works from a per-button action.

Scaffold.

  • packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts:15-25
    explains the submitter rules in a comment. Do NOT take ae2c6607's addition
    here, which teaches the deleted rule. Instead amend the existing comment at
    :23 ("the enclosing form has to be bound too") to the new rule.
  • packages/cli/templates/gallery/app/features/file-storage/page.ts:3 and
    packages/cli/templates/gallery/modules/file-storage/actions/store-upload.server.ts:16-17
    both say the framework emits the multipart enctype on the form. Add the
    submitter half in one clause each.
  • packages/cli/lib/create.js copies the repo-root .agents/skills/webjs/ into
    a generated app, so the skill edits above are the scaffold's copy too. There
    is no second file and none should be created.

Acceptance criteria

  • <form><button formaction=${fn}>Go</button></form>, with NO action and
    NO method on the form, runs the action end to end with JavaScript
    DISABLED, proven by an e2e against examples/blog
  • SSR emits name="__webjs_action", a value identity, formmethod="post"
    and formenctype="multipart/form-data" on a bound submitter, from BOTH
    state machines (renderToString and renderToStream(v, { ssr: false }))
  • The client renderer produces byte-identical markup for the same template,
    asserted by the differential parity suite
  • The renderer never emits a formaction attribute for a bound submitter,
    and test/scaffolds/scaffold-template-validation.test.js stays green
  • A bound submitter whose template supplies its own formmethod="post" or
    formenctype="multipart/form-data" keeps the author's value and gains
    only the one it did not supply
  • assertSubmitterFormIsBound, assertBoundFormSubmitters, enclosingForm
    and formActionCandidates are gone, and grep -c formScope over
    packages/core/src/render-server.js returns 0
  • Every same-element refusal still fires: a bound form's method="get" and
    unparseable enctype, a bound submitter's non-post formmethod,
    unparseable formenctype and formmethod="dialog", non-submit controls,
    <input type="image">, <input type="submit">, an own name / value /
    form / static formaction, every .prop spelling on a bound form or a
    bound submitter, a quoted action="${fn}", action=${fn} off a <form>,
    a second action hole, and a plain action="/url" beside a bound hole
  • A PLAIN, non-binding submitter carrying formmethod="get" or
    formenctype="text/plain" inside a bound form RENDERS untouched
  • The client router sends application/x-www-form-urlencoded for a form
    that declares it or declares nothing, and multipart/form-data only when
    declared, so the JavaScript-on and JavaScript-off bodies match
  • A File entry under urlencoded is sent as its NAME, matching the platform
  • enctype="text/plain" on an unsafe method bails to the native browser
    submission, with defaultPrevented left false
  • enctype="nonsense" is treated as urlencoded, per the enumerated
    attribute's invalid-value default
  • The dev console error still fires once for a submission carrying an
    identity it cannot deliver, and nothing is logged when
    NODE_ENV=production
  • WEBJS_FORM_ACTION_MISSING and WEBJS_FORM_SUBMITTED_AS_GET still reach
    onError, detect-only, deduplicated per process on code plus method plus
    matched route, carrying field names and never values
  • matchClosingBrace handles a template hole, with a counterfactual that
    fails against the old flat depth counter
  • webjs check and webjs doctor are clean on examples/blog and
    website, and webjs check --rules gains and loses nothing
  • Tests green at every layer: npm test, npm run test:browser,
    WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs, and
    node scripts/run-bun-tests.js
  • Every doc surface listed above is updated

Out of scope

  • Refusing formtarget on a bound submitter. Decided against above: WebJs
    does not refuse target on a bound form either, and the router already bails
    on any non-_self target, so both paths already agree.
  • Emitting formaction="" for React parity. Decided against above on this
    repo's own conformance stance.
  • A text/plain encoder in the client router. Decided against above. The
    router bails to the browser instead.
  • Any webjs check rule. No rule is added, changed or extracted here.
    form-action-not-a-get-action stays byte-identical, which means
    classifyActionHole is NOT extracted.
  • Reordering reconcileFormActions's two-pass loop. Its original motivation
    is gone, but changing commit ordering is a separate risk with no benefit here.
  • packages/ TypeScript. packages/ is plain .js with JSDoc and ships
    buildless. No .ts file is added under it.
  • Widening the enctype work into route.ts handlers, the RPC boundary, or
    submitForm.
    This change touches the client router's form-submission path
    and nothing else on the wire.
  • Follow-up issues. Anything this work turns up goes in the PR description
    for the owner to decide, not into a new issue.

Cross-issue landmines

Other issues may be planned in parallel against AGENTS.md and
.agents/skills/webjs/references/data-and-actions.md. Keep this change's diff
inside invariant 12 plus the html hole row in the former, and inside the
bound-form bullet list in the latter, and rebase on origin/main before opening
the PR rather than resolving a conflict after the fact.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions