diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 66d82b1ed..c9bf72124 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -105,7 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection 9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s). 10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators. 11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor). -12. A form that writes binds its action: `
`. A quoted `action="${fn}"`, a `formaction=${fn}`, `action=${fn}` off a ``, a bound form with `method="get"`, and a non-action function all throw. A page has no `action` export, so a bare `` is a 405. +12. A form that writes binds its action: ``, or a per-button `
+ +`; +``` + +The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed. The submitter must be a ` + + + + + `; +} diff --git a/examples/blog/modules/feedback/actions/publish-draft.server.ts b/examples/blog/modules/feedback/actions/publish-draft.server.ts new file mode 100644 index 000000000..4205c705c --- /dev/null +++ b/examples/blog/modules/feedback/actions/publish-draft.server.ts @@ -0,0 +1,27 @@ +'use server'; + +/** + * Per-submitter form-action e2e fixture (#1207), the button-level override. + * + * Bound with `formaction=${publishDraft}` on the "Publish" button inside the + * bound `
` on `/feedback/triage`. Pressing that + * button runs THIS action instead of the form's. + * + * With JavaScript off there is no interception anywhere: the browser submits + * the pressed button's own `name`/`value` pair alongside the form's hidden + * identity field, and the dispatcher takes the last one. That is the headline + * claim of #1207, and the e2e asserts it in a real browser with scripting + * disabled. + */ +export async function publishDraft(formData: FormData) { + const note = String(formData.get('note') || '').trim(); + if (!note) { + return { + success: false as const, + fieldErrors: { note: 'Write something first' }, + values: { note }, + status: 422, + }; + } + return { success: true as const, redirect: '/feedback/triage/done?ran=publishDraft' }; +} diff --git a/examples/blog/modules/feedback/actions/save-draft.server.ts b/examples/blog/modules/feedback/actions/save-draft.server.ts new file mode 100644 index 000000000..28c6bfb33 --- /dev/null +++ b/examples/blog/modules/feedback/actions/save-draft.server.ts @@ -0,0 +1,25 @@ +'use server'; + +/** + * Per-submitter form-action e2e fixture (#1207), the form-level default. + * + * `/feedback/triage` binds this action on the `` itself, so it runs when + * the form is submitted by anything that does not name its own action: the + * "Save draft" button, or a bare Enter in the text field. + * + * It reports WHICH action ran, because the whole point of the per-button + * binding is that a different one runs for a different button, and "the form + * submitted successfully" cannot tell those apart. + */ +export async function saveDraft(formData: FormData) { + const note = String(formData.get('note') || '').trim(); + if (!note) { + return { + success: false as const, + fieldErrors: { note: 'Write something first' }, + values: { note }, + status: 422, + }; + } + return { success: true as const, redirect: '/feedback/triage/done?ran=saveDraft' }; +} diff --git a/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts b/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts index 514544697..a0f83144c 100644 --- a/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts +++ b/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts @@ -8,10 +8,16 @@ import { deleteTodo } from './delete-todo.server.ts'; // THIS action, and the submit button's own `name="intent"` says which mutation // to run, which is how one form serves several buttons. // -// Why an intent dispatcher rather than binding each button to its own action: -// `formaction=${fn}` on a submit button is not supported yet (tracked in -// #1207). So a form binds ONE action, and a form with several buttons -// dispatches on the intent here. +// Why an intent dispatcher here rather than binding each button to its own +// action: this form carries the todo's `id` on a hidden input and needs the +// SAME id for whichever mutation runs, so one action reading both fields is the +// simpler shape. When the buttons need no shared payload, bind each one +// directly instead, with `formaction=${action}` on a , whose label ` + + `is its children.`, + ); + } + return; + } + throw new Error( + `[webjs] formaction=\${action} on <${t}>${value ? ` type="${value}"` : ''} ` + + `requires a submitter control, and formaction is inert on anything else. ` + + `Use
+ `, + host, + ); + const form = host.querySelector('form'); + const button = host.querySelector('button'); + + // No submitter pressed: the form's own identity is what the server sees. + assert.equal(new FormData(form).get('__webjs_action'), 'a1b2c3d4e5/save'); + + // Pressed: BOTH entries ride the submission, and the dispatcher takes the + // LAST. That ordering is a fact about the DOM (the form's hidden field is + // its first child, so a submitter's entry always follows it), which is why + // it is asserted here rather than assumed. + const fd = new FormData(form, button); + const all = fd.getAll('__webjs_action'); + assert.equal(all.length, 2, 'the form field and the submitter both submit'); + assert.equal(all[0], 'a1b2c3d4e5/save'); + assert.equal(all[all.length - 1], 'a1b2c3d4e5/remove', 'the pressed button wins'); + assert.equal(fd.get('title'), 'hi', 'alongside the real fields'); + assert.equal(button.hasAttribute('formaction'), false, 'no formaction url is emitted'); + }); + + test('an unpressed sibling submitter contributes nothing', () => { + // The reason the submitter's own name/value is the right channel: a browser + // submits it ONLY for the button that was pressed, unlike a hidden input. + const host = mount(); + render( + html`
+ + +
`, + host, + ); + const form = host.querySelector('form'); + const [first, second] = host.querySelectorAll('button'); + assert.deepEqual( + new FormData(form, second).getAll('__webjs_action'), + ['a1b2c3d4e5/save', 'a1b2c3d4e5/two'], + 'only the pressed button of the two appears', + ); + assert.deepEqual( + new FormData(form, first).getAll('__webjs_action'), + ['a1b2c3d4e5/save', 'a1b2c3d4e5/one'], + ); + }); + + test('a submitter built by a detached nested template still binds', () => { + // The shape the feature exists for, a per-row action button in a list. + // Array and repeat() items are built DETACHED, so the button cannot reach + // the
in the parent template while it reconciles. SSR renders this + // perfectly, so refusing it on the client would mean a page that renders on + // the server and crashes on hydration. + const host = mount(); + const rows = [1, 2].map((n) => html``); + render(html`${rows}
`, host); + + const form = host.querySelector('form'); + const buttons = host.querySelectorAll('button'); + assert.equal(buttons.length, 2); + assert.deepEqual( + new FormData(form, buttons[1]).getAll('__webjs_action'), + ['a1b2c3d4e5/save', 'a1b2c3d4e5/del'], + 'the detached row button still submits its own action', + ); + }); + + test('the identity never carries the action source, on the submitter path either', () => { + const host = mount(); + let threw = null; + try { + render(html`
`, host); + } catch (e) { threw = e; } + assert.ok(threw, 'an unidentifiable submitter action must refuse'); + assert.ok(/is not a server action/.test(threw.message), threw.message); + assert.ok(!document.body.innerHTML.includes('BROWSER_LEAK_MARKER'), 'no source in the document'); + }); + + test('formmethod="dialog" survives on a plain submitter inside a bound form', () => { + // Part B refuses values that cannot submit, but a dialog dismissal never + // submits at all. The browser is where `button.formMethod` reflects, so + // this is where "we left it alone" is really observable. + const host = mount(); + render( + html`
`, + host, + ); + assert.equal(host.querySelector('button').formMethod, 'dialog'); + }); + }); diff --git a/packages/core/test/rendering/browser/ssr-client-parity.test.js b/packages/core/test/rendering/browser/ssr-client-parity.test.js index 0aa8902b0..0b952f618 100644 --- a/packages/core/test/rendering/browser/ssr-client-parity.test.js +++ b/packages/core/test/rendering/browser/ssr-client-parity.test.js @@ -410,6 +410,7 @@ suite('SSR/client parity: form actions (#1155)', () => { // without JS and urlencoded with it. 'inert encoding= attribute': () => html`
`, 'inert encoding= with an unsubmittable value': () => html`
`, + 'submitter formaction inside bound form': () => html`
`, }; for (const [name, tpl] of Object.entries(ACCEPTS)) { @@ -433,7 +434,30 @@ suite('SSR/client parity: form actions (#1155)', () => { 'quoted action hole is a stringify': [() => html`
`, /interpolated into/], 'array-wrapped action': [() => html`
`, /interpolated into/], 'action off a form': [() => html`
`, /interpolated into/], - 'formaction anywhere': [() => html``, /interpolated into/], + // #1207: the submitter's form is RIGHT THERE in the same template, so both + // renderers can see it is unbound and both must say so. + 'submitter inside an unbound form': [ + () => html`
`, + /requires the enclosing
to also be bound/, + ], + 'submitter that is not a submit control': [ + () => html`
`, + /submitter control/, + ], + 'submitter carrying its own name': [ + () => html`
`, + /already carries a "name" attribute/, + ], + // #1207 Part B: a submitter that binds nothing, whose own formenctype would + // still defeat the bound form it sits in. + 'unparseable submitter enctype': [ + () => html`
`, + /formenctype=/, + ], + 'non-POST submitter method': [ + () => html`
`, + /formmethod=/, + ], 'a function that is not an action': [() => html`
{}}>
`, /is not a server action/], 'prop binding on a bound form': [() => html`
`, /also binds \./], 'two action holes': [() => html`
`, /two action=/], @@ -463,6 +487,43 @@ suite('SSR/client parity: form actions (#1155)', () => { }); } + /** + * The ONE asymmetry in #1207, stated here rather than left to be discovered. + * + * "Is my enclosing form bound" is a question SSR always answers and the client + * sometimes cannot. SSR reads a linear byte stream, so an open `
` either + * bound an action or did not. The client reconciles a template whose root may + * be a DocumentFragment that is not in the tree yet, and a submitter with no + * form ABOVE IT IN ITS OWN TEMPLATE is genuinely ambiguous there: it looks + * identical whether it is a stray button or a list row about to be inserted + * into a bound form by its parent. Those two are indistinguishable at that + * moment, and the list row is the shape the whole feature exists for. + * + * So the client BINDS when it cannot tell, and SSR refuses. The asymmetry is + * safe in that direction and only in that direction: the server renders every + * page, so a genuinely form-less submitter is still refused loudly before + * anything ships. The reverse (client refuses, SSR accepts) is what would + * render a page on the server and crash it on hydration. + */ + const SSR_ONLY_REFUSES = { + 'submitter with no form at all': [ + () => html``, + /requires the enclosing to also be bound/, + ], + }; + + for (const [name, [tpl, pattern]] of Object.entries(SSR_ONLY_REFUSES)) { + test(`SSR refuses, client defers: ${name}`, async () => { + const r = await bothWays(tpl); + assert.ok(r.ssrErr, `SSR must refuse: ${name}`); + assert.ok(pattern.test(r.ssrErr), `SSR reason (${r.ssrErr})`); + assert.ok(!r.clientErr, `client must NOT refuse what it cannot know: ${name} (${r.clientErr})`); + // Deferring the question must not defer the LEAK guard: the client still + // binds an identity rather than stringifying the function. + assert.ok(!/PARITY_SECRET/.test(String(r.ssrErr) + String(r.client)), 'no source on either path'); + }); + } + test('the identity field is submitted, which is what all of this is for', () => { // The end state, read the way a browser reads it: `new FormData(form)` is // exactly what a native submission serialises. diff --git a/packages/core/test/rendering/form-action-attr-guard-client.test.js b/packages/core/test/rendering/form-action-attr-guard-client.test.js index 5425e1f51..96797d273 100644 --- a/packages/core/test/rendering/form-action-attr-guard-client.test.js +++ b/packages/core/test/rendering/form-action-attr-guard-client.test.js @@ -74,11 +74,18 @@ test('client render of mixed action="/x/${fn}" throws', () => { // four clauses, and a normalization that only the SSR tests covered would leave // a client re-render free to write the source into a live DOM. test('client render of camelCase formAction=${fn} throws', () => { + // Pinned to ONE reason. `fakeAction` carries no identity, so the client enters + // the binding path (attribute names fold case, so `formAction` binds like + // `formaction`) and refuses at the identity check, which runs before the + // enclosing-form question. Accepting either message would let this pass with + // the case folding it exists to cover removed, since a `formAction` treated + // as a plain attribute refuses for a different reason entirely. const host = document.createElement('div'); assert.throws( () => render(html``, host), - /function was interpolated into formaction=/, + /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-attr-guard.test.js b/packages/core/test/rendering/form-action-attr-guard.test.js index f4bfa3e54..dc10c4662 100644 --- a/packages/core/test/rendering/form-action-attr-guard.test.js +++ b/packages/core/test/rendering/form-action-attr-guard.test.js @@ -151,26 +151,20 @@ test('mixed hole action="/x/${fn}" throws', async () => { ); }); -test('formaction=${fn} on a submit button throws', async () => { +test('formaction=${fn} on a submit button inside an unbound form throws', async () => { await assert.rejects( () => renderToString(html`
`, { ssr: true }), - /function was interpolated into formaction=/, + /requires the enclosing
to also be bound/, ); }); -// Case normalization was the ONE branch of the guard with no test. Mutation -// testing every branch against this suite, seven of eight mutants red it and -// this was the survivor: dropping `.toLowerCase()` from isFormActionAttr kept -// all 46 unit tests and the whole Bun table green while `` -// and `
`, { ssr: true }), + /requires the enclosing
to also be bound/, + ); + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), /function was interpolated into formaction=/, ); }); @@ -194,7 +188,7 @@ test('a quoted mixed-case Action="${fn}" throws (sigil strip and case-fold compo test('the streaming renderer folds case too', async () => { await assert.rejects( () => drain(renderToStream(html``, { ssr: false })), - /function was interpolated into formaction=/, + /requires the enclosing
to also be bound/, ); }); @@ -279,9 +273,13 @@ test('the streaming renderer refuses a mixed hole', async () => { ); }); -test('the streaming renderer refuses formaction', async () => { +test('the streaming renderer refuses formaction on an unbound button', async () => { await assert.rejects( () => drain(renderToStream(html``, { ssr: false })), + /requires the enclosing to also be bound/, + ); + await assert.rejects( + () => drain(renderToStream(html`
`, { ssr: false })), /function was interpolated into formaction=/, ); }); 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 4150b4cf2..5bab41881 100644 --- a/packages/core/test/rendering/form-action-binding-client.test.js +++ b/packages/core/test/rendering/form-action-binding-client.test.js @@ -23,12 +23,13 @@ before(() => { globalThis.HTMLElement = window.HTMLElement; }); -let html, render, FORM_ACTION_ID_KEY, asyncAppend; +let html, render, FORM_ACTION_ID_KEY, asyncAppend, repeat; before(async () => { ({ html } = await import('../../src/html.js')); ({ render } = await import('../../src/render-client.js')); ({ FORM_ACTION_ID_KEY } = await import('../../src/form-action.js')); ({ asyncAppend } = await import('../../src/directives.js')); + ({ repeat } = await import('../../src/repeat.js')); }); /** @@ -435,7 +436,7 @@ test('a failed render does not poison the NEXT one', () => { const h1 = document.createElement('div'); assert.throws( () => render(html`
`, h1), - /function was interpolated into/, + /cannot work|requires the enclosing
/, ); const h2 = document.createElement('div'); @@ -507,8 +508,360 @@ test('two action holes are refused with the BOUND one written second', () => { /two action=/, ); }); + +test('submitter non-POST formmethod is refused on the client (get and PATCH)', () => { + const host = document.createElement('div'); + const HOISTED = stub(ID); + assert.throws( + () => render(html`
`, host), + /formmethod="get"/, + ); + assert.throws( + () => render(html`
`, host), + /formmethod="PATCH"/, + ); +}); + +test('submitter controls and conflicting attributes are refused on the client', () => { + const action = HOISTED(); + // 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')), expected); + } +}); + +test('duplicate formaction holes on one submitter are refused on the client', () => { + const action = HOISTED(); + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /two formaction=/, + ); +}); + +test('a submitter in a nested template sees its enclosing form binding', () => { + const formAction = HOISTED(); + const buttonAction = HOISTED(); + const host = document.createElement('div'); + render(html`
${html``}
`, host); + assert.equal(host.querySelector('button').getAttribute('value'), ID); + assert.equal(host.querySelector('button').getAttribute('name'), '__webjs_action'); +}); + +test('releasing a submitter binding removes its stale identity', () => { + const formAction = HOISTED(); + const buttonAction = HOISTED(); + const host = document.createElement('div'); + const tpl = (action) => html`
`; + render(tpl(buttonAction), host); + render(tpl(null), host); + const button = host.querySelector('button'); + assert.equal(button.hasAttribute('name'), false); + assert.equal(button.hasAttribute('value'), false); +}); // NOTE: the release path's other guard, "an unbound form keeps a `.method` the // template writes as a PROPERTY", is asserted in `browser/form-action-guard.test.js` // instead. It turns entirely on the write reflecting to the content attribute, // and linkedom's HTMLFormElement has an empty class body with no reflection at // all, so a `.method` assertion here would pass whether or not the fix is present. + +// --------------------------------------------------------------------------- +// #1207: a submitter built by a DETACHED nested template. +// +// `repeat()` and a plain array both build each item through `buildDetached`, +// which reconciles before the nodes are in the tree, so a button there cannot +// reach the `
` that lives in the parent template. That is the single most +// ordinary shape this feature exists for, a per-row action button in a list, +// and SSR renders it perfectly. An earlier version asked the enclosing form +// whether it was bound, got no answer because there was no form to ask, and +// THREW, so the page rendered on the server and crashed on hydration. +// +// The rule these pin: an unresolved form SKIPS the boundness assertion and the +// binding is applied. SSR is the renderer that sees every page and refuses a +// genuinely unbound form there, loudly, before anything ships. +// --------------------------------------------------------------------------- + +test('a submitter inside repeat() binds instead of refusing', () => { + const formAction = HOISTED(); + const rowAction = HOISTED(); + const host = document.createElement('div'); + render( + html`${repeat([1, 2], (n) => n, (n) => html``)}
`, + host, + ); + const buttons = host.querySelectorAll('button'); + assert.equal(buttons.length, 2); + for (const button of buttons) { + assert.equal(button.getAttribute('name'), '__webjs_action'); + assert.equal(button.getAttribute('value'), ID); + assert.equal(button.hasAttribute('formaction'), false, 'no formaction url is emitted'); + } +}); + +test('a submitter inside a plain array binds instead of refusing', () => { + const formAction = HOISTED(); + const rowAction = HOISTED(); + const host = document.createElement('div'); + render( + html`
${[html``]}
`, + host, + ); + const button = host.querySelector('button'); + assert.equal(button.getAttribute('name'), '__webjs_action'); + assert.equal(button.getAttribute('value'), ID); +}); + +test('a detached submitter still refuses what the template alone decides', () => { + // Skipping the boundness question does not relax the refusals that are + // properties of the TEMPLATE, which are answerable with no form in sight. + const formAction = HOISTED(); + const rowAction = HOISTED(); + const host = document.createElement('div'); + assert.throws( + () => render( + html`
${[html``]}
`, + host, + ), + /name/, + ); + assert.throws( + () => render( + html`
${[html``]}
`, + host, + ), + /submitter control/, + ); +}); + +test('a submitter whose enclosing form is resolvable and UNBOUND is still refused', () => { + // The skip is narrow: it applies when there is no form to ask, not when the + // answer is no. An inline submitter can always reach its form. + const rowAction = HOISTED(); + const host = document.createElement('div'); + assert.throws( + () => render(html`
`, host), + /requires the enclosing
to also be bound/, + ); +}); + +// --------------------------------------------------------------------------- +// #1207 Part B on the client, so a component-only page (never SSR'd) gets the +// same answer the server would have given. +// --------------------------------------------------------------------------- + +test('the client refuses an unparseable submitter enctype inside a bound form', () => { + const formAction = HOISTED(); + const host = document.createElement('div'); + assert.throws( + () => render(html`
`, host), + /formenctype=/, + ); + assert.throws( + () => render(html`
`, host), + /formmethod=/, + ); +}); + +test('the client leaves dialog and retargeted submitters alone', () => { + const formAction = HOISTED(); + const host = document.createElement('div'); + render( + html`
`, + host, + ); + assert.equal(host.querySelectorAll('button')[0].getAttribute('formmethod'), 'dialog'); + assert.equal(host.querySelectorAll('button')[1].getAttribute('formaction'), '/search'); +}); + +test('the client identity field carries a value ATTRIBUTE, matching SSR markup', () => { + // Written through setAttribute rather than the `value` IDL property. A `.value` + // assignment sets the input's value and dirty flag but leaves no content + // attribute, so a client-created field would serialize without one while SSR + // always writes it in full, and anything reading the markup (a morph, an + // outerHTML snapshot) would see two different forms for one template. + const host = document.createElement('div'); + render(html`
`, host); + const field = host.querySelector('input[name="__webjs_action"]'); + 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 .name / .value on a NON-binding submitter is left alone', () => { + // A plain ``, + 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. + const formAction = HOISTED(); + const buttonAction = HOISTED(); + assert.throws( + () => render( + html`
`, + document.createElement('div'), + ), + /also its visible label/, + ); +}); + +test('a name HOLE is judged by what SSR would emit for it, not by its presence', () => { + // Counting any part called `name` refused templates SSR renders happily. SSR + // emits `name=""` for an attribute hole whatever it resolved to, but emits + // NOTHING for a falsy boolean hole and nothing for an `@name` listener, so + // those two must bind here exactly as they do on the server. Getting this + // wrong is the render-on-the-server, throw-on-hydration direction. + const formAction = HOISTED(); + const buttonAction = HOISTED(); + for (const tpl of [ + html`
`, + 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/, + ); +}); + +test('a value HOLE is judged by what SSR would emit, exactly as a name hole is', () => { + // The twin of the `name` test above. Both identity channels ask the same + // question through one predicate, so a falsy boolean hole binds on both sides + // and a truthy one refuses on both. Keeping them in step matters because the + // failure is silent: SSR renders and the client throws on hydration. + const formAction = HOISTED(); + const buttonAction = HOISTED(); + const host = document.createElement('div'); + render( + html`
`, + 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); +}); diff --git a/packages/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index 007fe17d1..2d097fa3c 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -247,11 +247,10 @@ test('a refused tag does not poison the NEXT form in the same template', async ( assert.match(out, new RegExp(`name="${FORM_ACTION_FIELD}"`), 'the second form still binds'); }); -test('binding is scoped to
: the same function elsewhere still refuses', async () => { +test('binding is scoped to and bound submitters: non-action shapes refuse', async () => { withResolver(); for (const tpl of [ html`
`, - html``, html`
`, ]) { let msg = ''; @@ -259,6 +258,19 @@ test('binding is scoped to
: the same function elsewhere still refuses', a assert.match(msg, /function was interpolated into/, 'refused as a stringify, not bound'); assert.doesNotMatch(msg, /SECRET/); } + // Standalone submitter outside a bound form throws the form-binding requirement error: + let unboundMsg = ''; + try { await renderToString(html``, { ssr: true }); } catch (e) { unboundMsg = String(e.message); } + assert.match(unboundMsg, /requires the enclosing to also be bound/); +}); + +test('formaction=${fn} on submitter inside a bound form emits submitter action identity', async () => { + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true } + ); + assert.match(out, /`, { ssr: true }), + /already carries a "name" attribute/, + ); + + // 2. : + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /is not supported on /, + ); + + // 3. Submitter formenctype="text/plain": + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /formenctype="text\/plain"/, + ); + + // 4. Submitter formmethod="get": + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /formmethod="get"/, + ); + + // 5. Submitter formmethod="PATCH": + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /formmethod="PATCH"/, + ); +}); + +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 [ + html`
`, + html`
`, + html`
`, + html`
`, + html`
`, + ]) { + await assert.rejects(() => renderToString(tpl, { ssr: true }), /submitter control|type="image"/); + } +}); + +test('formaction submitters refuse conflicting author attributes', async () => { + // 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 }), expected); + } +}); + +test('duplicate formaction holes on one submitter are refused', async () => { + withResolver(); + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /two formaction=/, + ); +}); + +test('formaction submitters work when rendered by a nested template', async () => { + withResolver(); + const buttons = () => html``; + const out = await renderToString(html`
${buttons()}
`, { ssr: true }); + assert.match(out, /`, + html`
`, + html`
`, + ]) { + await assert.rejects(() => renderToString(tpl, { ssr: true }), /formenctype=/); + } +}); + +test('a non-POST formmethod on a plain submitter inside a bound form is refused', async () => { + withResolver(); + for (const tpl of [ + html`
`, + html`
`, + html`
`, + ]) { + await assert.rejects(() => renderToString(tpl, { ssr: true }), /formmethod=/); + } +}); + +test('a padded formmethod is refused, matching the form-level untrimmed rule', async () => { + // `formmethod` is an enumerated attribute matched against exact keywords with + // no whitespace stripping, so `" post "` falls to the invalid-value default + // and the button submits as a GET. Trimming here would accept it and ship the + // silently-posts-nowhere submitter the refusal exists to prevent. + withResolver(); + await assert.rejects( + () => renderToString( + html`
`, + { ssr: true }, + ), + /formmethod=" post "/, + ); +}); + +test('parseable submitter enctypes stay fully supported', async () => { + // Part B refuses VALUES that cannot work, never the attribute itself. + withResolver(); + for (const enc of ['multipart/form-data', 'application/x-www-form-urlencoded']) { + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, new RegExp(`formenctype="${enc.replace(/[/]/g, '\\/')}"`)); + } +}); + +test('formmethod="dialog" is not a submission and is left alone', async () => { + // A native dismissal, never a submission, so there is no body for + // the action to miss. Refusing it would break a legal pattern the client + // router already skips deliberately. + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, /`, + { ssr: true }, + ), + /dialog/, + ); +}); + +test('a submitter retargeted by a static formaction keeps its own method', async () => { + // A plain `formaction="/url"` points the submission away from the page's + // bound action entirely, so a GET there is an ordinary form the author is + // entitled to write and the bound form says nothing about it. + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, /formaction="\/search"/); + assert.match(out, /formmethod="get"/); +}); + +test('Part B ignores controls that do not submit', async () => { + // `formmethod` / `formenctype` are inert on anything that is not a submitter, + // so flagging them there would be a false positive on valid markup. + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, /name="q"/); +}); + +test('Part B applies only INSIDE a bound form', async () => { + // An ordinary hand-written form is not this module's business. + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, /formenctype="text\/plain"/); +}); + +test('the bound-form scope closes at ', async () => { + // `insideBoundForm` is scoped by the tag stream, so a submitter written after + // the bound form has closed is outside it and judged by nothing. + withResolver(); + const out = await renderToString( + html`
`, + { ssr: true }, + ); + assert.match(out, /formmethod="get"/); +}); + +test('Part B reaches a submitter arriving through a nested template', async () => { + // The flag is threaded into nested renders, so a button rendered by a child + // template inside a bound form is judged exactly like an inline one. + withResolver(); + const row = () => html``; + await assert.rejects( + () => renderToString(html`
${row()}
`, { ssr: true }), + /formenctype=/, + ); +}); + +test('the streaming machine applies Part B identically', async () => { + // `streamTemplate` is a SEPARATE state machine that inherits nothing, and + // #1154 already shipped a guard in one machine and not the other once. + withResolver(); + await assert.rejects( + () => drain(renderToStream( + html`
`, + { ssr: false }, + )), + /formenctype=/, + ); + await assert.rejects( + () => drain(renderToStream( + html`
`, + { ssr: false }, + )), + /formmethod=/, + ); + const ok = await drain(renderToStream( + html`
`, + { ssr: false }, + )); + 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/, + ); +}); + +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 +// `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/); + } +}); + +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/, + ); +}); diff --git a/packages/core/test/rendering/render-server-streaming.test.js b/packages/core/test/rendering/render-server-streaming.test.js index e91f107a1..cec1f1645 100644 --- a/packages/core/test/rendering/render-server-streaming.test.js +++ b/packages/core/test/rendering/render-server-streaming.test.js @@ -109,6 +109,26 @@ test('renderToStream: basic template produces a readable stream', async () => { assert.match(out, /

stream<\/p>/); }); +test('renderToStream: ssr:false preserves progressive text-hole chunks', async () => { + const later = new Promise((resolve) => setTimeout(() => resolve('later'), 25)); + const stream = renderToStream( + html`

first

${Promise.resolve('now')}

${later}

`, + { ssr: false }, + ); + const reader = stream.getReader(); + const first = await reader.read(); + assert.equal(first.done, false); + assert.match(first.value, /first/); + assert.doesNotMatch(first.value, /later/); + const rest = []; + for (;;) { + const next = await reader.read(); + if (next.done) break; + rest.push(next.value); + } + assert.match(rest.join(''), /later/); +}); + test('renderToStream: ssr:true path runs DSD injection and enqueues full HTML', async () => { const stream = renderToStream(html`

ssr

`); const out = await streamText(stream); @@ -330,3 +350,84 @@ test('Suspense nested in a template is replaced by its boundary HTML', async () ); assert.match(out, /
loading<\/span><\/webjs-boundary><\/main>/); }); + +/* ---------------- property holes in the streaming machine ---------------- */ + +test('renderToStream: a .prop hole on a custom element is dropped, attribute text and all', async () => { + // The buffered machine slices the authored `.name=` text off `out` BEFORE it + // decides what to emit in its place. The streaming machine has to do the same + // or the authored text survives and whatever it emits lands glued to it, as + // ``, which parses as an + // unquoted `.fallback` value and swallows the rest of the tag. + const out = await streamText(renderToStream( + html`loading

`}>content
`, + { ssr: false }, + )); + assert.ok(!out.includes('.fallback='), 'the authored attribute text is gone'); + assert.ok(!out.includes('data-webjs-fallback'), 'no fallback attribute: nothing reads one on this path'); + assert.match(out, /content<\/span><\/webjs-suspense>/); +}); + +test('renderToStream: a .prop hole on a native element is dropped', async () => { + const out = await streamText(renderToStream( + html``, + { ssr: false }, + )); + assert.ok(!out.includes('.value='), 'the authored attribute text is gone'); + assert.ok(!out.includes('typed'), 'a native .prop never reaches the markup'); +}); + +test('renderToStream: an @event hole is dropped', async () => { + const out = await streamText(renderToStream( + html``, + { ssr: false }, + )); + 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/src/check.js b/packages/server/src/check.js index 02ae494cd..69e3bb01b 100644 --- a/packages/server/src/check.js +++ b/packages/server/src/check.js @@ -1395,11 +1395,14 @@ export async function checkConventions(appDir) { // with no hole in it. That distinction is the whole carve-out: the // framework's own website renders this exact shape as a code sample. const { redacted, literals } = redactToPlaceholders(content); - // A hole binds a form when the literal segment IMMEDIATELY before it - // ends inside a `
` and - // a `formaction=` on a button out: both are refused at render time, and - // neither is this rule's failure mode. + // A hole binds an action when the literal segment IMMEDIATELY before it + // ends inside a start tag at the attribute that tag can really bind: + // `action=` on a `` (#1155), or `formaction=` on a `
\`; +`, + }); + const v = hits(await checkConventions(dir)); + assert.equal(v.length, 1, 'exactly one violation'); + assert.match(v[0].file, /page\.ts/); + assert.match(v[0].message, /method = 'GET'/); + await rm(dir, { recursive: true, force: true }); +}); + +test('does NOT flag a submitter bound to a POST action (counterfactual)', async () => { + const dir = await makeApp({ + 'modules/save/actions/save.server.ts': POST_ACTION, + 'app/page.ts': `import { html } from '@webjsdev/core'; +import { saveIt } from '../modules/save/actions/save.server.ts'; +export default () => html\`
\`; +`, + }); + assert.equal(hits(await checkConventions(dir)).length, 0); + await rm(dir, { recursive: true, force: true }); +}); + +test('does NOT flag formaction= on a
, which binds nothing', async () => { + const dir = await makeApp({ + 'modules/read/actions/read.server.ts': GET_ACTION, + 'app/page.ts': `import { html } from '@webjsdev/core'; +import { readIt } from '../modules/read/actions/read.server.ts'; +export default () => html\`
\`; +`, + }); + assert.equal(hits(await checkConventions(dir)).length, 0, 'a form has no formaction binding'); + await rm(dir, { recursive: true, force: true }); +}); + +test('does NOT flag action= on a \`; +`, + }); + assert.equal(hits(await checkConventions(dir)).length, 0, 'a button has no action binding'); + await rm(dir, { recursive: true, force: true }); +}); + +test('flags a GET action bound through a second submitter in the same form', async () => { + const dir = await makeApp({ + 'modules/read/actions/read.server.ts': GET_ACTION, + 'modules/save/actions/save.server.ts': POST_ACTION, + 'app/page.ts': `import { html } from '@webjsdev/core'; +import { readIt } from '../modules/read/actions/read.server.ts'; +import { saveIt } from '../modules/save/actions/save.server.ts'; +export default () => html\`
\`; +`, + }); + assert.equal(hits(await checkConventions(dir)).length, 1); + await rm(dir, { recursive: true, force: true }); +}); diff --git a/packages/server/test/routing/form-dispatch.test.js b/packages/server/test/routing/form-dispatch.test.js index d77239926..f48f20c88 100644 --- a/packages/server/test/routing/form-dispatch.test.js +++ b/packages/server/test/routing/form-dispatch.test.js @@ -200,6 +200,58 @@ export default () => html\`

ok

\`; assert.equal(resp.headers.get('location'), '/save'); }); +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, 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'; + export default () => html\` +
+ +
+ \`; + `, + }); + const app = await createRequestHandler({ appDir, dev: true }); + await app.warmup(); + + const getResp = await app.handle(new Request('http://x/multi')); + const htmlStr = await getResp.text(); + const ids = Array.from(htmlStr.matchAll(/name="__webjs_action" value="([^"]*)"/g), m => m[1]); + assert.equal(ids.length, 2, 'renders both form action and button formaction identities'); + + const body = new URLSearchParams(); + body.append('__webjs_action', ids[0]); + body.append('__webjs_action', ids[1]); + + const postResp = await app.handle(new Request('http://x/multi', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + })); + + 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 () => { // The path exists and renders; the method is what is wrong. Under the page // `action` export this was a 404, which said the url did not exist. diff --git a/test/bun/form-action-guard.mjs b/test/bun/form-action-guard.mjs index efba0d13a..238d75d5b 100644 --- a/test/bun/form-action-guard.mjs +++ b/test/bun/form-action-guard.mjs @@ -74,21 +74,21 @@ async function drain(stream) { const refused = { 'action="${fn}"': () => html`
`, 'mixed action="/x/${fn}"': () => html`
`, - 'formaction=${fn}': () => html``, 'quoted prop .action="${fn}"': () => html`
`, 'quoted bool ?action="${fn}"': () => html`
`, 'quoted event @action="${fn}"': () => html`
`, 'native prop .action=${fn}': () => html`
`, 'unquoted bool ?action=${fn}': () => html`
`, 'array-wrapped action=${[fn]}': () => html`
`, - // Case folding, on both runtimes: every other row here spells the attribute - // lowercase, and with those alone the `.toLowerCase()` in isFormActionAttr - // could be deleted with this whole table still green while `formAction=` - // leaked. camelCase is React's spelling, so it is the likeliest arrival. - 'camelCase formAction=${fn}': () => html``, + 'quoted formaction="${fn}"': () => html`
`, 'reflecting prop .formAction=${fn} on a button': () => html``, }; +const refusedUnboundSubmitter = { + 'formaction=${fn}': () => html``, + 'camelCase formAction=${fn}': () => html``, +}; + /** * The two shapes #1155 turned into a BINDING rather than a stringify: * `action=${fn}` and its case-folded spelling, on a `
`. They still refuse @@ -98,6 +98,7 @@ const refused = { const refusedAsUnidentified = { 'action=${fn}': () => html`
`, 'upper-case ACTION=${fn}': () => html`
`, + 'formaction=${fn} inside bound form': () => html`
`, }; for (const [name, mk] of Object.entries(refused)) { @@ -122,6 +123,18 @@ for (const [name, mk] of Object.entries(refused)) { `[${runtime}] streaming refusal must not carry the source (${name})`); } +for (const [name, mk] of Object.entries(refusedUnboundSubmitter)) { + let threw = null; + try { await renderToString(mk(), { ssr: true }); } catch (e) { threw = e; } + assert.ok(threw, `[${runtime}] buffered SSR must refuse ${name}`); + assert.match(threw.message, /requires the enclosing
to also be bound/, `[${runtime}] ${name} message`); + + let streamThrew = null; + try { await drain(renderToStream(mk(), { ssr: false })); } catch (e) { streamThrew = e; } + assert.ok(streamThrew, `[${runtime}] streaming SSR must refuse ${name}`); + assert.match(streamThrew.message, /requires the enclosing to also be bound/, `[${runtime}] ${name} message`); +} + for (const [name, mk] of Object.entries(refusedAsUnidentified)) { for (const [machine, run] of [ ['buffered', () => renderToString(mk(), { ssr: true })], diff --git a/test/bun/form-action-submitter-parity.test.mjs b/test/bun/form-action-submitter-parity.test.mjs new file mode 100644 index 000000000..bf8716a49 --- /dev/null +++ b/test/bun/form-action-submitter-parity.test.mjs @@ -0,0 +1,100 @@ +/** + * Cross-runtime parity test for per-submitter `formaction=${action}` (#1207). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderToString, html, setFormActionResolver } from '@webjsdev/core'; + +setFormActionResolver(async (fn) => { + return fn.name ? `hash/${fn.name}` : null; +}); + +async function saveAction() {} +async function deleteAction() {} + +test('SSR: formaction=${fn} on submitter inside bound form emits submitter name=__webjs_action', async () => { + const tpl = html` + + +
+ `; + const out = await renderToString(tpl, { ssr: true }); + assert.match(out, //); + assert.match(out, /`; + await assert.rejects( + () => renderToString(tpl, { ssr: true }), + /requires the enclosing
to also be bound/, + ); +}); + +test('SSR: formaction=${fn} on submitter with name attribute throws refusal', async () => { + const tpl = html` + + +
+ `; + 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``; + const out = await renderToString(html`
${buttons()}
`, { 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`
${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.