Delete<\/button>/);
+});
+
+test('SSR: formaction=${fn} on unbound button throws actionable refusal', async () => {
+ const tpl = html`Delete `;
+ await assert.rejects(
+ () => renderToString(tpl, { ssr: true }),
+ /requires the enclosing
+ `;
+ await assert.rejects(
+ () => renderToString(tpl, { ssr: true }),
+ /already carries a "name" attribute/,
+ );
+});
+
+test('SSR: submitter guards stay identical for Bun and Node', async () => {
+ const refused = [
+ ['text input', html``, /requires a submitter control/],
+ ['hidden input', html``, /requires a submitter control/],
+ ['image input', html``, /coordinate pairs/],
+ ['submit input', html``, /also its visible label/],
+ ['button input', html``, /requires a submitter control/],
+ ['value attribute', html``, /already carries a "value" attribute/],
+ ['static formaction', html``, /cannot also carry a plain formaction attribute/],
+ ['form attribute', html``, /cannot be used with a "form" attribute/],
+ ];
+ // Each row asserts its OWN message: a shared alternation matches every
+ // message in the module and would only prove that something threw.
+ for (const [label, tpl, expected] of refused) {
+ await assert.rejects(() => renderToString(tpl, { ssr: true }), expected, label);
+ }
+});
+
+test('SSR: nested submitter templates keep the enclosing form binding', async () => {
+ const buttons = () => html`Delete `;
+ const out = await renderToString(html``, { ssr: true });
+ assert.match(out, /name="__webjs_action" value="hash\/deleteAction"/);
+});
+
+// #1207 Part B, cross-runtime. The renderers are byte-identical on Node and
+// Bun, so the refusals have to be too: a submitter guard that fires on one
+// runtime and not the other would ship a page that works in dev and 405s in
+// production, which is the same works-one-way-only failure Part B exists to
+// close.
+test('SSR: Part B refuses an unparseable submitter enctype on both runtimes', async () => {
+ const refused = [
+ ['plain formenctype', html``],
+ ['plain formmethod', html``],
+ ['padded formmethod', html``],
+ ['submit input', html``],
+ ['nested template', html``],
+ ['bound plus dialog', html``],
+ ];
+ for (const [label, tpl] of refused) {
+ await assert.rejects(() => renderToString(tpl, { ssr: true }), /formenctype=|formmethod=|dialog/, label);
+ }
+});
+
+test('SSR: Part B carve-outs render on both runtimes', async () => {
+ const allowed = [
+ ['dialog dismissal', html``, /formmethod="dialog"/],
+ ['retargeted submitter', html``, /formaction="\/search"/],
+ ['non-submitter control', html``, /name="q"/],
+ ['outside a bound form', html``, /formenctype="text\/plain"/],
+ ['parseable enctype', html``, /formenctype="multipart\/form-data"/],
+ ];
+ for (const [label, tpl, re] of allowed) {
+ assert.match(await renderToString(tpl, { ssr: true }), re, label);
+ }
+});
diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs
index 0d56035b8..74b4a8a0e 100644
--- a/test/e2e/e2e.test.mjs
+++ b/test/e2e/e2e.test.mjs
@@ -2940,6 +2940,130 @@ describe('E2E: form actions (no-JS + enhanced)', { skip: !process.env.WEBJS_E2E
assert.ok(p.url().endsWith('/feedback/thanks'), `expected PRG to /feedback/thanks, got ${p.url()}`);
} finally { await p.close(); }
});
+
+ // -------------------------------------------------------------------------
+ // #1207: PER-BUTTON actions, the headline proof.
+ //
+ // `/feedback/triage` binds `saveDraft` on the form and overrides it with
+ // `formaction=${publishDraft}` on the Publish button. With JavaScript off
+ // nothing intercepts anything: the browser serializes the form's hidden
+ // identity field AND the pressed button's own name/value pair, in that DOM
+ // order, and the dispatcher takes the last. If the submitter's pair were not
+ // emitted, or landed before the hidden field, or the dispatcher still read
+ // only the first entry, the wrong action would run and the redirect would
+ // name it.
+ //
+ // The redirect target carries WHICH action ran precisely so these cannot
+ // pass on "the form submitted successfully": with the submitter binding
+ // removed, pressing Publish still submits fine and still redirects, just to
+ // the other name.
+ // -------------------------------------------------------------------------
+
+ test('JS DISABLED: the submitter carries its own identity, after the form field', async () => {
+ const p = await paBrowser.newPage();
+ await p.setJavaScriptEnabled(false);
+ try {
+ await p.goto(`${paBase}/feedback/triage`, { waitUntil: 'domcontentloaded', timeout: 10000 });
+ const shape = await p.evaluate(() => {
+ const form = document.querySelector('form');
+ const publish = document.getElementById('publish');
+ const save = document.getElementById('save');
+ return {
+ hidden: form.querySelector('input[name="__webjs_action"]')?.value || null,
+ hiddenIsFirst: form.firstElementChild?.getAttribute('name') === '__webjs_action',
+ publishName: publish.getAttribute('name'),
+ publishValue: publish.getAttribute('value'),
+ publishHasFormAction: publish.hasAttribute('formaction'),
+ // The plain button binds nothing, so it must stay untouched.
+ saveName: save.getAttribute('name'),
+ // What the browser would really serialize for each submitter.
+ onPublish: [...new FormData(form, publish).getAll('__webjs_action')],
+ onSave: [...new FormData(form, save).getAll('__webjs_action')],
+ };
+ });
+ assert.ok(/^[0-9a-f]{10}\/saveDraft$/.test(shape.hidden || ''),
+ `form identity must name saveDraft, got ${shape.hidden}`);
+ assert.ok(shape.hiddenIsFirst, 'the form identity is the first child, so a submitter entry follows it');
+ assert.equal(shape.publishName, '__webjs_action', 'the submitter carries the identity name');
+ assert.ok(/^[0-9a-f]{10}\/publishDraft$/.test(shape.publishValue || ''),
+ `the submitter value must name publishDraft, got ${shape.publishValue}`);
+ assert.equal(shape.publishHasFormAction, false, 'no formaction url is emitted, so it posts to this page');
+ assert.equal(shape.saveName, null, 'a button that binds nothing is left alone');
+ assert.deepEqual(shape.onPublish, [shape.hidden, shape.publishValue],
+ 'pressing Publish submits both identities, the submitter LAST');
+ assert.deepEqual(shape.onSave, [shape.hidden],
+ 'pressing Save submits only the form identity');
+ const src = await p.content();
+ assert.ok(!src.includes('Write something first'),
+ 'the action bodies must not ship: their validation message appears only after a submit');
+ } finally { await p.close(); }
+ });
+
+ test('JS DISABLED: pressing the bound submitter runs ITS action, not the form\'s', async () => {
+ const p = await paBrowser.newPage();
+ await p.setJavaScriptEnabled(false);
+ try {
+ await p.goto(`${paBase}/feedback/triage`, { waitUntil: 'domcontentloaded', timeout: 10000 });
+ await p.type('#note', 'ship it');
+ await Promise.all([
+ p.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 10000 }),
+ p.click('#publish'),
+ ]);
+ const ran = await p.evaluate(() => document.getElementById('ran')?.textContent || '');
+ assert.equal(ran, 'publishDraft', `the submitter's action must run, got "${ran}"`);
+ } finally { await p.close(); }
+ });
+
+ test('JS DISABLED: pressing a plain submitter runs the FORM action', async () => {
+ // The other half of the same claim. A browser submits a submitter's pair
+ // only for the button pressed, so with Save pressed there is no second
+ // entry and the form's own identity is what the dispatcher sees.
+ const p = await paBrowser.newPage();
+ await p.setJavaScriptEnabled(false);
+ try {
+ await p.goto(`${paBase}/feedback/triage`, { waitUntil: 'domcontentloaded', timeout: 10000 });
+ await p.type('#note', 'later');
+ await Promise.all([
+ p.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 10000 }),
+ p.click('#save'),
+ ]);
+ const ran = await p.evaluate(() => document.getElementById('ran')?.textContent || '');
+ assert.equal(ran, 'saveDraft', `the form's action must run, got "${ran}"`);
+ } finally { await p.close(); }
+ });
+
+ test('JS DISABLED: a failing submitter action re-renders THIS page at 422', async () => {
+ // The per-button path has to reach the same 422 re-render as the form-level
+ // one, or a validation failure on a submitter action would lose the page.
+ const p = await paBrowser.newPage();
+ await p.setJavaScriptEnabled(false);
+ try {
+ await p.goto(`${paBase}/feedback/triage`, { waitUntil: 'domcontentloaded', timeout: 10000 });
+ await Promise.all([
+ p.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 10000 }),
+ p.click('#publish'),
+ ]);
+ const error = await p.evaluate(() => document.getElementById('note-error')?.textContent || '');
+ assert.ok(error.includes('Write something first'), `expected the field error, got "${error}"`);
+ assert.ok(p.url().endsWith('/feedback/triage'), `expected to stay put, got ${p.url()}`);
+ } finally { await p.close(); }
+ });
+
+ test('JS ENABLED: the same button runs the same action through the client router', async () => {
+ // Identical by construction is the claim, so the enhanced path must reach
+ // the same action. The client router posts the same body to the same url
+ // and follows the 303 via fetch.
+ const p = await paBrowser.newPage();
+ try {
+ await p.goto(`${paBase}/feedback/triage`, { waitUntil: 'networkidle0', timeout: 15000 });
+ await p.type('#note', 'ship it');
+ await p.click('#publish');
+ await p.waitForFunction(() => !!document.getElementById('ran'), { timeout: 10000 });
+ const ran = await p.evaluate(() => document.getElementById('ran')?.textContent || '');
+ assert.equal(ran, 'publishDraft', `the submitter's action must run with JS too, got "${ran}"`);
+ } finally { await p.close(); }
+ });
+
});
// ---------------------------------------------------------------------------
diff --git a/website/app/docs/migrating-from-nextjs/page.ts b/website/app/docs/migrating-from-nextjs/page.ts
index 2ac40e5d5..ddc1d467c 100644
--- a/website/app/docs/migrating-from-nextjs/page.ts
+++ b/website/app/docs/migrating-from-nextjs/page.ts
@@ -103,7 +103,7 @@ Counter.register('my-counter');
What ports cleanly, and what does not
Ports directly: the app/ directory layout, dynamic segments ([id], [...rest], [[...rest]]), route groups ((group)), the metadata API, route handlers, middleware, and the loading / error / not-found conventions.
- Needs rethinking: anything written as a Client Component becomes a web component; anything fetching server data moves into a .server action; React state becomes signals; next/link becomes a plain link. Write progressive-enhancement-first: a <form> plus a server action instead of a fetch in a click handler, since the form works without JavaScript and the client router upgrades it automatically. The form binding ports directly: <form action={serverAction}> in Next is <form action=\${serverAction}> here, and it is the only way WebJs submits a form to an action. What differs is underneath: React serializes the binding, bound arguments included, into hidden fields, while WebJs emits ONE hidden field carrying the action's identity and no arguments, so a per-row action takes its row id from a hidden input rather than from action.bind(null, id). One divergence to know: formaction= on a submit button is refused, so a form with several buttons binds one action and dispatches on a button's name.
+ Needs rethinking: anything written as a Client Component becomes a web component; anything fetching server data moves into a .server action; React state becomes signals; next/link becomes a plain link. Write progressive-enhancement-first: a <form> plus a server action instead of a fetch in a click handler, since the form works without JavaScript and the client router upgrades it automatically. The form binding ports directly: <form action={serverAction}> in Next is <form action=\${serverAction}> here, and per-button submitter actions work via <button formaction=\${deleteAction}>. What differs is underneath: React serializes the binding, bound arguments included, into hidden fields, while WebJs emits ONE hidden field per bound action carrying its identity and no arguments, so a per-row action takes its row id from a hidden input rather than from action.bind(null, id).
Not provided: image and font optimization, i18n, and a static export. WebJs is a no-build, standards-based framework, so these are libraries you layer on, not built-ins.
Next steps: read Getting Started to scaffold an app, Architecture for the execution model in depth, and Progressive Enhancement for the design posture that replaces the Client Component habit.
`;
diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts
index ca0c2c719..4900dbd8c 100644
--- a/website/app/docs/progressive-enhancement/page.ts
+++ b/website/app/docs/progressive-enhancement/page.ts
@@ -169,11 +169,11 @@ export default function NewPost({ actionData }: {
- A form that binds nothing gets a 405: there is no page action export to catch a bare <form method="post">. A form with several submit buttons binds one action and dispatches on a button's name, since formaction=\${fn} is not supported yet (tracked in issue #1207).
+ A form that binds nothing gets a 405: there is no page action export to catch a bare <form method="post">. Multi-submitter forms can bind per-button actions using formaction=\${action} on submitter buttons inside a bound form, which work with JavaScript disabled via standard DOM submitter precedence.
- One honest limit on "identical by construction": it is a claim about the FORM, and the renderers do not read a submitter's own formmethod / formenctype. So <form action=\${fn}><button formenctype="text/plain"> submits fine with JS (the router posts FormData and ignores the attribute) and is a bare 405 without it, with nothing refused at render. Leave those attributes off a bound form until #1207 closes the gap.
+ "Identical by construction" is a claim about the whole submission, submitter included. A button's own formmethod / formenctype can defeat the form it sits in, so the renderers read those on EVERY submitter inside a bound form, whether or not that button binds an action of its own: <form action=\${fn}><button formenctype="text/plain"> would submit fine with JS (the router posts FormData and ignores the attribute) and be a bare 405 without it, so it is refused at render instead. The same goes for formmethod="get", which sends no body. Two shapes are deliberately left alone, because neither submits to the bound action: formmethod="dialog" is a native <dialog> dismissal, and a plain formaction="/url" points the submission somewhere else entirely.
3. Make components render correctly on the server
diff --git a/website/app/docs/server-actions/page.ts b/website/app/docs/server-actions/page.ts
index 477bb802a..562c5dead 100644
--- a/website/app/docs/server-actions/page.ts
+++ b/website/app/docs/server-actions/page.ts
@@ -472,7 +472,8 @@ export default function NewPost({ actionData }: {
An identity that no longer resolves is a 422 re-render carrying a resubmit message and the submitted values. That is a form held open across a deploy; a 404 would discard what was typed and a silent success would report a write that never happened.
Everything the action declares applies here too, or an action would be protected over RPC and open over a form: validate runs on the submitted FormData, the middleware chain runs (with the page's params / searchParams / url on ctx), and invalidates is evicted when the action actually ran. The submission is Origin-verified exactly like an RPC call, so a no-JS form needs no CSRF token field. An action declaring method = 'GET' cannot be bound to a form (it rides its arguments in the URL and skips the CSRF check), which is a 405 at runtime and the form-action-not-a-get-action error in webjs check. A streamed return is refused on this path: a submission is answered with a redirect or a page, and with JS off there is no consumer for frames.
- formaction=\${fn} on a submit button is not supported yet, so it is refused rather than silently ignored. A form with several buttons binds ONE action and dispatches on a button's name. Per-button actions are tracked in issue #1207.
+ A form whose buttons run different actions binds each one on its submitter , with the same unquoted spelling one level down: <form action=\${saveDraft}>…<button formaction=\${publishPost}>Publish</button></form>. The identity rides the pressed button's own name/value pair, which a browser submits for that button alone, so no formaction url is emitted and the whole thing works with JavaScript off. Both identities reach the server and the LAST wins, which is the submitter's whenever one was pressed.
+ The submitter must be a <button> inside a form that is itself bound, and it cannot carry its own name, value, form, or a static formaction, because the identity already occupies that name/value pair. Two input controls are refused for the same underlying reason. <input type="image"> submits name.x / name.y coordinates rather than name=value, so the identity would never arrive. <input type="submit"> would receive the identity in its value, which on that control is also the visible caption, so it would render captioned with the action id and the only way to label it is the channel the identity needs. A <button> avoids both, because its label is its children. A .prop spelling of any of these (.name, .value, .formAction, .formMethod, .formEnctype) is refused as well: all reflect on a submitter, so the write is dropped at SSR and lands in the attribute in the browser. Separately, formmethod="get" and an unparseable formenctype like text/plain are refused on ANY submitter inside a bound form, binding or not, since neither can carry the action's body. formmethod="dialog" and a plain formaction="/url" are left alone, because neither submits to the bound action.
With JavaScript off this is a native round-trip (the browser submits, follows the 303, or renders the 422). With JavaScript on the client router applies the 422 in place (no reload, typed input preserved) and follows the 303 via fetch. Both ends of the progressive-enhancement spectrum from one piece of code, no form library. See the client router docs for the rendering behavior, and progressive enhancement for the full write-path pattern.
`;
}
diff --git a/website/app/docs/ssr/page.ts b/website/app/docs/ssr/page.ts
index f2dca07d7..f8bae8baf 100644
--- a/website/app/docs/ssr/page.ts
+++ b/website/app/docs/ssr/page.ts
@@ -165,7 +165,7 @@ async function loadExpensiveItems() {
The custom-element .prop path supports rich types out of the box: Array, Object, Date, Map, Set, BigInt, and reference cycles. Functions, class instances with private state, and DOM nodes are unserializable; they drop with a dev warning. See Components for the full property-binding semantics.
- One exception to the table above: a function under action= or formaction= is never serialized. Stringifying a function writes its source into the HTML, and during SSR an imported 'use server' action is the real function, so that source is the action's whole body. An unquoted action=\${fn} on a <form> is the one supported binding: the renderer resolves the action's identity, drops the attribute, and emits a hidden field (see Server Actions ). Every other shape under those two names throws instead, including the boolean row and the native .prop row (on a <form>, or .formAction on a button or input, where the property reflects). Every other attribute behaves exactly as the table says, and a string-valued action is unchanged. See Troubleshooting .
+ One exception to the table above: a function under action= or formaction= is never serialized. Stringifying a function writes its source into the HTML, and during SSR an imported 'use server' action is the real function, so that source is the action's whole body. An unquoted action=\${fn} on a <form>, or an unquoted formaction=\${fn} on a <button> inside a bound form, is supported: the renderer resolves the action's identity and emits the reserved identity field (see Server Actions ). Unsupported submitter types and conflicting submitter attributes throw instead. Every other shape under those two names throws, including the boolean row and the native .prop row (on a <form>, or .formAction on a button or input, where the property reflects). Every other attribute behaves exactly as the table says, and a string-valued action is unchanged. See Troubleshooting .
Metadata in <head>
The SSR pipeline collects metadata from the layout chain and the page, then injects it into the document <head>. You declare metadata via a named export:
diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts
index a2fd0a021..226e94dd6 100644
--- a/website/app/docs/troubleshooting/page.ts
+++ b/website/app/docs/troubleshooting/page.ts
@@ -45,15 +45,20 @@ export default function Troubleshooting() {
Symptom: a render fails with a function was interpolated into action=, or with the function in action= is not a server action. The supported binding is an unquoted action=\${fn} on a <form>, where the function is imported from a 'use server' module; the first message means the shape was a near-miss the renderer will not stringify, and the second means the shape was right but the function is not an action the server can run. Where that surfaces depends on which render threw. From a page or layout it propagates, so the nearest error boundary catches it and the message is visible. From inside a component it does not: per-component SSR error isolation contains it, so dev renders an error box in place of the component while production renders the component empty and the page still returns 200. A form that has silently vanished in production, with no error anywhere, is the same bug wearing a disguise; check the server log for the message. In neither case is the action's source emitted.
Two refinements on "renders the component empty", because both send people looking in the wrong place. The isolation replaces the element through its matching closing tag, so anything slotted into that component disappears with it : put the bad form in a shared header or shell and every page returns 200 with an empty body, which reads like a routing failure rather than one broken component. And on a route with a loading.ts, the page renders inside a Suspense boundary after the 200 and the shell are already flushed, where a throw is currently swallowed with no server log and no error report ; the visitor gets chrome and a blank body, and with JS off the loading skeleton simply stays. That silence is why the log line you are looking for may not exist on that path; it is a known gap rather than intended behaviour.
Cause: during SSR an imported 'use server' action is the REAL function (the RPC stub exists only in the browser). The supported binding resolves that function's identity instead of stringifying it, but in any OTHER shape action= is an ordinary attribute hole, so stringifying it would write the action's whole body into the HTML every visitor downloads: its logic, the query shapes it builds, and any literal written inside it, a connection string or internal path included.
- Assume outer values are exposed too. On Node, Function.prototype.toString returns source text, so a module-scope const the body reads appears only as its identifier. That is NOT a guarantee you can rely on: Bun transpiles a module before the engine sees it and can fold a module-scope string literal straight into the body, so the same action reports Authorization: "Bearer sk_live_…" where Node reports Bearer \${VENDOR_API_KEY}. Do not go looking for the rule that decides when it folds. It is a transpiler's internal choice rather than a documented boundary, and two modules on the same Bun version can differ. Whether a secret in an outer binding escapes therefore depends on the runtime and on how the module was transformed, and it is not a boundary worth building a habit on. Treat everything reachable from the action as exposed, which is what the refusal assumes. WebJs refuses rather than emitting it. The refusal covers every shape that is not the binding: formaction= anywhere, action=\${fn} on a tag that is not a <form>, a quoted action="\${fn}" or mixed action="/x/\${fn}" (quoting turns a binding hole back into a plain attribute), and a function wrapped in an array (action=\${[fn]}), which stringifies its elements the same way. One shape it does not cover: a hole inside an HTML comment is emitted raw by the renderer, so commenting a working form out does NOT disable the interpolation, it turns the binding back into a leak. Delete the form rather than commenting around it. .action=\${fn} on a native form is refused at SSR too, even though the property is dropped there, so a page cannot render clean on the server and then throw on hydration, where the reflected IDL attribute would carry the source into the live DOM.
+ Assume outer values are exposed too. On Node, Function.prototype.toString returns source text, so a module-scope const the body reads appears only as its identifier. That is NOT a guarantee you can rely on: Bun transpiles a module before the engine sees it and can fold a module-scope string literal straight into the body, so the same action reports Authorization: "Bearer sk_live_…" where Node reports Bearer \${VENDOR_API_KEY}. Do not go looking for the rule that decides when it folds. It is a transpiler's internal choice rather than a documented boundary, and two modules on the same Bun version can differ. Whether a secret in an outer binding escapes therefore depends on the runtime and on how the module was transformed, and it is not a boundary worth building a habit on. Treat everything reachable from the action as exposed, which is what the refusal assumes. WebJs refuses rather than emitting it. The refusal covers every shape that is not the binding: an unsupported formaction= shape, action=\${fn} on a tag that is not a <form>, a quoted action="\${fn}" or mixed action="/x/\${fn}" (quoting turns a binding hole back into a plain attribute), and a function wrapped in an array (action=\${[fn]}), which stringifies its elements the same way. One shape it does not cover: a hole inside an HTML comment is emitted raw by the renderer, so commenting a working form out does NOT disable the interpolation, it turns the binding back into a leak. Delete the form rather than commenting around it. .action=\${fn} on a native form is refused at SSR too, even though the property is dropped there, so a page cannot render clean on the server and then throw on hydration, where the reflected IDL attribute would carry the source into the live DOM.
Two things stay legal , because neither stringifies its value: a custom element's .action property (an author-defined property, not the reflected IDL attribute a native <form> has, so <my-el .action=\${fn}> is fine), and an unquoted @action=\${fn} event listener, where a function is exactly what is wanted. One qualification on the first: that holds for a plain property, and NOT if the component declares it reflect: true. Reflection is a separate path that writes String(value) into the attribute without passing through the template commit sites this guard covers, so a reflecting action prop still emits the function's source. That path is a general problem with reflecting any function-valued prop rather than an action one, and it is tracked separately. Quoting a binding hole turns it back into a plain attribute, so @action="\${fn}" IS refused; that is the practical reason invariant 4 requires @, . and ? holes to be unquoted. ?action=\${fn} is refused as well: it never leaked, but a truthy function would silently emit a bare action="", which is never what anyone meant.
- Fix: write the binding exactly: an unquoted action=\${importedAction} on the <form> itself, with the function imported from a 'use server' module. Do not add method or enctype; the renderer supplies both. For a form with several submit buttons, bind one action and dispatch on a button's name, since formaction=\${fn} is refused. A string action is unaffected. See Server Actions and Progressive Enhancement .
+ Fix: write the binding exactly: an unquoted action=\${importedAction} on the <form> itself, or formaction=\${importedAction} on a <button> inside a bound form, with the function imported from a 'use server' module. Use a <button> rather than an <input type="submit">, whose value is both the identity channel and the visible label. Do not add submitter name, value, form, or static formaction attributes. Do not add method or enctype; the renderer supplies both. A string action is unaffected. See Server Actions and Progressive Enhancement .
A form submission answered with a 405
Symptom: submitting a form returns 405 Method Not Allowed with Allow: GET, HEAD, and the page itself renders fine on a GET.
Cause: the form binds no action. A page has no action export, so a bare <form method="post"> has nothing to run: the url exists and only renders, which is what the 405 says. The other way to get one is binding an action whose file declares export const method = 'GET'; a GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST. That case answers Allow: GET, and webjs check's form-action-not-a-get-action rule catches it before it ships.
Fix: bind the action (<form action=\${submitFeedback}>), or drop the method export from the action's file so it is an ordinary POST.
+ A render fails on a button's formmethod or formenctype
+ Symptom: a page that renders fine with JavaScript on fails at render with <button formenctype="text/plain"> inside a bound <form action=\${action}> cannot work, or the same message naming formmethod.
+ Cause: a submitter's formmethod / formenctype overrides the form's own for that button, and a bound action needs a POST body the server can parse as multipart/form-data or application/x-www-form-urlencoded. A GET sends no body at all, and text/plain is flagged by the HTML spec as not intended for machine parsing. Either one submits fine with JavaScript (the client router posts FormData and ignores the attribute) and is a bare 405 without it, so it is refused at render rather than shipped as a form that works one way only. The check runs on EVERY submitter inside a bound form, whether or not that button binds an action of its own, which is why it can fire on a button you never touched.
+ Fix: drop the attribute and let the form's own method="post" and enctype apply, which is what the renderer already supplies. If the button was meant to go somewhere else entirely, give it a plain formaction="/url"; that retargets the submission away from the bound action and its own formmethod is then left alone. A formmethod="dialog" button is never refused either, because it dismisses a <dialog> instead of submitting.
+
A form re-renders asking you to submit again
Symptom: a submission comes back at 422 with a message about the page having been updated, and the typed values still filled in.
Cause: the identity the form submitted names an action file this build does not have. That is deploy skew: the form was rendered by an older build and submitted against a newer one, so the hash no longer resolves. Re-rendering the page is the deliberate answer, because a 404 would discard everything typed and a silent success would report a write that never happened.