From ae2278fdcf036fa33ab15b6e04f3b7c74b82451e Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 1 Aug 2026 20:06:58 +0530 Subject: [PATCH 01/18] feat(core): support formaction=${action} on submitters (#1207) --- .../webjs/references/muscle-memory-gotchas.md | 3 +- AGENTS.md | 4 +- packages/core/src/form-action.js | 363 ++++++------------ packages/core/src/render-client.js | 148 ++++--- packages/core/src/render-server.js | 304 ++++++++------- .../form-action-attr-guard-client.test.js | 2 +- .../rendering/form-action-attr-guard.test.js | 28 +- .../form-action-binding-client.test.js | 2 +- .../rendering/form-action-binding.test.js | 43 ++- packages/server/src/check.js | 4 +- packages/server/src/form-dispatch.js | 3 +- .../server/test/routing/form-dispatch.test.js | 34 ++ test/bun/form-action-guard.mjs | 29 +- .../bun/form-action-submitter-parity.test.mjs | 44 +++ .../app/docs/migrating-from-nextjs/page.ts | 2 +- .../app/docs/progressive-enhancement/page.ts | 2 +- website/app/docs/server-actions/page.ts | 2 +- website/app/docs/troubleshooting/page.ts | 2 +- 18 files changed, 529 insertions(+), 490 deletions(-) create mode 100644 test/bun/form-action-submitter-parity.test.mjs diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md index c4bd5808d..a06526679 100644 --- a/.agents/skills/webjs/references/muscle-memory-gotchas.md +++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md @@ -83,7 +83,8 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that | `action=${fn}` unquoted, on a `
` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified | | `action=${fn}` on any other tag | yes | `action` submits nothing off a ``, so it is an ordinary attribute and the function would be stringified | | `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute | -| `formaction=` anywhere | yes | not supported YET rather than impossible (tracked in #1207). Today, write one form per action, or bind one action and dispatch on a submit button's `name="intent"` | +| `formaction=${fn}` unquoted, on a submitter inside a bound form | **no, it BINDS** | per-submitter server action binding (#1207): emits `
, whose label ` + + `is its children.`, ); } return; @@ -242,7 +260,7 @@ export function assertSubmitterType(tag, type) { throw new Error( `[webjs] formaction=\${action} on <${t}>${value ? ` type="${value}"` : ''} ` + `requires a submitter control, and formaction is inert on anything else. ` - + `Use `, host), - /is not a server action|requires the enclosing /, + /is not a server action/, ); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'no source in the live DOM'); }); test('client re-render swapping in an upper-case ACTION=${fn} throws, live DOM stays clean', () => { diff --git a/packages/core/test/rendering/form-action-binding-client.test.js b/packages/core/test/rendering/form-action-binding-client.test.js index a3d4e7434..adb5d1153 100644 --- a/packages/core/test/rendering/form-action-binding-client.test.js +++ b/packages/core/test/rendering/form-action-binding-client.test.js @@ -524,16 +524,20 @@ test('submitter non-POST formmethod is refused on the client (get and PATCH)', ( test('submitter controls and conflicting attributes are refused on the client', () => { const action = HOISTED(); - for (const tpl of [ - html``, - html`
`, - html`
`, - html`
`, - html`
`, - html`
`, - html`
`, + // Paired with the message each guard produces, for the reason spelled out in + // the SSR twin of this test: one shared alternation matches every message in + // the module and proves only that something threw. + for (const [tpl, expected] of [ + [html`
`, /requires a submitter control/], + [html`
`, /requires a submitter control/], + [html`
`, /coordinate pairs/], + [html`
`, /also its visible label/], + [html`
`, /requires a submitter control/], + [html`
`, /already carries a "value" attribute/], + [html`
`, /cannot also carry a plain formaction attribute/], + [html`
`, /cannot be used with a "form" attribute/], ]) { - assert.throws(() => render(tpl, document.createElement('div')), /submitter|value|formaction|form.*attribute/); + assert.throws(() => render(tpl, document.createElement('div')), expected); } }); @@ -694,3 +698,50 @@ test('the client identity field carries a value ATTRIBUTE, matching SSR markup', assert.equal(field.getAttribute('value'), ID); assert.match(host.querySelector('form').innerHTML, /value="a1b2c3d4e5\/submitFeedback"/); }); + +// --------------------------------------------------------------------------- +// A `.prop` spelling on a submitter, the twin of the form-level `.method` / +// `.enctype` refusal. Refused on BOTH sides, because SSR drops a native `.prop` +// while a browser reflects it: ``, + html`
`, + html`
`, + html`
`, + ]) { + assert.throws(() => render(tpl, document.createElement('div')), /reflected IDL attribute/); + } +}); + +test('the client .prop refusal leaves ordinary controls alone', () => { + const formAction = HOISTED(); + const buttonAction = HOISTED(); + const host = document.createElement('div'); + render( + html`
`, + host, + ); + assert.equal(host.querySelector('button').getAttribute('name'), '__webjs_action'); +}); + +test('a formaction binding on is refused on the client too', () => { + // The identity has to occupy `value`, which on this control is its visible + // label, so the binding would render a button captioned with the action id. + const formAction = HOISTED(); + const buttonAction = HOISTED(); + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /also its visible label/, + ); +}); diff --git a/packages/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index ad855e700..5acf2c051 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -379,6 +379,35 @@ test('formaction=${fn} submitter refusals: name attribute, input type=image, unp ); }); +test('a formaction binding on is refused for its label', async () => { + // `` IS a submitter, so Part B still judges it, but the + // identity has to occupy `value`, which on this control is also the visible + // caption. Binding would render a button captioned with the action id, and + // the only fix (`value="Publish"`) is the channel the identity needs. + withResolver(); + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /also its visible label/, + ); + // Part B still reaches it, so the control is not simply ignored. + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /formmethod=/, + ); + // And a plain labelled one renders untouched. + const ok = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(ok, //); +}); + test('formaction submitters require an actual submit control', async () => { withResolver(); for (const tpl of [ @@ -393,15 +422,25 @@ test('formaction submitters require an actual submit control', async () => { }); test('formaction submitters refuse conflicting author attributes', async () => { - withResolver(); - for (const tpl of [ - html`
`, - html`
`, - html`
`, - html`
`, - html`
`, + // Each row asserts the message its OWN guard produces. A shared alternation + // like /value|formaction|form.*attribute/ matches every message in this + // module (they all contain the literal `formaction=${action}`), so it + // degenerates to "an Error was thrown" and a wrong-guard-fired regression + // would sail through. + withResolver(); + for (const [tpl, expected] of [ + [html`
`, + /already carries a "value" attribute/], + [html`
`, + /already carries a "value" attribute/], + [html`
`, + /cannot also carry a plain formaction attribute/], + [html`
`, + /cannot also carry a plain formaction attribute/], + [html`
`, + /cannot be used with a "form" attribute/], ]) { - await assert.rejects(() => renderToString(tpl, { ssr: true }), /value|formaction|form.*attribute/); + await assert.rejects(() => renderToString(tpl, { ssr: true }), expected); } }); @@ -600,3 +639,109 @@ test('the streaming machine applies Part B identically', async () => { )); assert.match(ok, /formmethod="dialog"/); }); + +// --------------------------------------------------------------------------- +// Boundness is BEST EFFORT in SSR too, which an earlier version got wrong. +// +// A COMPONENT renders its own template in a separate pass (`injectDSD` walks the +// already-emitted HTML and renders each component), so that pass has no view of +// the host page and cannot see the enclosing `
`. Treating that as "no +// form" refused a perfectly good per-row button, and because component SSR +// errors are ISOLATED, production returned 200 with the button silently gone. +// +// So the scan distinguishes cannot-tell from conclusively-none, and only the +// latter refuses. +// --------------------------------------------------------------------------- + +test('a submitter rendered by a component inside a bound form binds', async () => { + withResolver(); + const { WebComponent } = await import('../../src/component.js'); + class RowActions extends WebComponent({}) { + render() { return html``; } + } + RowActions.register('row-actions-bind'); + const out = await renderToString( + html`
`, + { ssr: true, dev: false }, + ); + assert.match(out, /`, { ssr: true }), + /requires the enclosing
to also be bound/, + ); + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /requires the enclosing
to also be bound/, + 'and the scope really does close at
', + ); +}); + +test('an UNBOUND form is refused, which is a different answer from cannot-tell', async () => { + withResolver(); + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /requires the enclosing
to also be bound/, + ); +}); + +// --------------------------------------------------------------------------- +// A `.prop` spelling on a submitter, the twin of the form-level `.method` / +// `.enctype` refusal. `name`, `value`, `formAction`, `formMethod` and +// `formEnctype` are all REFLECTED IDL attributes on a submitter, so a property +// binding is dropped at SSR and written to the attribute in the browser: the +// page renders on the server and throws on hydration. +// --------------------------------------------------------------------------- + +test('a reflected .prop on a submitter is refused, in both machines', async () => { + withResolver(); + const refused = [ + html`
`, + html`
`, + html`
`, + html`
`, + ]; + for (const tpl of refused) { + await assert.rejects(() => renderToString(tpl, { ssr: true }), /reflected IDL attribute/); + await assert.rejects(() => drain(renderToStream(tpl, { ssr: false })), /reflected IDL attribute/); + } +}); + +test('the submitter .prop refusal does not fire on ordinary controls', async () => { + // These properties reflect on ANY control, so an ungated check refused + // ``, an ordinary field that has nothing to do with the + // action. + withResolver(); + const ok = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(ok, /name="__webjs_action"/, 'the real binding still applies'); +}); + +test('an empty author name after the hole is refused, not shipped as a duplicate', async () => { + // `name=${null}` emits `name=""`. The parse keeps the LAST duplicate, so + // reading the value back found `''` and waved through a tag carrying TWO + // `name` attributes. A browser keeps the FIRST, so whichever came first would + // silently win, and SSR would ship markup the client never produces. + withResolver(); + for (const tpl of [ + html`
`, + html`
`, + ]) { + await assert.rejects(() => renderToString(tpl, { ssr: true }), /already carries a "name" attribute/); + } +}); diff --git a/packages/core/test/rendering/render-server-streaming.test.js b/packages/core/test/rendering/render-server-streaming.test.js index 7b6205952..cec1f1645 100644 --- a/packages/core/test/rendering/render-server-streaming.test.js +++ b/packages/core/test/rendering/render-server-streaming.test.js @@ -385,3 +385,49 @@ test('renderToStream: an @event hole is dropped', async () => { assert.ok(!out.includes('@click'), 'the authored attribute text is gone'); assert.match(out, /Go<\/button>/); }); + +/* ---------------- rawtext entry is per exit branch (#1207 regression) ---------------- */ + +test('a start tag ending on a BARE attribute keeps its body escaped', async () => { + // Five `>` exits close a start tag, and only two of them (`tag-name` and + // `in-tag`) ever entered rawtext. The three attribute exits always forced + // `text`, so `` from + // escaped into RAW script. Whether that escaping is right is a separate + // question; flipping it as a side effect of a form-action change is not. + const evil = ''; + for (const [label, tpl] of [ + ['bare attribute', html``], + ['unquoted value', html``], + ['style, unquoted', html``], + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.ok(out.includes('</script>'), `${label}: the body stays escaped`); + assert.ok(!out.includes('onerror=alert(1)>'), `${label}: no raw injection`); + } +}); + +test('a start tag ending on whitespace or a QUOTED value still enters rawtext', async () => { + // The counterfactual for the test above: the two exits that always did enter + // rawtext must keep doing so, or the guard would be satisfied by a renderer + // that simply escaped everything. + const raw = 'a < b && c > d'; + for (const [label, tpl] of [ + ['no attributes', html``], + ['quoted value', html``], + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.ok(out.includes(raw), `${label}: the body is emitted raw`); + } +}); + +test('the streaming machine matches on both counts', async () => { + const evil = ''; + const escaped = await streamText(renderToStream(html``, { ssr: false })); + assert.ok(escaped.includes('</script>'), 'bare attribute stays escaped'); + const rawOut = await streamText(renderToStream(html``, { ssr: false })); + assert.ok(rawOut.includes('x < y'), 'no attributes still raw'); +}); diff --git a/packages/server/test/check/form-action-not-a-get-action.test.js b/packages/server/test/check/form-action-not-a-get-action.test.js index 8b7fb0230..6269a43fc 100644 --- a/packages/server/test/check/form-action-not-a-get-action.test.js +++ b/packages/server/test/check/form-action-not-a-get-action.test.js @@ -237,14 +237,14 @@ export default () => html\`
\`; `, }); assert.equal(hits(await checkConventions(dir)).length, 1); diff --git a/packages/server/test/routing/form-dispatch.test.js b/packages/server/test/routing/form-dispatch.test.js index 4fae6e01c..f48f20c88 100644 --- a/packages/server/test/routing/form-dispatch.test.js +++ b/packages/server/test/routing/form-dispatch.test.js @@ -201,8 +201,13 @@ export default () => html\`

ok

\`; }); test('multi-submitter form dispatch: last __webjs_action entry wins (submitter precedence)', async () => { + // The two actions redirect to DIFFERENT targets on purpose. With both + // returning the same result, any identity produced a 303 and the assertion + // held whether the dispatcher took the first entry or the last, which is the + // one line this test exists to pin. Asserting the `location` is what makes + // first-wins observable. const appDir = makeApp({ - 'modules/multi/actions/multi.server.ts': `'use server';\nexport async function formAction() { return { success: true }; }\nexport async function buttonAction() { return { success: true }; }\n`, + 'modules/multi/actions/multi.server.ts': `'use server';\nexport async function formAction() { return { success: true, redirect: '/ran-form' }; }\nexport async function buttonAction() { return { success: true, redirect: '/ran-button' }; }\n`, 'app/multi/page.ts': ` import { html } from ${CORE}; import { formAction, buttonAction } from '../../modules/multi/actions/multi.server.ts'; @@ -232,6 +237,19 @@ test('multi-submitter form dispatch: last __webjs_action entry wins (submitter p })); assert.equal(postResp.status, 303); + assert.equal(postResp.headers.get('location'), '/ran-button', + 'the LAST entry, the submitter, is the action that runs'); + + // Counterfactual: the form identity alone still runs the form's action, so + // the assertion above is about precedence and not about which id was sent. + const formOnly = new URLSearchParams(); + formOnly.append('__webjs_action', ids[0]); + const formResp = await app.handle(new Request('http://x/multi', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: formOnly.toString(), + })); + assert.equal(formResp.headers.get('location'), '/ran-form'); }); test('a page that binds no action answers 405 on POST, not 404', async () => { diff --git a/test/bun/form-action-submitter-parity.test.mjs b/test/bun/form-action-submitter-parity.test.mjs index 0a67e02a4..bf8716a49 100644 --- a/test/bun/form-action-submitter-parity.test.mjs +++ b/test/bun/form-action-submitter-parity.test.mjs @@ -45,16 +45,19 @@ test('SSR: formaction=${fn} on submitter with name attribute throws refusal', as test('SSR: submitter guards stay identical for Bun and Node', async () => { const refused = [ - ['text input', html`
`], - ['hidden input', html`
`], - ['image input', html`
`], - ['button input', html`
`], - ['value attribute', html`
`], - ['static formaction', html`
`], - ['form attribute', html`
`], + ['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/], ]; - for (const [label, tpl] of refused) { - await assert.rejects(() => renderToString(tpl, { ssr: true }), /submitter|value|formaction|form.*attribute/, label); + // 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); } }); diff --git a/website/app/docs/server-actions/page.ts b/website/app/docs/server-actions/page.ts index cf594969d..562c5dead 100644 --- a/website/app/docs/server-actions/page.ts +++ b/website/app/docs/server-actions/page.ts @@ -473,7 +473,7 @@ export default function NewPost({ actionData }: {

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.

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 real submit control (<button> or <input type="submit">) 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. An <input type="image"> is refused too: it submits name.x / name.y coordinates rather than name=value, so the identity would never arrive. 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.

+

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 25419230a..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>, or an unquoted formaction=\${fn} on a submit button or <input type="submit"> 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.

+

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 285710fb7..226e94dd6 100644 --- a/website/app/docs/troubleshooting/page.ts +++ b/website/app/docs/troubleshooting/page.ts @@ -47,7 +47,7 @@ export default function Troubleshooting() {

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: 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, or formaction=\${importedAction} on a submit button or <input type="submit"> inside a bound form, with the function imported from a 'use server' module. 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.

+

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.

From 38a49a58e394800a1e20c8d7a0070d8831c9ba6e Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 3 Aug 2026 16:31:14 +0530 Subject: [PATCH 15/18] fix(core): carry the form scope into Suspense and component scans (#1207) Round-two review findings, each reproduced before fixing and each pinned by a test that reds when the fix is reverted. The Suspense plumbing never reached the renderer that actually runs. The shell records a boundary's form scope, but the page pipeline in `@webjsdev/server` drains `ctx.pending` itself through `renderToString`, which had no way to receive it, so a boundary re-render restarted at "no form". A ``, + host, + ); + assert.equal(host.querySelectorAll('button').length, 2, 'both plain submitters render'); +}); + +test('an empty name PART on a bound submitter is refused, matching SSR', () => { + // Judged on the part, not on what it resolved to. `name=${null}` leaves no + // attribute on the client while SSR emits `name=""` beside the identity, so + // reading the live value back returned '' and the client waved through a + // template SSR hard-refuses. + const formAction = HOISTED(); + const buttonAction = HOISTED(); + for (const value of [null, '', undefined]) { + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /already carries a "name" attribute/, + `name=\${${String(value)}}`, + ); + } +}); + test('a formaction binding on is refused on the client too', () => { // The identity has to occupy `value`, which on this control is its visible // label, so the binding would render a button captioned with the action id. diff --git a/packages/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index 5acf2c051..b4bbfd800 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -698,6 +698,99 @@ test('an UNBOUND form is refused, which is a different answer from cannot-tell', ); }); +test("the 'unbound' state is what refuses inside a COMPONENT's own form", async () => { + // The test above cannot observe the 'unbound' transition: a top-level scan + // starts at 'none', so that template is refused either way, and deleting the + // transition left the whole suite green. 'unbound' differs from 'none' only + // under an 'unknown' seed, which is the component pass. + // + // Without it, a component's own GET-defaulting form would happily bind a + // submitter inside it, which is the silently-posts-nowhere shape the guard + // exists to prevent. The component's SSR error is isolated, so the proof is + // that the component renders EMPTY rather than emitting the identity. + withResolver(); + const { WebComponent } = await import('../../src/component.js'); + class OwnUnbound extends WebComponent({}) { + render() { return html`
`; } + } + OwnUnbound.register('own-unbound-form'); + const out = await renderToString( + html`
`, + { ssr: true, dev: false }, + ); + assert.equal((out.match(/name="__webjs_action"/g) || []).length, 1, + "only the page form's identity is emitted; the component's submitter never bound"); + assert.ok(!out.includes(' { + // `` used to hard-reset the scope to 'none', which asserted a fact the + // component scan cannot know: closing a form of its own teaches it nothing + // about the host page. A bound submitter written after it was then refused + // and, being isolated, vanished from a 200. + withResolver(); + const { WebComponent } = await import('../../src/component.js'); + class ClosesOwnForm extends WebComponent({}) { + render() { + return html`
`; + } + } + ClosesOwnForm.register('closes-own-form'); + const out = await renderToString( + html`
`, + { ssr: true, dev: false }, + ); + assert.match(out, /`), + })}`); + assert.match(shell, /

loading<\/p><\/webjs-boundary>/); + assert.equal(pending[0].formScope, 'bound', 'the shell records the scope'); + assert.match(parts[0], /`) })}`, + html`

${Suspense({ fallback: html`

l

`, children: Promise.resolve(html``) })}
`, + ]) { + await assert.rejects(() => drainSuspense(shell), /requires the enclosing
to also be bound/); + } +}); + // --------------------------------------------------------------------------- // A `.prop` spelling on a submitter, the twin of the form-level `.method` / // `.enctype` refusal. `name`, `value`, `formAction`, `formMethod` and diff --git a/packages/server/src/ssr.js b/packages/server/src/ssr.js index 57ac242c0..69886e794 100644 --- a/packages/server/src/ssr.js +++ b/packages/server/src/ssr.js @@ -2042,7 +2042,7 @@ function reachedVendorSpecifiers(graph, entryFiles, componentUrls, appDir, elida * @param {string} prefix * @param {string} bodyHtml * @param {string} closer - * @param {{ pending: {id: string, promise: Promise}[], nextId: number }} ctx + * @param {{ pending: {id: string, promise: Promise, formScope?: 'none'|'unbound'|'bound'|'unknown'}[], nextId: number }} ctx * @param {number} status * @param {Request | undefined} req * @param {URL | undefined} url @@ -2098,7 +2098,15 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url, try { const resolved = await p.promise; const sub = { pending: [], nextId: ctx.nextId, dev: ctx.dev }; - const html = await renderToString(resolved, { ssr: true, suspenseCtx: sub }); + // Carry the boundary's form scope (#1207). This is a fresh scan + // that cannot see the shell the boundary sits in, so without it + // a ``, + html`
`, + ]) { + const host = document.createElement('div'); + render(tpl, host); + assert.equal(host.querySelector('button').getAttribute('name'), '__webjs_action', + 'a hole that emits no name leaves the identity channel free'); + } + // A TRUTHY boolean hole does emit `name=""`, so it collides and must refuse, + // which is what SSR does with the resulting duplicate. + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /already carries a "name" attribute/, + ); +}); diff --git a/packages/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index b4bbfd800..2d097fa3c 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -838,3 +838,41 @@ test('an empty author name after the hole is refused, not shipped as a duplicate await assert.rejects(() => renderToString(tpl, { ssr: true }), /already carries a "name" attribute/); } }); + +test('a .formAction prop is refused on a submitter that binds nothing', async () => { + // The narrowing that spares `.name` / `.value` on a non-binding submitter must + // NOT spare `.formAction`. SSR drops the prop, so with JS off the button + // submits to the page and runs the bound action; a browser reflects it, so + // with JS on the button posts elsewhere and the action never runs. That is the + // works-one-way-only shape, and it is why the STATIC `formaction="/url"` stays + // fine: both renderers see that one and agree. + withResolver(); + const tpl = html`
`; + await assert.rejects(() => renderToString(tpl, { ssr: true }), /reflected IDL attribute/); + await assert.rejects(() => drain(renderToStream(tpl, { ssr: false })), /reflected IDL attribute/); + + const ok = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(ok, /formaction="\/search"/, 'the static retarget is still allowed'); +}); + +test('a falsy boolean name hole leaves the identity channel free', async () => { + // The SSR half of the client test with the same name: `?name=${false}` emits + // nothing, so there is no collision and the binding applies. + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, /x<\/button>/); + // Truthy emits `name=""`, which collides with the identity. + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /already carries a "name" attribute/, + ); +}); From 42e7c400868ae16dbba9075cc7c5d06e6c7e4898 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 3 Aug 2026 17:30:11 +0530 Subject: [PATCH 17/18] docs(core): record the attribute-based limit of the client Part B sweep The sweep reads the live DOM, so a `.formMethod` / `.formEnctype` / `.formAction` PROPERTY on a submitter that binds no action is refused by SSR and invisible to it. Uniform across all three, and `.formAction` is worse: a browser reflects it, so the sweep reads the attribute and treats the button as deliberately retargeted, skipping the check entirely. Left as is. It is the server-strict direction, so the page never ships, and the only way through is a component that renders only on the client. Closing it would mean building template records for submitters that bind nothing. --- packages/core/src/form-action.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/core/src/form-action.js b/packages/core/src/form-action.js index 5eba43426..88111ee8d 100644 --- a/packages/core/src/form-action.js +++ b/packages/core/src/form-action.js @@ -985,6 +985,24 @@ export function assertSubmitterStartTag(startTag, tag, shape) { * in a nested template that has not been inserted yet is simply not in the * query, and SSR is the renderer that sees every page. * + * ATTRIBUTE-BASED, which is a second limit worth stating. This reads the live + * DOM, so it cannot see a divergence that exists only in the TEMPLATE: a + * `.formMethod` / `.formEnctype` / `.formAction` PROPERTY on a submitter that + * binds no action of its own is refused by SSR (`assertConvergentSubmitter` + * runs there from the parsed start tag) and is invisible here, because the + * client builds a record only for a submitter that owns an action hole. Worse + * for `.formAction` specifically: a browser reflects it, so the query below + * reads a `formaction` attribute and treats the button as deliberately + * retargeted, skipping the check. + * + * Left as is on purpose. It is the server-strict direction, so the page never + * ships: SSR renders every page and refuses the shape before a browser sees it. + * The only way through is a component that renders ONLY on the client, and + * closing it would mean building template records for submitters that bind + * nothing, which is a wider change than the hole justifies. The reverse + * asymmetry, client refusing what SSR accepts, is the one this module treats as + * unacceptable, and none of these are that. + * * @param {HTMLFormElement} form * @returns {void} */ From 2971c95103096b4d820acfd8c372585986aa4aa0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Mon, 3 Aug 2026 18:10:53 +0530 Subject: [PATCH 18/18] fix(core): keep the value channel in step with name, and settle boundness once Final-review findings. None blocked the merge; all three are cheap and two close divergences in the direction this module treats as unacceptable, so they are fixed rather than deferred. `value` was left behind when `name` became kind-aware. A falsy boolean hole emits nothing at SSR, so ``, + host, + ); + assert.equal(host.querySelector('button').getAttribute('value'), ID, + 'a falsy boolean hole emits nothing, so the identity channel is free'); + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /already carries a "value" attribute/, + ); +}); + +test('the enclosing-form verdict does not change between renders', () => { + // Whether `enclosingForm` resolves depends on whether the element happened to + // be in the tree when it reconciled: not on a first render (the fragment is + // detached) and yes on an update. Re-asking made the SAME template with the + // SAME values bind at first paint and then throw on an arbitrary later + // re-render, which is far worse to diagnose than refusing at first paint. + const buttonAction = HOISTED(); + const outer = document.createElement('form'); + outer.setAttribute('method', 'post'); + document.body.appendChild(outer); + const host = document.createElement('div'); + outer.appendChild(host); + try { + const tpl = (n) => html``; + render(tpl(1), host); + render(tpl(2), host); + assert.equal(host.querySelector('button').getAttribute('name'), '__webjs_action', + 'the verdict is stable across passes'); + } finally { outer.remove(); } +}); + +test('a bound form still binds its submitter on every re-render', () => { + // The counterfactual for the stability guard: it must not become a blanket + // skip that stops the binding from being re-applied. + const formAction = HOISTED(); + const buttonAction = HOISTED(); + const host = document.createElement('div'); + const tpl = (n) => html`
`; + render(tpl(1), host); + render(tpl(2), host); + const button = host.querySelector('button'); + assert.equal(button.getAttribute('name'), '__webjs_action'); + assert.equal(button.getAttribute('value'), ID); +});