Skip to content

Support formaction=${action} for per-button actions, and refuse unparseable submitter enctype #1207

Description

@vivek7405

Problem

#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

<button name="__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:

  1. 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.
  2. 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.
  3. 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.
  • Two SSR state machines. renderTemplate and streamTemplate are 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.
  • 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.

Tests + docs surfaces:

  • Unit: packages/core/test/rendering/form-action-binding{,-client}.test.js, form-action-attr-guard.test.js
  • 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

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions