diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index 0ebd7f322..f81983028 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -106,7 +106,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: `
`, or a per-button `
+ +`; +``` + +One consequence worth knowing rather than discovering: no `formaction` url is emitted (an empty one is an HTML conformance error), so the submission targets whatever the FORM targets. A form declaring its own `action="/x"` sends its buttons to `/x`, which is native precedence. The action still runs if `/x` is a PAGE route, because the identity travels in the body, but against a `route.ts` or another origin the identity is ignored and nothing runs. In dev the client logs a warning at submit time naming the url. Leaving the form's `action` off, the ordinary shape, keeps the submission on the current page. + +The submitter must be a ` + + + + + `; +} diff --git a/examples/blog/modules/feedback/components/publish-button.ts b/examples/blog/modules/feedback/components/publish-button.ts new file mode 100644 index 000000000..fa59935bf --- /dev/null +++ b/examples/blog/modules/feedback/components/publish-button.ts @@ -0,0 +1,38 @@ +import { WebComponent, html } from '@webjsdev/core'; +import { publishDraft } from '#modules/feedback/actions/publish-draft.server.ts'; + +/** + * A bound submitter rendered by a COMPONENT (#1307). + * + * This is the case the old enclosing-form check could never resolve. SSR + * renders a component's template in a SEPARATE pass, walking the + * already-emitted HTML, so this template's scan has no view of the page that + * placed the tag and cannot see whether the surrounding `
` bound + * anything. The scan therefore had a third answer, cannot-tell, and had to + * bind on it: refusing would have rejected a per-row button in a list, and a + * refused component is ISOLATED at SSR, so production would have returned 200 + * with the button silently missing. + * + * Binding on cannot-tell is what produced the silent failure, because the + * button then carried an identity with no way to send it as a POST. + * + * Nothing here asks about the form any more. The renderer gives this button + * `formmethod="post"` and `formenctype="multipart/form-data"` alongside the + * identity, so it submits correctly inside `/feedback/triage-split`'s unbound, + * method-less form, with JavaScript on or off. + * + * The component itself is display-only, so elision drops its module from the + * browser. That is the point rather than an oversight: the button is a plain + * HTML submitter once SSR has run, and the no-JS e2e proves it needs no + * script at all. + */ +class PublishButton extends WebComponent({}) { + render() { + return html` + + `; + } +} +PublishButton.register('publish-button'); 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 a0f83144c..2a6cff268 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 @@ -12,9 +12,11 @@ import { deleteTodo } from './delete-todo.server.ts'; // 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
`, + // #1307: a bound submitter carries its whole submission, so none of these + // depend on the form around it. Each moved here from a refusal table, and + // each must render IDENTICALLY on both renderers, which is what proves the + // two inject the same `formmethod` / `formenctype` pair in the same order. + 'submitter with no form at all': () => html``, + 'submitter inside an unbound form': () => html`
`, + 'submitter inside a form with no method at all': () => html`
`, + 'submitter inside a method=get form': () => html`
`, + // The author's own value wins; only the attribute they did NOT supply is + // injected. Both renderers make that call through `resolveBoundSubmitterAttrs`. + 'bound submitter supplying its own formmethod': () => html``, + 'bound submitter supplying its own formenctype': () => html``, + // HOLE-provided, not static. These are the rows that catch a client record + // which files a submitter's `formmethod` hole under the wrong attribute: + // SSR reads the emitted start tag and is unaffected, so only a DIFFERENTIAL + // row sees it. The first spelling shipped broken and threw + // `formenctype="post"` on hydration for a template SSR renders happily. + 'bound submitter with a HOLE-provided formmethod': () => html``, + 'bound submitter with a HOLE-provided formenctype': () => html``, + 'bound submitter with BOTH provided by holes': () => html``, + // The awkward hole KINDS, which resolve through different commit branches + // than a plain `attr` hole. An `attr-mixed` value is assembled from statics + // plus values, and a FALSY boolean hole emits nothing at all, so the + // framework supplies the attribute exactly as if the template were silent. + // Each is a separate path through `effectiveFormAttr`, and a plain `attr` + // row does not exercise any of them. + 'bound submitter with an attr-mixed formmethod': () => html``, + 'bound submitter with a FALSY boolean formmethod hole': () => html``, + 'bound submitter with a FALSY boolean formenctype hole': () => html``, + // #1307 reverses #1207's Part B: a PLAIN button's own override is a legal + // native instruction, so both renderers leave it exactly as written. + 'plain submitter formmethod=get inside a bound form': () => html`
`, + 'plain submitter formenctype=text/plain inside a bound form': () => html`
`, }; for (const [name, tpl] of Object.entries(ACCEPTS)) { @@ -434,12 +467,6 @@ 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/], - // #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/, @@ -448,16 +475,38 @@ suite('SSR/client parity: form actions (#1155)', () => { () => 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`
`, + // #1307: same-element contradictions on a BOUND submitter. These replace + // #1207's Part B rows, which asked about the button's NEIGHBOUR and are now + // in ACCEPTS. Here the author bound an action to this very button and then + // told the same button to submit in a way that action could never read. + 'unparseable enctype on a BOUND submitter': [ + () => html``, /formenctype=/, ], - 'non-POST submitter method': [ - () => html`
`, + 'non-POST method on a BOUND submitter': [ + () => html``, /formmethod=/, ], + 'dialog method on a BOUND submitter': [ + () => html``, + /formmethod="dialog"/, + ], + // A TRUTHY boolean hole emits `formmethod=""`, an empty enumerated value + // that cannot submit, so it must refuse rather than be treated as absent + // and quietly supplied. This is the submitter twin of the form-level + // `?enctype=${true}` row above. + 'truthy boolean formmethod hole on a BOUND submitter': [ + () => html``, + /cannot work/, + ], + 'padded formmethod on a BOUND submitter': [ + () => html``, + /cannot work/, + ], + 'prop binding on a BOUND submitter': [ + () => html``, + /reflected IDL attribute/, + ], '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=/], @@ -488,41 +537,25 @@ suite('SSR/client parity: form actions (#1155)', () => { } /** - * The ONE asymmetry in #1207, stated here rather than left to be discovered. + * There is deliberately NO third table here any more. + * + * #1207 had one, holding a single row: a `formaction=${fn}` submitter with no + * enclosing form. "Is my enclosing form bound" was a question SSR always + * answered and the client sometimes could not, because the client reconciles + * a template whose root may be a DocumentFragment not yet in the tree, where a + * stray button and a list row about to be inserted into a bound form look + * identical. So SSR refused and the client deferred, and that asymmetry was + * documented here as permanent. * - * "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. + * #1307 removed the question rather than the asymmetry. A bound submitter now + * carries its own `formmethod` and `formenctype`, so no renderer needs to know + * what encloses it, and the row moved to ACCEPTS where both renderers must now + * produce identical bytes for it. * - * 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. + * Keep this table absent. A new "SSR refuses, client defers" entry is a signal + * that a cross-element rule has crept back in, which is exactly the shape both + * issues concluded cannot be enforced honestly. */ - 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 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 dc10c4662..9b03a4926 100644 --- a/packages/core/test/rendering/form-action-attr-guard.test.js +++ b/packages/core/test/rendering/form-action-attr-guard.test.js @@ -152,16 +152,19 @@ test('mixed hole action="/x/${fn}" throws', async () => { }); test('formaction=${fn} on a submit button inside an unbound form throws', async () => { + // The enclosing form no longer decides anything (#1307). What still refuses is + // the LEAK guard: `leaky` is not a registered action, so it has no identity to + // bind and stringifying it would write the function's source into the HTML. await assert.rejects( () => renderToString(html`
`, { ssr: true }), - /requires the enclosing
to also be bound/, + NOT_AN_ACTION, ); }); test('camelCase formAction=${fn} throws on an unbound form (React spells it this way)', async () => { await assert.rejects( () => renderToString(html`
`, { ssr: true }), - /requires the enclosing
to also be bound/, + NOT_AN_ACTION, ); await assert.rejects( () => renderToString(html`
`, { ssr: true }), @@ -188,7 +191,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 })), - /requires the enclosing
to also be bound/, + NOT_AN_ACTION, ); }); @@ -273,10 +276,10 @@ test('the streaming renderer refuses a mixed hole', async () => { ); }); -test('the streaming renderer refuses formaction on an unbound button', async () => { +test('the streaming renderer refuses a formaction that is not an action', async () => { await assert.rejects( () => drain(renderToStream(html``, { ssr: false })), - /requires the enclosing to also be bound/, + NOT_AN_ACTION, ); await assert.rejects( () => drain(renderToStream(html`
`, { ssr: false })), 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 5bab41881..5a778160f 100644 --- a/packages/core/test/rendering/form-action-binding-client.test.js +++ b/packages/core/test/rendering/form-action-binding-client.test.js @@ -646,35 +646,52 @@ test('a detached submitter still refuses what the template alone decides', () => ); }); -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. +test('a submitter whose enclosing form is UNBOUND now binds, carrying its own submission', () => { + // The client used to ask "is my enclosing form bound" and refuse when it could + // reach the form and the answer was no. #1307 removed the question: the button + // supplies `formmethod` / `formenctype` itself, so the form is irrelevant. const rowAction = HOISTED(); const host = document.createElement('div'); - assert.throws( - () => render(html`
`, host), - /requires the enclosing
to also be bound/, - ); + render(html`
`, host); + const btn = host.querySelector('button'); + assert.equal(btn.getAttribute('name'), '__webjs_action'); + assert.equal(btn.getAttribute('formmethod'), 'post'); + assert.equal(btn.getAttribute('formenctype'), 'multipart/form-data'); + assert.equal(btn.hasAttribute('formaction'), false, 'no url is emitted'); }); // --------------------------------------------------------------------------- -// #1207 Part B on the client, so a component-only page (never SSR'd) gets the -// same answer the server would have given. +// The same-element rule 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(); +test('the client refuses a BOUND submitter contradicting its own binding', () => { + const rowAction = HOISTED(); const host = document.createElement('div'); assert.throws( - () => render(html`
`, host), + () => render(html``, host), /formenctype=/, ); assert.throws( - () => render(html`
`, host), + () => render(html``, host), /formmethod=/, ); }); +test('the client leaves a PLAIN submitter\'s own override alone, matching SSR', () => { + // #1207's Part B refused these on both renderers. #1307 allows them on both, + // which is the half that keeps the two in step. + const formAction = HOISTED(); + const host = document.createElement('div'); + render( + html`
`, + host, + ); + const [a, b] = host.querySelectorAll('button'); + assert.equal(a.getAttribute('formenctype'), 'text/plain'); + assert.equal(b.getAttribute('formmethod'), 'get'); +}); + test('the client leaves dialog and retargeted submitters alone', () => { const formAction = HOISTED(); const host = document.createElement('div'); @@ -831,12 +848,17 @@ test('a value HOLE is judged by what SSR would emit, exactly as a name hole is', ); }); -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. +test('a bound submitter binds identically on a first render and an update', () => { + // This used to guard an enclosing-form verdict whose answer depended on + // whether the element happened to be in the tree when it reconciled: no on a + // first render (the fragment is still detached) and yes on an update. That + // asymmetry made the SAME template with the SAME values bind at first paint + // and throw on an arbitrary later re-render. + // + // #1307 deleted the question, so the property now holds for a much better + // reason than a carefully-placed cache: there is nothing left to ask. Kept as + // a regression guard, because any future rule that reads OUTSIDE the element + // reintroduces exactly this first-render-versus-update split. const buttonAction = HOISTED(); const outer = document.createElement('form'); outer.setAttribute('method', 'post'); @@ -852,6 +874,40 @@ test('the enclosing-form verdict does not change between renders', () => { } finally { outer.remove(); } }); +test('releasing a submitter takes back the framework attrs, never the author\'s (#1307)', () => { + // Both halves matter. A released button that keeps `formmethod="post"` no + // longer matches what SSR emits for the same template, and one that loses an + // author's own value has had its markup destroyed by a framework that should + // never have owned it. + // + // Which attribute is whose is RECOMPUTED, not remembered from the bind: it is + // the framework's exactly when the template supplies nothing for it on this + // pass. That is why there is no bookkeeping here to go stale. + const act = HOISTED(); + + const host = document.createElement('div'); + const framework = (v) => html``; + render(framework(act), host); + let btn = host.querySelector('button'); + assert.equal(btn.getAttribute('formmethod'), 'post'); + assert.equal(btn.getAttribute('formenctype'), 'multipart/form-data'); + + render(framework('/plain-url'), host); + btn = host.querySelector('button'); + assert.equal(btn.getAttribute('name'), null, 'the identity is released'); + assert.equal(btn.getAttribute('formmethod'), null, 'and the supplied method with it'); + assert.equal(btn.getAttribute('formenctype'), null, 'and the supplied enctype'); + + const host2 = document.createElement('div'); + const authored = (v) => html``; + render(authored(act), host2); + render(authored('/plain-url'), host2); + const btn2 = host2.querySelector('button'); + assert.equal(btn2.getAttribute('name'), null, 'the identity is still released'); + assert.equal(btn2.getAttribute('formmethod'), 'post', "the AUTHOR's own value survives"); + assert.equal(btn2.getAttribute('formenctype'), null, 'only the supplied one is taken back'); +}); + 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. diff --git a/packages/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index 2d097fa3c..b4a7f9643 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -258,10 +258,10 @@ test('binding is scoped to
and bound submitters: non-action shapes refuse 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/); + // A standalone submitter is NOT a non-action shape. Since #1307 it binds and + // carries its own submission, so it belongs in neither refusal above. + const standalone = await renderToString(html``, { ssr: true }); + assert.match(standalone, /name="__webjs_action"/); }); test('formaction=${fn} on submitter inside a bound form emits submitter action identity', async () => { @@ -270,7 +270,51 @@ test('formaction=${fn} on submitter inside a bound form emits submitter action i html`
`, { ssr: true } ); - assert.match(out, /`, { ssr: true }); + assert.match(bare, /formmethod="post"/, 'the button supplies its own method'); + assert.match(bare, /formenctype="multipart\/form-data"/, 'and its own enctype'); + assert.match(bare, /name="__webjs_action"/); + // No enclosing form at all, and a form that explicitly declares GET. The + // button overrides both, exactly as native HTML says a submitter does. + for (const tpl of [ + html``, + html`
`, + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.match(out, /formmethod="post"/); + assert.match(out, /formenctype="multipart\/form-data"/); + } + // COUNTERFACTUAL: delete the two injections in `bindSubmitterStartTag` and + // every assertion above fails, because the button falls back to whatever the + // enclosing form declares, which here is a GET carrying no body. +}); + +test('the author\'s own formmethod / formenctype wins; only the missing one is injected', async () => { + withResolver(); + const ownMethod = await renderToString( + html``, { ssr: true }); + assert.equal(ownMethod.match(/formmethod=/g).length, 1, 'not duplicated'); + assert.match(ownMethod, /formenctype="multipart\/form-data"/, 'the missing one is still supplied'); + + const ownEnctype = await renderToString( + html``, + { ssr: true }, + ); + assert.equal(ownEnctype.match(/formenctype=/g).length, 1); + assert.match(ownEnctype, /formenctype="application\/x-www-form-urlencoded"/, 'the author\'s value survives'); + assert.match(ownEnctype, /formmethod="post"/); }); test('a bound form nested among siblings does not disturb them', async () => { @@ -380,10 +424,10 @@ 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. + // `` IS a submitter, 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( @@ -392,13 +436,15 @@ test('a formaction binding on is refused for its label', a ), /also its visible label/, ); - // Part B still reaches it, so the control is not simply ignored. + // The label refusal fires FIRST, before any submission attribute is looked + // at, so a bound `` reports the label conflict whatever + // else it carries. await assert.rejects( () => renderToString( - html`
`, + html`
`, { ssr: true }, ), - /formmethod=/, + /also its visible label/, ); // And a plain labelled one renders untouched. const ok = await renderToString( @@ -459,7 +505,7 @@ 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=/); + const out = await renderToString(tpl, { ssr: true }); + assert.match(out, /formenctype="(text\/plain|TEXT\/PLAIN)"/, 'left exactly as written'); } }); -test('a non-POST formmethod on a plain submitter inside a bound form is refused', async () => { +test('a PLAIN submitter\'s own formmethod inside a bound form renders untouched', async () => { withResolver(); for (const tpl of [ html`
`, html`
`, html`
`, + html`
`, ]) { - await assert.rejects(() => renderToString(tpl, { ssr: true }), /formmethod=/); + const out = await renderToString(tpl, { ssr: true }); + assert.match(out, /formmethod=/, 'left exactly as written'); } }); -test('a padded formmethod is refused, matching the form-level untrimmed rule', async () => { +test('a padded formmethod on a BOUND submitter 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. + // silently-posts-nowhere submitter the refusal exists to prevent. Scoped to a + // BOUND submitter now: on a plain one the author owns the consequence. withResolver(); await assert.rejects( () => renderToString( - html`
`, + html``, { ssr: true }, ), /formmethod=" post "/, ); }); +test('a BOUND submitter contradicting its own binding is still refused', async () => { + // The surviving half of Part B, and the whole of the new rule: the author + // bound an action to THIS button and then told THIS button to submit in a way + // that action could never read. + withResolver(); + for (const [tpl, pattern] of [ + [html``, /formmethod=/], + [html``, /formmethod=/], + [html``, /formenctype=/], + ]) { + await assert.rejects(() => renderToString(tpl, { ssr: true }), pattern); + } +}); + test('parseable submitter enctypes stay fully supported', async () => { - // Part B refuses VALUES that cannot work, never the attribute itself. + // The rule 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`
`, + html`
`, { ssr: true }, ); assert.match(out, new RegExp(`formenctype="${enc.replace(/[/]/g, '\\/')}"`)); @@ -572,9 +644,9 @@ test('a submitter retargeted by a static formaction keeps its own method', async 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. +test('formmethod / formenctype on a non-submitting control are inert and untouched', async () => { + // Inert on anything that is not a submitter, so touching them there would be a + // false positive on valid markup. withResolver(); const out = await renderToString( html`
`, @@ -583,8 +655,7 @@ test('Part B ignores controls that do not submit', async () => { 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. +test('an ordinary hand-written form is not this module\'s business', async () => { withResolver(); const out = await renderToString( html`
`, @@ -593,9 +664,10 @@ test('Part B applies only INSIDE a bound form', async () => { 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. +test('a plain submitter after a bound form closes is untouched', async () => { + // There is no enclosing-form scope left to leak (#1307 deleted it), so this + // pins the absence: a plain button anywhere keeps whatever it was written + // with, before or after any form. withResolver(); const out = await renderToString( html`
`, @@ -604,35 +676,37 @@ test('the bound-form scope closes at ', async () => { 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. +test('a plain submitter arriving through a nested template is untouched too', async () => { + // Nothing is threaded into nested renders any more, which is the point: the + // verdict does not depend on how the button reached the page. withResolver(); const row = () => html``; - await assert.rejects( - () => renderToString(html`
${row()}
`, { ssr: true }), - /formenctype=/, - ); + const out = await renderToString(html`
${row()}
`, { ssr: true }); + assert.match(out, /formenctype="text\/plain"/); }); -test('the streaming machine applies Part B identically', async () => { +test('the streaming machine judges a BOUND submitter 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`
`, + html``, { ssr: false }, )), /formenctype=/, ); await assert.rejects( () => drain(renderToStream( - html`
`, + html``, { ssr: false }, )), /formmethod=/, ); + // And INJECTS identically, which is the half a refusal-only test would miss. + const bound = await drain(renderToStream( + html`
`, { ssr: false })); + assert.match(bound, /formmethod="post" formenctype="multipart\/form-data"/); const ok = await drain(renderToStream( html`
`, { ssr: false }, @@ -664,50 +738,37 @@ test('a submitter rendered by a component inside a bound form binds', async () = 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 () => { +test('a form-less submitter BINDS now, which is the whole of #1307', async () => { + // This block used to prove the opposite. SSR distinguished cannot-tell from + // conclusively-none and refused the latter, because a submitter could not + // supply `method="post"` for itself. It can now, so every shape below binds. withResolver(); - await assert.rejects( - () => renderToString( - html`
`, - { ssr: true }, - ), - /requires the enclosing
to also be bound/, - ); + for (const tpl of [ + html``, + html`
`, + html`
`, + html`
`, + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.match(out, /formmethod="post"/); + assert.match(out, /formenctype="multipart\/form-data"/); + } }); -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. +test("a component's own unbound form no longer swallows its submitter", async () => { + // The #1307 failure in its purest form. A component renders in a SEPARATE SSR + // pass with no view of the host page, so the old scan seeded 'unknown' and + // could not judge the enclosing form. Where it thought it COULD judge (the + // component's own form), it refused, and because a component's SSR error is + // ISOLATED the button silently vanished from a page that still returned 200. // - // 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. + // Both halves are fixed by the same change: nothing is judged, so nothing is + // isolated away. withResolver(); const { WebComponent } = await import('../../src/component.js'); class OwnUnbound extends WebComponent({}) { @@ -718,16 +779,12 @@ test("the 'unbound' state is what refuses inside a COMPONENT's own form", async 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. +test('a component that closes its own form still binds a later submitter', async () => { withResolver(); const { WebComponent } = await import('../../src/component.js'); class ClosesOwnForm extends WebComponent({}) { @@ -740,16 +797,20 @@ test("a component that CLOSES its own form keeps deferring, rather than downgrad 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/); + const { parts } = await drainSuspense(shell); + assert.match(parts[0], /formmethod="post" formenctype="multipart\/form-data"/); } }); @@ -801,11 +864,17 @@ test('a Suspense boundary outside a bound form still refuses a submitter', async test('a reflected .prop on a submitter is refused, in both machines', async () => { withResolver(); + // Every row binds its own action (#1307). The rule is same-element: a `.prop` + // on a button the author bound is refused, because SSR drops it and the + // browser reflects it, so the SAME button would submit differently with JS + // than without. On a PLAIN button it is an ordinary native property and is + // left alone, which is the row below this test. const refused = [ - html`
`, - html`
`, - html`
`, - html`
`, + html``, + html``, + html``, + html``, + html``, ]; for (const tpl of refused) { await assert.rejects(() => renderToString(tpl, { ssr: true }), /reflected IDL attribute/); @@ -839,23 +908,29 @@ test('an empty author name after the hole is refused, not shipped as a duplicate } }); -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. +test('a .prop on a submitter that binds NOTHING is an ordinary native property', async () => { + // #1307 narrowed this. `.formMethod` / `.formEnctype` / `.formAction` on a + // button that binds no action used to be refused, on the grounds that they + // could defeat the enclosing form's binding. That is a rule about the + // author's OTHER element, and the ordinary native-property behaviour (SSR + // drops a `.prop`, the browser reflects it) is what this codebase already + // accepts everywhere else, including for an unbound form's own `.method`. 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/); + for (const tpl of [ + html`
`, + html`
`, + html`
`, + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.match(out, /Save<\/button>/, 'the prop is dropped at SSR, as every native prop is'); + assert.ok(!/formaction|formmethod|formenctype/.test(out), 'and nothing is invented for it'); + } const ok = await renderToString( html`
`, { ssr: true }, ); - assert.match(ok, /formaction="\/search"/, 'the static retarget is still allowed'); + assert.match(ok, /formaction="\/search"/, 'a static retarget is still allowed'); }); test('a falsy boolean name hole leaves the identity channel free', async () => { @@ -866,7 +941,7 @@ test('a falsy boolean name hole leaves the identity channel free', async () => { html`
`, { ssr: true }, ); - assert.match(out, /x<\/button>/); + assert.match(out, /x<\/button>/); // Truthy emits `name=""`, which collides with the identity. await assert.rejects( () => renderToString( diff --git a/packages/core/test/routing/browser/form-action-submit.test.js b/packages/core/test/routing/browser/form-action-submit.test.js index 70e8e7646..7224cdee0 100644 --- a/packages/core/test/routing/browser/form-action-submit.test.js +++ b/packages/core/test/routing/browser/form-action-submit.test.js @@ -185,4 +185,269 @@ suite('Client router: bound form submissions (#1155)', () => { teardown(); } }); + + // ------------------------------------------------------------------------- + // #1307: the router honours the DECLARED enctype. + // + // It used to build a `FormData` for every submission and send it with no + // explicit content type, so `fetch` always derived `multipart/form-data` and + // the authored `enctype` was never read at all. A plain `
` + // MEANS `application/x-www-form-urlencoded` in HTML, so the same form sent a + // urlencoded body with JS off and a multipart body with JS on. Two different + // requests from one template is exactly what progressive enhancement rules + // out, and it is why these assertions read the real RequestInit rather than + // trusting the resolver in isolation. + // ------------------------------------------------------------------------- + + const okHtml = () => new Response( + '

ok

', + { headers: { 'content-type': 'text/html', 'x-webjs-build': '' } }, + ); + + test('a form declaring no enctype sends URLENCODED, the HTML default (#1307)', async () => { + setup(okHtml); + try { + render(html` + + + +
+ `, container); + container.querySelector('button').click(); + await tick(); + const post = calls[0]; + assert.ok(post, 'router issued the submission fetch'); + assert.ok(post.init.body instanceof URLSearchParams, + 'a form with no enctype must NOT be sent as multipart'); + assert.equal(post.init.body.get('email'), 'a@b.com', 'and the field survives the encoding'); + // COUNTERFACTUAL: revert `encodeSubmitBody` to return the FormData + // unconditionally and this goes red, which is what pins the fix. + } finally { teardown(); } + }); + + test('a form declaring multipart still sends FormData', async () => { + setup(okHtml); + try { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.ok(calls[0].init.body instanceof FormData, 'multipart is still FormData'); + } finally { teardown(); } + }); + + test("a submitter's formenctype overrides the form's, as native precedence says", async () => { + setup(okHtml); + try { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.ok(calls[0].init.body instanceof URLSearchParams, + "the button's own formenctype decides the encoding"); + } finally { teardown(); } + }); + + test('an invalid enctype is urlencoded, not passed through or treated as text/plain', async () => { + // `enctype` is an enumerated attribute whose invalid-value default is + // urlencoded, so a browser sends urlencoded for this and so must the router. + setup(okHtml); + try { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.ok(calls[0], 'the submission was still routed, not bailed'); + assert.ok(calls[0].init.body instanceof URLSearchParams); + } finally { teardown(); } + }); + + test('a text/plain POST is NOT routed, so both paths do the same native thing', async () => { + // The server parses multipart and urlencoded only, so there is no honest + // way to send this over fetch. Bailing means the browser performs exactly + // the submission it would have without JS. The nav guard would catch the + // real navigation, so what is asserted is simply that no fetch was issued. + setup(okHtml); + try { + render(html` +
+ +
+ `, container); + // The native submission this bail deliberately allows is cancelled by + // the suite's nav guard, which listens on WINDOW bubble, i.e. after the + // router's own document-bubble listener. A listener on `container` would + // run BEFORE the router and set `defaultPrevented`, so `onSubmit` would + // return at its first line and this test would pass without the router + // ever making the decision it claims to measure. + container.querySelector('button').click(); + await tick(); + assert.equal(calls.length, 0, 'the router did not take it'); + } finally { teardown(); } + }); + + // ------------------------------------------------------------------------- + // #1307: the dev-time submit guard. + // + // The renderer deliberately stopped refusing a PLAIN submitter's own + // `formmethod` / `formenctype`, because native HTML defines what those mean + // and the author wrote them on purpose. That leaves one honest gap: the + // shape is also what a mistake looks like. Submit time is the only moment + // the resolved method, the resolved enctype, and whether a bound identity is + // actually in the body all exist together, so the report happens there. + // + // Observational by construction: it runs before `preventDefault` and changes + // nothing about the submission. + // ------------------------------------------------------------------------- + + function captureWarnings(fn) { + const orig = console.warn; + const seen = []; + console.warn = (...a) => { seen.push(a.join(' ')); }; + try { return fn(seen); } finally { console.warn = orig; } + } + + function captureErrors(fn) { + const orig = console.error; + const seen = []; + console.error = (...a) => { seen.push(a.join(' ')); }; + try { return fn(seen); } finally { console.error = orig; } + } + + test('a bound identity submitted as GET logs once, and still submits', async () => { + setup(okHtml); + try { + await captureErrors(async (seen) => { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.ok( + seen.some((m) => /never runs/.test(m)), + `expected a submit-time console.error, saw: ${JSON.stringify(seen)}`, + ); + }); + } finally { teardown(); } + }); + + test('a bound identity posting to ANOTHER url is reported (#1307)', async () => { + // The one shape the redesign left unrefused. A bound submitter emits no + // `formaction`, so a form declaring its own action sends the identity + // there by native precedence. The renderer used to throw, but only where + // it could SEE the form, which is the cross-element judgement it cannot + // make from inside a component. Reported here, where the resolved target + // is a fact rather than an inference. + setup(okHtml); + try { + await captureWarnings(async (seen) => { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.ok( + seen.some((m) => /posts to "\/somewhere-else"/.test(m)), + `expected the submit-elsewhere report, saw: ${JSON.stringify(seen)}`, + ); + }); + } finally { teardown(); } + }); + + test('the submit-elsewhere guard stays silent for a form posting to its own page', async () => { + // The counterfactual. A bound FORM has its action stripped by the renderer, + // so it always posts to the page and must never trip this. Without this + // row the guard could be written to fire on every submission and the test + // above would still pass. + setup(okHtml); + try { + await captureWarnings(async (seen) => { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.equal(seen.length, 0, `expected silence, saw: ${JSON.stringify(seen)}`); + }); + } finally { teardown(); } + }); + + test('the guard stays silent for a form carrying no bound identity', async () => { + // An ordinary hand-written form is not this feature's business, and a + // console error on every plain GET form would be pure noise. + setup(okHtml); + try { + await captureErrors(async (seen) => { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.equal(seen.length, 0, `expected silence, saw: ${JSON.stringify(seen)}`); + }); + } finally { teardown(); } + }); + + test('the guard fires for text/plain but NOT for an invalid enctype', async () => { + // `enctype` is an enumerated attribute whose invalid-value default is + // urlencoded, so `nonsense` submits a perfectly parseable body and the + // action runs. Testing against the renderer's parseable-enctype allowlist + // instead of `text/plain` alone would report that working form as broken. + setup(okHtml); + try { + await captureErrors(async (seen) => { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.equal(seen.length, 0, `an invalid enctype is urlencoded and works, saw: ${JSON.stringify(seen)}`); + }); + } finally { teardown(); } + + setup(okHtml); + try { + await captureErrors(async (seen) => { + render(html` +
+ + +
+ `, container); + container.querySelector('button').click(); + await tick(); + assert.ok( + seen.some((m) => /cannot parse/.test(m)), + `expected the text/plain report, saw: ${JSON.stringify(seen)}`, + ); + }); + } finally { teardown(); } + }); }); diff --git a/packages/core/test/routing/router-client.test.js b/packages/core/test/routing/router-client.test.js index 7c3a6c913..06ac6717b 100644 --- a/packages/core/test/routing/router-client.test.js +++ b/packages/core/test/routing/router-client.test.js @@ -30,6 +30,7 @@ let _collect, _plan, _keyOf, _diffEl, _reconcile, _applySwap, _prefetchCache, _snapshotCache, _LIVE_ATTRS, _blurOutgoingFocus, _onSubmit, _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, + _getSubmitEnctype, _encodeSubmitBody, _restoreOptimistic, _navToken, _bumpNavToken, _currentPageUrl, _setCurrentPageUrl, _resetWarnOnce, _eligibleAnchorHref, _prefetchSuppressed, _prefetchMode, _prefetchHasHoverPointer, _prefetch, _prefetchTake, _prefetchAnchor, @@ -101,6 +102,8 @@ before(async () => { _getSubmitMethod, _getSubmitAction, _buildSubmitFormData, + _getSubmitEnctype, + _encodeSubmitBody, _restoreOptimistic, _navToken, _bumpNavToken, @@ -3006,6 +3009,66 @@ test('getSubmitMethod: tolerates null submitter (programmatic submit)', () => { assert.equal(_getSubmitMethod(form, null), 'post'); }); +test('getSubmitEnctype: submitter formenctype overrides form enctype', () => { + // Native precedence, the same rule `getSubmitMethod` follows one line up. + const form = formFrom('
'); + assert.equal(_getSubmitEnctype(form, form.querySelector('button')), 'multipart/form-data'); +}); + +test('getSubmitEnctype: the missing-value default is urlencoded, per HTML', () => { + // This is the case that mattered (#1307). A plain `
` + // MEANS urlencoded, and the router used to send multipart for it, so the + // same form produced a different request body with JS on than with JS off. + const form = formFrom('
'); + assert.equal(_getSubmitEnctype(form, form.querySelector('button')), 'application/x-www-form-urlencoded'); +}); + +test('getSubmitEnctype: an INVALID value is urlencoded too, not passed through', () => { + // `enctype` is an enumerated attribute whose invalid-value default is also + // urlencoded, so `nonsense` really does mean urlencoded. Treating an + // unrecognised value as text/plain would bail a form that submits perfectly. + for (const raw of ['nonsense', 'TEXT/HTML', '', ' multipart/form-data ']) { + const form = formFrom(`
`); + assert.equal( + _getSubmitEnctype(form, form.querySelector('button')), + 'application/x-www-form-urlencoded', + `${raw || '(empty)'} normalizes to the invalid-value default`, + ); + } +}); + +test('getSubmitEnctype: the two other keywords are matched case-insensitively', () => { + for (const [raw, want] of [ + ['MULTIPART/FORM-DATA', 'multipart/form-data'], + ['Text/Plain', 'text/plain'], + ]) { + const form = formFrom(`
`); + assert.equal(_getSubmitEnctype(form, form.querySelector('button')), want); + } +}); + +test('encodeSubmitBody: urlencoded sends URLSearchParams, multipart sends FormData', () => { + const fd = new FormData(); + fd.append('email', 'a@b.com'); + fd.append('note', 'hi there'); + const params = _encodeSubmitBody(fd, 'application/x-www-form-urlencoded'); + assert.ok(params instanceof URLSearchParams, 'urlencoded must not send FormData'); + assert.equal(params.get('email'), 'a@b.com'); + assert.equal(params.get('note'), 'hi there'); + assert.equal(_encodeSubmitBody(fd, 'multipart/form-data'), fd, 'multipart passes the FormData through'); +}); + +test('encodeSubmitBody: a File under urlencoded is sent as its NAME, matching the platform', () => { + // The platform's urlencoded serializer writes the file's name. Turbo drops + // file entries entirely here, which loses a field the no-JS path sends. + const fd = new FormData(); + fd.append('avatar', new File(['x'], 'portrait.png', { type: 'image/png' })); + fd.append('email', 'a@b.com'); + const params = _encodeSubmitBody(fd, 'application/x-www-form-urlencoded'); + assert.equal(params.get('avatar'), 'portrait.png'); + assert.equal(params.get('email'), 'a@b.com', 'and the sibling text field survives'); +}); + test('getSubmitAction: submitter formaction overrides form action', () => { const form = formFrom('
'); const submitter = form.querySelector('button'); @@ -3070,6 +3133,36 @@ test('onSubmit: ignores submissions with method="dialog"', () => { assert.equal(e._wasPrevented(), false, 'native dialog dismissal not routed'); }); +// NOTE on these two bail tests, and on every `onSubmit: ignores ...` test +// around them: this harness is linkedom, where `new FormData(formElement)` +// throws, so `onSubmit` cannot be driven all the way to `preventDefault()` +// here. A bail assertion therefore proves that the submission was NOT routed, +// but cannot prove it bailed for the stated REASON. The positive control (an +// ordinary POST still being intercepted, and the body actually encoded per the +// declared enctype) lives in the browser suite, in +// `packages/core/test/routing/browser/form-action-submit.test.js`, against a +// real DOM and a stubbed fetch. +test('onSubmit: an unsafe text/plain submission bails to the browser (#1307)', () => { + // The server parses multipart and urlencoded only, so there is no honest way + // to send text/plain over fetch and have the response mean anything. Bailing + // makes the JS-on and JS-off paths do the SAME thing (both a native + // text/plain POST, both answered the same way), which is the requirement. + const form = formFrom('
'); + const e = fakeSubmitEvent(form); + _onSubmit(e); + assert.equal(e._wasPrevented(), false, 'the browser performs the submission'); +}); + +test('onSubmit: a submitter formenctype="text/plain" bails too', () => { + // Native precedence: the submitter's override decides the encoding, so the + // bail has to read it there as well or a per-button text/plain would be sent + // as multipart under JS and natively without it. + const form = formFrom('
'); + const e = fakeSubmitEvent(form, form.querySelector('button')); + _onSubmit(e); + assert.equal(e._wasPrevented(), false); +}); + test('onSubmit: ignores cross-origin actions', () => { const form = formFrom('
'); const e = fakeSubmitEvent(form); diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md index 846f0c677..119278edd 100644 --- a/packages/server/AGENTS.md +++ b/packages/server/AGENTS.md @@ -37,7 +37,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` / | `dev-error.js` | Dev error overlay frame builder (#264). `buildDevErrorFrame(error, { kind, appDir, file?, line?, hint?, url? })` returns a JSON-serializable frame (message, parsed `file`/`line`/`column`, a source `codeFrame`, an optional `hint`, and an optional `url`); `parseStackLocation(stack, appDir)` finds the first app frame (preferring non-`node_modules`, splitting off the dev loader's `?t=` cache-bust query); `readCodeFrame(file, line, column)` reads the source excerpt with a `>` line marker + a caret. `url` (#1047) is the request URL that produced the frame, `null` when absent: only a `render` frame carries one, and it is what lets the browser overlay refuse a frame for a page the tab is not viewing. A `ts-strip` / `rebuild` frame passes none and stays unscoped, because it describes a still-broken build rather than one page. PURE (the only side effect is a guarded source read) and DEV-ONLY by the caller's contract, so no path / source is built in prod | | `dev-overlay.js` | The BROWSER half of the dev error overlay (#264), a browser-safe module (no node imports) that `reloadClientJs` inlines verbatim after an `export`-keyword strip, so the browser test drives the exact shipping code. `renderDevOverlay(frame, currentPath?)` builds the card with `createElement` + `textContent` ONLY (never `innerHTML`, so a hostile message / path / code frame is inert text); `dismissDevOverlay()` takes it down and forgets any held frame, which is what the Dismiss button calls. **URL scope (#1047):** a url-stamped `render` frame for a different path than `currentPath` (default `location.pathname + location.search`, compared encoding-tolerantly) is NOT rendered. The gate runs BEFORE any removal, so a refused frame neither paints nor wipes a live `rebuild` / `ts-strip` overlay. It cannot simply drop the frame either, because the SSE frame is pushed during the render, before the navigation response is sent, so it arrives while `location` is still the old page: a refused frame goes to a PENDING slot that `syncDevOverlayToLocation(currentPath?)` re-evaluates on the next navigation, which renders it only if the url matches AND it arrived during the navigation now finishing, and drops it otherwise (consume-once, so retention is bounded with no timer). The second half of that condition is what stops a frame held from an IDLE render (another tab's page, a background fetch of some other url, never a link prefetch since that reports nothing) painting on a later visit to that url, when the page may render fine; `markDevOverlayNavStart()` is the nav-start marker it counts. `installDevOverlayNavSync(opts?)` wires all of it to the client router's `webjs:navigate` (applied nav) + `popstate` (a snapshot-cache restore returns before `webjs:navigate`) + `webjs:before-cache` (the nav START, since `snapshotCurrent` runs at the top of every navigation and form submission), and returns an uninstall thunk. Two things keep a snapshot copy of the overlay from becoming an undismissable card (the router's tier-4 popstate restore `replaceChildren`s the body from cached HTML, and a parsed copy is not the node this module holds, so nothing could remove it and its Dismiss button has no listener). `before-cache` DETACHES the overlay across the router's synchronous `outerHTML` read and re-attaches it a microtask later, so the cached HTML carries none. That is what covers the hard ordering: under an opt-in view transition the router defers the body swap PAST the sync, so a copy the swap inserts would arrive after any sweep had already run. The sync additionally SWEEPS any `[data-webjs-error-overlay]` element it does not own, as the backstop for a copy from anywhere else (a snapshot cached before this client installed its listeners, or any other wholesale body replacement). What `before-cache` must NOT do is strip the overlay for good, which the event's own contract invites: it fires on EVERY navigation, so that would tear a `rebuild` / `ts-strip` overlay off the page on any link click while the build was still broken. `packages/core` is untouched: the fix rides existing events | | `frame-render.js` | Server-side `` subtree extraction (#253). `requestedFrameId(req)` reads the `x-webjs-frame` header (null when absent, the normal full-page path); `extractFrameSubtree(html, id)` returns the `...` slice from rendered HTML verbatim (so byte-equivalent by construction), balancing nested `` tags and reading the `id` attribute (not a substring match), or null when the id is absent. Used by `ssr.js`'s frame-render branch | -| `form-dispatch.js` | Form-submission dispatch (#1155, replacing the page `action` export of #244): `runFormAction` verifies Origin, parses the bounded form body, resolves the `__webjs_action` identity, runs the action's declared `validate` + `middleware`, evicts its `invalidates` tags, and maps the `ActionResult` to a response (303 PRG on success, 422 re-render with `actionData` on failure, honoring thrown `redirect()`/`notFound()`/`forbidden()`/`unauthorized()`). An action that returns a `Response` DIRECTLY (e.g. a content-negotiated `streamResponse`, #248) is honored verbatim; a STREAMED return is refused (a submission has no frame consumer). A submission carrying no identity is a 405, an unresolvable hash is a 422 re-render with `ACTION_SKEW_MESSAGE` plus the typed values, and a module that throws at import is a sanitized 500 (never mislabelled as skew). `dev.js` routes EVERY non-GET/HEAD page request here, wrapped in the page's segment middleware | +| `form-dispatch.js` | Form-submission dispatch (#1155, replacing the page `action` export of #244): `runFormAction` verifies Origin, parses the bounded form body, resolves the `__webjs_action` identity, runs the action's declared `validate` + `middleware`, evicts its `invalidates` tags, and maps the `ActionResult` to a response (303 PRG on success, 422 re-render with `actionData` on failure, honoring thrown `redirect()`/`notFound()`/`forbidden()`/`unauthorized()`). An action that returns a `Response` DIRECTLY (e.g. a content-negotiated `streamResponse`, #248) is honored verbatim; a STREAMED return is refused (a submission has no frame consumer). A submission carrying no identity is a 405, an unresolvable hash is a 422 re-render with `ACTION_SKEW_MESSAGE` plus the typed values, and a module that throws at import is a sanitized 500 (never mislabelled as skew). `dev.js` routes EVERY non-GET/HEAD page request here, wrapped in the page's segment middleware. **Two detect-only `onError` signals (#1307):** `WEBJS_FORM_ACTION_MISSING` for a parseable form body carrying no identity (the 405 above), and `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its QUERY STRING, which means a submission holding a bound action's identity went out as a GET so the action never ran. Nothing in the framework puts that field in a url, and a bound submitter now carries its own `formmethod="post"`, so what reaches the second signal is an explicit `formmethod="get"` / `method="get"` the author wrote and the renderer deliberately honours; it is the production counterpart to the dev-time client guard. Both keep rendering their normal response (answering differently on a query parameter would let any visitor turn any page into a different status), carry field NAMES and never values, and dedupe per process on code plus method plus matched ROUTE with a 256-entry cap. Keying on the route PATTERN rather than the pathname is load-bearing: keyed on the pathname a dynamic route yields unbounded keys, so a few hundred crafted requests would fill the cap and permanently silence the diagnostics | | `form-action-identity.js` | The `/` identity a bound form submits (#1155). `resolveActionIdentity` maps a real action function to its identity via the always-on `'use server'` load-hook registry (`action-seed.js`), with a module-scan fallback only on a runtime that installs no hook; `lookupActionIdentity` maps a submitted identity back to `{ file, fnName, module }`, distinguishing an unknown hash (skew) from an unknown export (404) and from an import failure (`load-failed`). The scheme is the RPC endpoint's, so both transports resolve the same string to the same function | | `action-seed.js` | SSR action-result seeding (#472, Bun-enabled #529). `registerActionHooks({ seed, dev })` (async, `dev` gating only the determinism assertion below) installs a load hook chosen by `serverRuntime()`: Node's synchronous `module.registerHooks`, or a `Bun.plugin` `onLoad` on Bun (the glue is in `action-seed-bun.js`, dynamically imported so `Bun.*` never loads on Node). For a `'use server'` `*.server.*` module the hook returns a transparent FACADE re-exporting each function wrapped in a `Proxy` (`__actionWrap`); the faceting decision (`isSeedCandidate`) + facade source (`buildSeedFacade`) are runtime-neutral, so both runtimes emit the identical seed. (Bun's `onLoad` must return an object for every filter match, so the non-facet cases serve the raw source.) The Proxy records `(file, fn, args) -> result` into an ambient `AsyncLocalStorage` collector when one is active (a pure passthrough otherwise, so the RPC endpoint path is untouched, and any metadata attached to the function forwards through the Proxy). `collectSeeds(fn)` runs the SSR render inside a fresh collector; `buildSeedScript(collector, { dev, reason })` serializes it into an HTML-escaped `