You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#1155 unified form submission on <form action=${importedAction}> and deliberately REFUSED formaction=${fn} on a submit button, so a form can bind exactly one action. A form with several buttons has to bind one action and dispatch on a submitter's name="intent", which is what the scaffold's todo gallery does.
That refusal was justified on the PR with reasoning that is wrong. The comment claims a per-submitter identity "would have to ride the submitter's own name/value pair, and that pair is how a multi-button form tells its buttons apart". That is only true of one of the two channels a browser gives you, and the constraint it describes is avoidable. Meanwhile React 19 / Next.js DOES support formAction={fn} per button, so this is a real capability gap rather than a considered limitation.
Separately, and in the same area: a submitter's formmethod / formenctype can silently defeat a bound form, and NEITHER renderer sees it today. <form action=${fn}><button formenctype="text/plain"> works with JS (the client router posts a FormData, ignoring formenctype) and is a bare 405 without it, because looksLikeFormSubmission (packages/server/src/form-dispatch.js:198-201) only accepts multipart and urlencoded. That is exactly the works-one-way-only shape form-action.js refuses at form level, unrefused one level down.
Design / approach
Part A: support formaction=${fn}
Use the standard lowercase HTML attribute. Attribute names are case-insensitive, so formAction binds for free the way action already does. Do NOT introduce a React-style camelCase concept.
Without JS, exactly two channels identify the pressed submitter: its own name/value pair, and the URL its formaction points at. Hidden inputs cannot do it, because every hidden input in the form is submitted regardless of which button was pressed.
Chosen mechanism: the submitter's own name/value. Emit
<buttonname="__webjs_action" value="<hash>/<fn>">
and emit no formaction URL attribute at all, exactly as a bound action=${fn} emits no action attribute.
This needs almost nothing new:
No JS: the browser submits the pressed button's pair, and the dispatcher already reads __webjs_action off the FormData (packages/server/src/form-dispatch.js:275).
With JS: buildSubmitFormData already uses new FormData(form, submitter) (packages/core/src/router-client.js:607-620), which includes the submitter's pair, with an explicit fd.append fallback for older Safari.
Rejected alternative: encode the identity in a formaction URL (formaction="?__webjs_action=<id>"). It needs the request URL inside the renderer (a new server-only provider, like setAssetUrlProvider) and a query-only relative URL silently DROPS the page's existing query params. Verified: new URL('?x=1', 'https://h/items?page=2').href is https://h/items?x=1, so page=2 is lost and both the 303 target and the 422 re-render lose the page's query state.
Precedence. The form-level hidden field is inserted as the form's FIRST child (bindFormActionElement, insertBefore(field, form.firstChild)) and SSR emits it immediately after the start tag, so any submitter entry necessarily follows it in DOM order. The dispatcher therefore reads formData.getAll(FORM_ACTION_FIELD) and takes the LAST, which is unambiguously the submitter's when present.
Three refusals, all detectable at compile time on the client and at the start tag on SSR:
The button already carries its own name, whether static or hole-provided (name=${n} is a name part on that element). It cannot hold both its own value and the identity.
The enclosing <form> does not itself bind an action. method / enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action requires the form to be bound.
The submitter is an <input type="image">. An image submitter submits name.x / name.y coordinates instead of name=value, so the identity would never arrive and the form would look bound while posting nothing.
Part B: refuse unparseable submitter formenctype / formmethod
Refuse only VALUES that cannot work, never the attribute itself. formenctype="multipart/form-data" and "application/x-www-form-urlencoded" stay fully supported.
formenctype outside those two, since text/plain is flagged by the HTML spec as not intended for machine parsing (no escaping, genuinely lossy)
formmethod="get" inside a bound form, since GET sends no body so the action cannot receive its FormData
This is the same rule already applied at form level, reusing assertSubmittableForm / PARSEABLE_ENCTYPES in packages/core/src/form-action.js.
Implementation notes (for the implementing agent)
READ FIRST. This lands on top of the client-guard redesign for #1155 (the "shared oracle" work). That redesign replaces the per-write-site guard with a compile-time parts record plus an end-of-pass reconcile, and adds the "inside a bound form" tracking this issue needs. Check whether it has landed before starting; if it has not, coordinate, because both touch the same assignPaths extension point.
Where to edit:
packages/core/src/form-action.js
formActionError / the refusal table live here. The module header at L26-45 documents formaction=${fn} as refused and states the WRONG reason; rewrite it.
reflectsAsFormAction (L604) maps formaction to button/input, and isFormActionAttr (L657) matches action / formaction. The formaction path currently routes to refusal; it needs a bind path.
Reuse PARSEABLE_ENCTYPES and assertSubmittableForm for Part B.
packages/core/src/render-server.js
closeBoundFormTag (L168) and pendingActionId (L160) already exist. Add an "inside a bound form" flag set when a form's start tag is bound and cleared at </form>, so a descendant submitter knows whether its form is bound. The second SSR state machine (streamTemplate) needs the same treatment; it is a SEPARATE machine and inherits nothing.
packages/core/src/render-client.js
assignPaths (L465-502) is the one place with the real element, all its STATIC attributes, and every part index bound to it at once. Detect the button's own name part / static name, and type="image", here.
The client's "is my enclosing form bound" check is part.el.closest('form') at reconcile time. It is BEST EFFORT: a button in a nested template may not reach its form while the fragment is detached. SSR is authoritative because it renders every page.
packages/server/src/form-dispatch.js
L275 formData.get(FORM_ACTION_FIELD) becomes getAll(...) with last-wins.
L282 formData.delete(FORM_ACTION_FIELD) already strips the wire field before the action sees it; it removes ALL entries, which stays correct.
looksLikeFormSubmission (L198-201) is the gate that turns a text/plain submission into a 405.
packages/server/src/check.js
Rule form-action-not-a-get-action (definition L139, impl L1372) currently matches <form action=${x}> only. Extend it to a formaction-bound button so a GET-declared action bound to a submitter is caught at edit time too.
Landmines:
The refusal reasoning on PR feat: unify form submissions on <form action=${action}> #1205 is wrong and is quoted in several places.AGENTS.md invariant 12, .agents/skills/webjs/references/muscle-memory-gotchas.md (the refused/allowed table), data-and-actions.md, optimistic-ui.md, and website/app/docs/{server-actions,troubleshooting,migrating-from-nextjs}/page.ts all state that formaction=${fn} is refused because the identity would collide with the submitter's name/value. Every one of those becomes wrong when this ships.
The client router already honors submitter precedence for STRING actions (getSubmitAction L585-596, getSubmitMethod L571-582). Do not break that path; a plain formaction="/url" must keep working exactly as it does.
formmethod="dialog" is explicitly skipped by the router (L522). Do not refuse it; it is a native <dialog> dismissal and never a submission.
linkedom has no form reflection.node_modules/linkedom/esm/html/form-element.js is an empty class body, so any assertion about a reflected IDL property passes for the wrong reason under the node tests. Reflection-dependent rows belong in packages/core/test/rendering/browser/.
The scaffold's todo gallery (packages/cli/templates/gallery/modules/todo/) currently uses the intent dispatcher precisely because per-button binding was unavailable. It can be simplified once this lands, and its comment explaining the refusal must be updated either way.
Invariants to respect:
AGENTS.md invariant 12 (the form-binding invariant) needs rewriting rather than merely extending.
Progressive enhancement is the headline invariant: the no-JS path must work for every shape the renderers accept. That is the whole reason Part B exists.
packages/ stays plain JS with JSDoc, no .ts.
No prose em-dashes / pause-hyphens (invariant 11) in any doc or comment touched.
Browser (REQUIRED for reflection and real submission): packages/core/test/rendering/browser/form-action-guard.test.js, and the parity table in browser/ssr-client-parity.test.js
Server: packages/server/test/routing/form-dispatch.test.js (precedence, the 405 for an unparseable submitter enctype), packages/server/test/check/form-action-not-a-get-action.test.js
E2E with JS DISABLED: a multi-button form where each button runs a different action, in test/e2e/e2e.test.mjs. This is the headline proof and cannot be skipped.
Bun: test/bun/form-action-dispatch.mjs gains a submitter row; run node scripts/run-bun-tests.js.
Docs: every surface listed under Landmines, plus the scaffold gallery.
Acceptance criteria
<form action=${a}><button formaction=${b}> runs b when that button is pressed and a otherwise, with JS ON and with JS OFF (e2e proves both)
The submitter's identity wins over the form's, proven by a test that would pass under first-wins if the precedence were reversed
A plain string formaction="/url" still routes exactly as before
formmethod="dialog" is still never treated as a submission
Refused with an actionable message: a button that already has a name (static or hole-provided), a bound submitter whose enclosing form is not bound, and <input type="image">
formenctype="text/plain" and formmethod="get" on a submitter inside a bound form are refused at render, while multipart/form-data and application/x-www-form-urlencoded still render
Both SSR state machines refuse identically, each with its own test
form-action-not-a-get-action also flags a GET-declared action bound via formaction
A counterfactual proves each new test fires (revert the fix, the named test reds)
Bun parity test added and green
Every doc surface that states the old refusal reasoning is corrected, including AGENTS.md invariant 12 and the muscle-memory-gotchas table
Problem
#1155 unified form submission on
<form action=${importedAction}>and deliberately REFUSEDformaction=${fn}on a submit button, so a form can bind exactly one action. A form with several buttons has to bind one action and dispatch on a submitter'sname="intent", which is what the scaffold's todo gallery does.That refusal was justified on the PR with reasoning that is wrong. The comment claims a per-submitter identity "would have to ride the submitter's own
name/valuepair, and that pair is how a multi-button form tells its buttons apart". That is only true of one of the two channels a browser gives you, and the constraint it describes is avoidable. Meanwhile React 19 / Next.js DOES supportformAction={fn}per button, so this is a real capability gap rather than a considered limitation.Separately, and in the same area: a submitter's
formmethod/formenctypecan silently defeat a bound form, and NEITHER renderer sees it today.<form action=${fn}><button formenctype="text/plain">works with JS (the client router posts aFormData, ignoringformenctype) and is a bare 405 without it, becauselooksLikeFormSubmission(packages/server/src/form-dispatch.js:198-201) only accepts multipart and urlencoded. That is exactly the works-one-way-only shapeform-action.jsrefuses at form level, unrefused one level down.Design / approach
Part A: support
formaction=${fn}Use the standard lowercase HTML attribute. Attribute names are case-insensitive, so
formActionbinds for free the wayactionalready does. Do NOT introduce a React-style camelCase concept.Without JS, exactly two channels identify the pressed submitter: its own
name/valuepair, and the URL itsformactionpoints at. Hidden inputs cannot do it, because every hidden input in the form is submitted regardless of which button was pressed.Chosen mechanism: the submitter's own
name/value. Emitand emit no
formactionURL attribute at all, exactly as a boundaction=${fn}emits noactionattribute.This needs almost nothing new:
__webjs_actionoff theFormData(packages/server/src/form-dispatch.js:275).buildSubmitFormDataalready usesnew FormData(form, submitter)(packages/core/src/router-client.js:607-620), which includes the submitter's pair, with an explicitfd.appendfallback for older Safari.Rejected alternative: encode the identity in a
formactionURL (formaction="?__webjs_action=<id>"). It needs the request URL inside the renderer (a new server-only provider, likesetAssetUrlProvider) and a query-only relative URL silently DROPS the page's existing query params. Verified:new URL('?x=1', 'https://h/items?page=2').hrefishttps://h/items?x=1, sopage=2is lost and both the 303 target and the 422 re-render lose the page's query state.Precedence. The form-level hidden field is inserted as the form's FIRST child (
bindFormActionElement,insertBefore(field, form.firstChild)) and SSR emits it immediately after the start tag, so any submitter entry necessarily follows it in DOM order. The dispatcher therefore readsformData.getAll(FORM_ACTION_FIELD)and takes the LAST, which is unambiguously the submitter's when present.Three refusals, all detectable at compile time on the client and at the start tag on SSR:
name, whether static or hole-provided (name=${n}is anamepart on that element). It cannot hold both its own value and the identity.<form>does not itself bind an action.method/enctypeare forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action requires the form to be bound.<input type="image">. An image submitter submitsname.x/name.ycoordinates instead ofname=value, so the identity would never arrive and the form would look bound while posting nothing.Part B: refuse unparseable submitter
formenctype/formmethodRefuse only VALUES that cannot work, never the attribute itself.
formenctype="multipart/form-data"and"application/x-www-form-urlencoded"stay fully supported.formenctypeoutside those two, sincetext/plainis flagged by the HTML spec as not intended for machine parsing (no escaping, genuinely lossy)formmethod="get"inside a bound form, since GET sends no body so the action cannot receive itsFormDataThis is the same rule already applied at form level, reusing
assertSubmittableForm/PARSEABLE_ENCTYPESinpackages/core/src/form-action.js.Implementation notes (for the implementing agent)
READ FIRST. This lands on top of the client-guard redesign for #1155 (the "shared oracle" work). That redesign replaces the per-write-site guard with a compile-time parts record plus an end-of-pass reconcile, and adds the "inside a bound form" tracking this issue needs. Check whether it has landed before starting; if it has not, coordinate, because both touch the same
assignPathsextension point.Where to edit:
packages/core/src/form-action.jsformActionError/ the refusal table live here. The module header at L26-45 documentsformaction=${fn}as refused and states the WRONG reason; rewrite it.reflectsAsFormAction(L604) mapsformactionto button/input, andisFormActionAttr(L657) matchesaction/formaction. Theformactionpath currently routes to refusal; it needs a bind path.PARSEABLE_ENCTYPESandassertSubmittableFormfor Part B.packages/core/src/render-server.jscloseBoundFormTag(L168) andpendingActionId(L160) already exist. Add an "inside a bound form" flag set when a form's start tag is bound and cleared at</form>, so a descendant submitter knows whether its form is bound. The second SSR state machine (streamTemplate) needs the same treatment; it is a SEPARATE machine and inherits nothing.packages/core/src/render-client.jsassignPaths(L465-502) is the one place with the real element, all its STATIC attributes, and every part index bound to it at once. Detect the button's ownnamepart / staticname, andtype="image", here.part.el.closest('form')at reconcile time. It is BEST EFFORT: a button in a nested template may not reach its form while the fragment is detached. SSR is authoritative because it renders every page.packages/server/src/form-dispatch.jsformData.get(FORM_ACTION_FIELD)becomesgetAll(...)with last-wins.formData.delete(FORM_ACTION_FIELD)already strips the wire field before the action sees it; it removes ALL entries, which stays correct.looksLikeFormSubmission(L198-201) is the gate that turns atext/plainsubmission into a 405.packages/server/src/check.jsform-action-not-a-get-action(definition L139, impl L1372) currently matches<form action=${x}>only. Extend it to aformaction-bound button so a GET-declared action bound to a submitter is caught at edit time too.Landmines:
AGENTS.mdinvariant 12,.agents/skills/webjs/references/muscle-memory-gotchas.md(the refused/allowed table),data-and-actions.md,optimistic-ui.md, andwebsite/app/docs/{server-actions,troubleshooting,migrating-from-nextjs}/page.tsall state thatformaction=${fn}is refused because the identity would collide with the submitter's name/value. Every one of those becomes wrong when this ships.getSubmitActionL585-596,getSubmitMethodL571-582). Do not break that path; a plainformaction="/url"must keep working exactly as it does.formmethod="dialog"is explicitly skipped by the router (L522). Do not refuse it; it is a native<dialog>dismissal and never a submission.renderTemplateandstreamTemplateare independent; SSR stringifies a function-valued form action, leaking server source #1154 shipped a guard in one and not the other. Whatever tracking is added must land in both, with a test for each.node_modules/linkedom/esm/html/form-element.jsis an empty class body, so any assertion about a reflected IDL property passes for the wrong reason under the node tests. Reflection-dependent rows belong inpackages/core/test/rendering/browser/.packages/cli/templates/gallery/modules/todo/) currently uses theintentdispatcher precisely because per-button binding was unavailable. It can be simplified once this lands, and its comment explaining the refusal must be updated either way.Invariants to respect:
packages/stays plain JS with JSDoc, no.ts.Tests + docs surfaces:
packages/core/test/rendering/form-action-binding{,-client}.test.js,form-action-attr-guard.test.jspackages/core/test/rendering/browser/form-action-guard.test.js, and the parity table inbrowser/ssr-client-parity.test.jspackages/server/test/routing/form-dispatch.test.js(precedence, the 405 for an unparseable submitter enctype),packages/server/test/check/form-action-not-a-get-action.test.jstest/e2e/e2e.test.mjs. This is the headline proof and cannot be skipped.test/bun/form-action-dispatch.mjsgains a submitter row; runnode scripts/run-bun-tests.js.Acceptance criteria
<form action=${a}><button formaction=${b}>runsbwhen that button is pressed andaotherwise, with JS ON and with JS OFF (e2e proves both)formaction="/url"still routes exactly as beforeformmethod="dialog"is still never treated as a submissionname(static or hole-provided), a bound submitter whose enclosing form is not bound, and<input type="image">formenctype="text/plain"andformmethod="get"on a submitter inside a bound form are refused at render, whilemultipart/form-dataandapplication/x-www-form-urlencodedstill renderform-action-not-a-get-actionalso flags a GET-declared action bound viaformaction