diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 4be1cca2f..7ec2dcfd5 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -146,6 +146,7 @@ Everything the action declares applies here too, or an action would be protected - `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data. - `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag. - `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`. +- A form whose buttons run DIFFERENT actions binds each on its submitter, ``; } +} +PublishButton.register('publish-button'); + +// app/triage/page.ts <- the form lives here +// WRONG: the form binds nothing, and NOTHING throws. +html``; +// RIGHT: bind the enclosing form too. +html`
`; +``` + +The component renders its own template in a separate pass with no view of the host page, so the renderer sees a cannot-tell and binds anyway (refusing would drop an isolated component from a page that still returned 200, which is worse). What ships is a button carrying the reserved `__webjs_action` identity inside whatever form the page wrote. Whether that is broken depends on the form, and the distinction is easy to miss: one that still sends a parseable POST body WORKS, because the identity rides the button's own `name`/`value` pair into the body and the dispatcher runs the action. One with no `method` (or `method="get"`) submits a GET, so the identity rides the QUERY STRING, the action never runs, the page re-renders, the status is 200, and there is no throw, no log, and no 405. A silent write path is the whole failure mode, so treat the address bar growing a `?__webjs_action=` as the fingerprint. + +**Two runtime signals back the check up.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values. + +**Run `webjs check` and it catches this for you.** The `submitter-needs-bound-form` rule reads every template in the app at once, which neither renderer can do, so it resolves the enclosing form across module boundaries and transitively through intermediate components (a page's form around `` around `` around the button). It is conservative by design and says nothing when it cannot be sure: a tag rendered in a bound form somewhere and an unbound one elsewhere, a tag whose host form is unbound but still DELIVERS (that shape works), a form whose `method` or `enctype` comes from a hole, a tag with no call site in the app, a submitter in a bare `html` helper rather than a component class body, a file registering more than one tag, a file that opens a form of its own, a submitter or tag handed to another element through a start-tag hole (``), a `formaction` hole that is not a proven action binding (a url string or CONSTANT, a factory-produced export, a namespace or default import, a barrel re-export, or a non-identifier expression like `acts.publishDraft`), or a reference cycle. The one start-tag hole it DOES judge is ``, because the renderer renders a fallback inline in the enclosing form rather than handing it off. Silence from the rule is therefore not proof the form is bound; a green check plus the shape above still deserves a look. + **Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way. Two things that "renders it empty" understates, both worth knowing before you go looking: diff --git a/gallery/modules/todo/actions/submit-todo.server.ts b/gallery/modules/todo/actions/submit-todo.server.ts index 2a6cff268..cf9d24bfa 100644 --- a/gallery/modules/todo/actions/submit-todo.server.ts +++ b/gallery/modules/todo/actions/submit-todo.server.ts @@ -21,6 +21,18 @@ import { deleteTodo } from './delete-todo.server.ts'; // control's visible label), and a bound submitter cannot carry its own // `name`/`value`, which is exactly the channel `name="intent"` uses below. // +// Third thing to know, and the one that fails silently: the enclosing
+// has to be bound too, because `method="post"` and the enctype are supplied on +// the form's start tag and a per-button action cannot retrofit them. The +// renderer refuses an unbound form it can see, but a submitter inside a +// COMPONENT is a cannot-tell (the component renders in its own pass with no +// view of the host page) and binds anyway. What happens then depends on that +// form: one still declaring `method="post"` works (the identity rides the +// button's own name/value pair into the body), but one with no method submits as +// a GET, so the identity rides the query string and the action never runs while +// the page returns 200. Run `webjs check`: `submitter-needs-bound-form` finds +// these across modules. +// // With JS the component intercepts the submit and calls the underlying action // directly for the optimistic path, so this runs only with JS off. export async function submitTodo(formData: FormData) { diff --git a/packages/server/src/check.js b/packages/server/src/check.js index ee87e3ec9..98f67dbb2 100644 --- a/packages/server/src/check.js +++ b/packages/server/src/check.js @@ -6,10 +6,13 @@ import { redactToPlaceholders, extractWebComponentClassBodies, matchClosingBrace, + matchClosingParenthesis, parsePropEntries, + classifyActionHole, + scanHtmlFormScopes, } from './js-scan.js'; import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js'; -import { scanComponents } from './component-scanner.js'; +import { scanComponents, extractComponents } from './component-scanner.js'; import { buildRouteTable } from './router.js'; import { analyzeElision } from './component-elision.js'; import { RESERVED_CONFIG } from './action-config.js'; @@ -140,6 +143,11 @@ export const RULES = [ description: 'Flags `` bound to a `\'use server\'` action whose file declares `export const method = \'GET\'` (#488). A GET action is a READ: it is CSRF-exempt and rides its arguments in the url, while a form submission is a CSRF-checked POST carrying a body, so the two contracts contradict each other and the submission is answered with a 405 at runtime. The rule reads the imported action\'s own file, so it only fires when the binding really does resolve to a GET-declared action. Fix by dropping the `method` export (an action with no `method` is a POST, which is what a form wants) or by binding a different action; if the form really is a read, use a plain `` with no bound action.', }, + { + name: 'submitter-needs-bound-form', + description: + 'Flags a `
`'; + assert.deepEqual(scanHtmlFormScopes(src).submitters, [{ tag: 'button', scope: 'none', delivers: null, expr: 'del' }]); +}); + +test('classifyActionHole matches the tag and the attribute as a pair', () => { + assert.equal(classifyActionHole('
'), null, 'the tag already closed'); + assert.equal(classifyActionHole('plain text'), null); +}); + +test('matchClosingBrace walks past a template hole (#1307)', () => { + // A hole is a CODE context nested in a template, not a brace in the block + // being matched. Counting it toward the outer depth (the earlier behaviour) + // meant depth could never return to zero. + const s = '{ return html`${x}`; }'; + assert.equal(matchClosingBrace(s, 1), s.length - 1); + const nested = '{ a(html`${ b(html`${c}`) }`); }'; + assert.equal(matchClosingBrace(nested, 1), nested.length - 1); + // Still returns -1 when there really is no match. + assert.equal(matchClosingBrace('{ a(', 1), -1); +}); + +test('a class body holding a template hole is extractable from RAW source', () => { + // Every other caller passes a masked source in which holes are blanked, so + // this path was the one that exposed the brace bug. + const src = [ + 'class RowBtn extends WebComponent({}) {', + ' render() { return html``; }', + '}', + ].join('\n'); + const bodies = extractWebComponentClassBodies(src); + assert.equal(bodies.length, 1); + assert.match(bodies[0].body, /formaction=\$\{del\}/); + assert.deepEqual(scanHtmlFormScopes(bodies[0].body).submitters, [{ tag: 'button', scope: 'none', delivers: null, expr: 'del' }]); +}); + +test('an unbound form reports whether it would still DELIVER the identity', () => { + // This is the bit that decides broken from working, and it is not boundness. + // A submitter's identity rides its OWN name/value pair, so an unbound form + // that still sends a parseable POST body delivers it and the action runs. + const sub = (formTag) => scanHtmlFormScopes( + 'html`' + formTag + '`', + ).submitters[0]; + + assert.equal(sub('
').delivers, true, 'a POST body carries the pair'); + assert.equal(sub('').delivers, true, 'the keyword folds case'); + assert.equal(sub('').delivers, true); + assert.equal(sub('').delivers, false, 'no method is a GET'); + assert.equal(sub('').delivers, false); + // `method` is an enumerated attribute matched against exact keywords with no + // whitespace stripping, so a padded value falls to the GET default. + assert.equal(sub('').delivers, false); + assert.equal(sub('').delivers, false, 'the server cannot parse it'); + // `enctype` is an enumerated attribute whose missing AND invalid value default + // are both application/x-www-form-urlencoded, so an unrecognised value falls + // back to a parseable body. Treating it as unparseable reported a working form + // as broken, which is the one thing this rule must never do. + assert.equal(sub('').delivers, true, 'an invalid enctype falls back to urlencoded'); + assert.equal(sub('').delivers, true, 'padded, so invalid, so urlencoded'); + assert.equal(sub('').delivers, false, 'the keyword folds case'); + // A hole anywhere else in the start tag makes the answer dynamic. + assert.equal(sub('').delivers, null); + assert.equal(sub('').delivers, null); +}); + +test('opensForm reports whether the source opens any form at all', () => { + assert.equal(scanHtmlFormScopes('html``').opensForm, false); + assert.equal(scanHtmlFormScopes('html`
`').opensForm, true); + // A form written only inside a plain string is not a form. + assert.equal(scanHtmlFormScopes("const s = '
';").opensForm, false); +}); + +test('class-body offsets index the RAW source identically to the mask', () => { + // How the rule gets a body with its templates intact without asking the brace + // matcher to lex raw source: locate in the position-preserving mask, slice + // from `content`. A regex literal holding a brace is the case that broke when + // raw source was passed directly. + const src = [ + 'class RowBtn extends WebComponent({}) {', + ' static re = /[{]/;', + ' render() { return html``; }', + '}', + ].join('\n'); + const masked = redactStringsAndTemplates(src); + assert.equal(masked.length, src.length, 'the mask is position-preserving'); + const bodies = extractWebComponentClassBodies(masked); + assert.equal(bodies.length, 1, 'the masked view blanks the regex body, so the braces balance'); + const body = src.slice(bodies[0].bodyStart, bodies[0].bodyEnd); + assert.match(body, /formaction=\$\{del\}/, 'the raw slice keeps the template intact'); + assert.deepEqual(scanHtmlFormScopes(body).submitters, [{ tag: 'button', scope: 'none', delivers: null, expr: 'del' }]); +}); + +test('a template in an ordinary START-TAG hole is handed off, not inherited', () => { + // A hole inside a start tag is an attribute or property VALUE whose placement + // this scan cannot speak for: SSR never renders it in place (a serializable + // value applies at hydration, a function-carrying one is dropped outright), so + // the receiving element decides where it lands in the browser. Scoring it by + // lexical nesting reported a shape the renderer treats as cannot-tell (and + // therefore binds) as a conclusive 'unbound'. `` is + // the one exception and has its own test below. + const passed = 'html`x`}>`'; + // 'handed', NOT 'none'. Both mean "no enclosing form in this scan", but only + // 'none' is a cannot-tell the caller may attribute to this file's own + // component. A handed-off template belongs to whichever element received it, + // so collapsing the two resolves it through the wrong call sites. + assert.deepEqual(scanHtmlFormScopes(passed).submitters, [{ tag: 'button', scope: 'handed', delivers: null, expr: 'del' }]); + // A hyphenated tag inside a property hole is placed by the receiving element + // too, so it is not this file's call site either. + const tagPassed = 'html`
`}>`'; + assert.deepEqual(scanHtmlFormScopes(tagPassed).tagUses.filter((u) => u.tag === 'todo-row'), + [{ tag: 'todo-row', scope: 'handed', delivers: null, expr: null }]); + // A form the handed-off template opens ITSELF is still its own scope. + const ownForm = 'html``}>`'; + assert.deepEqual(scanHtmlFormScopes(ownForm).submitters, [{ tag: 'button', scope: 'bound', delivers: null, expr: 'del' }]); + // A hole in CHILD position IS rendered inline by this scan, so it still + // inherits. This is the pair that keeps the fix from being a blanket opt-out. + const child = 'html`
${html``}
`'; + assert.deepEqual(scanHtmlFormScopes(child).submitters, [{ tag: 'button', scope: 'unbound', delivers: true, expr: 'del' }]); +}); + +test('the unparseable-enctype constant tracks the renderer\'s own set', async () => { + // The scanner states its enctype rule as a DENYLIST of one because of the + // invalid-value default, while the renderer refuses the wider allowlist. This + // pins the relationship rather than asserting the two are equal, so a change + // to core's set surfaces here instead of drifting silently. + assert.ok(!PARSEABLE_ENCTYPES.has('text/plain'), 'the renderer cannot parse text/plain either'); + for (const e of PARSEABLE_ENCTYPES) { + const src = `html\`
\``; + assert.equal(scanHtmlFormScopes(src).submitters[0].delivers, true, `${e} delivers`); + } + assert.deepEqual([...PARSEABLE_ENCTYPES].sort(), + ['application/x-www-form-urlencoded', 'multipart/form-data'], + 'if core gains an enctype, revisit the scanner denylist'); + + // There are now THREE hardcoded copies of the denylist keyword: the scanner's + // `UNPARSEABLE_FORM_ENCTYPE`, the client guard in `router-client.js`, and this + // test. Pin the client one too, so the two halves of the feature cannot drift + // into disagreeing on the same input (which is what the allowlist did). + const clientSrc = await readFile(new URL('../../../core/src/router-client.js', import.meta.url), 'utf8'); + assert.match(clientSrc, /enctype\.toLowerCase\(\) === 'text\/plain'/, + 'the client guard uses the same one-keyword denylist, not the renderer allowlist'); + assert.doesNotMatch(clientSrc, /PARSEABLE_ENCTYPES/, + 'and does not reach for the renderer allowlist again'); +}); + +test('the start-tag-hole rule matches what the RENDERER actually does', async () => { + // A differential rather than an assertion about the scanner alone, because the + // whole 'handed' state is a claim about the renderer's behaviour. Most + // start-tag holes are values the receiving element places, but + // `` is rendered INLINE with the enclosing form + // scope (#471: a custom-element property applies at hydration, too late for a + // placeholder that must be in the first flushed bytes). Treating that one as + // handed off would blind the same-scan half to a real render-time throw. + const { html } = await import('../../../core/src/html.js'); + const { renderToString } = await import('../../../core/src/render-server.js'); + const { setFormActionResolver } = await import('../../../core/src/form-action.js'); + setFormActionResolver(async () => 'abc1234567/publish'); + const publish = async () => ({ success: true }); + + // Rendered inline, so the renderer judges it against the unbound form and + // throws. The scanner must see the same thing. + const fallbackSrc = 'html`
x`}>
`'; + assert.deepEqual(scanHtmlFormScopes(fallbackSrc).submitters, + [{ tag: 'button', scope: 'unbound', delivers: false, expr: 'p' }]); + await assert.rejects( + () => renderToString( + html`
x`}>
`, + { ssr: true }, + ), + /requires the enclosing
to also be bound/, + 'the renderer really does refuse it, so the scanner must not be silent', + ); + + // The negative half, and it has to assert the MECHANISM rather than "it did + // not throw". A template carrying a function cannot serialize, so SSR drops + // the whole property binding and emits nothing for it: the submitter is never + // rendered and never judged here at all. Asserting only that the render + // succeeded would stay green for ANY scanner verdict, which is precisely the + // non-discriminating test this differential exists to avoid. + const propSrc = 'html`x`}>
`'; + assert.deepEqual(scanHtmlFormScopes(propSrc).submitters, + [{ tag: 'button', scope: 'handed', delivers: null, expr: 'p' }]); + const warns = []; + const quiet = console.warn; + console.warn = (...a) => warns.push(a.join(' ')); + let out; + try { + out = await renderToString( + html`
x`}>
`, + { ssr: true }, + ); + } finally { console.warn = quiet; } + assert.doesNotMatch(out, /`', + ).submitters[0]; + + // The scanner: an invalid enctype still delivers, so it is not a defect. + assert.equal(sub('
').delivers, true); + // The renderer: the same typo throws, because it would cost the author their + // file upload with no other signal. + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /cannot work|enctype/i, + ); + // And both agree that text/plain is broken. + assert.equal(sub('
').delivers, false); + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /cannot work|enctype/i, + ); +}); diff --git a/test/bun/form-action-dispatch.mjs b/test/bun/form-action-dispatch.mjs index 408d3e4e9..fbcb3dd87 100644 --- a/test/bun/form-action-dispatch.mjs +++ b/test/bun/form-action-dispatch.mjs @@ -28,6 +28,7 @@ import { join, dirname, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequestHandler } from '../../packages/server/src/dev.js'; +import { resetFormReportDedupe } from '../../packages/server/src/form-dispatch.js'; const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; const CORE = JSON.stringify(pathToFileURL(resolve('packages/core/index.js')).toString()); @@ -154,5 +155,45 @@ function urlencoded(fields) { assert.equal(res.status, 405, `[${runtime}] a submission with no identity is a 405`); } +// #1307: both fingerprints of a form that posts nowhere reach the `onError` +// sink with a groupable code, and neither changes the response. Cross-runtime +// by construction: the code paths are `url.searchParams.has()`, +// `formData.keys()`, and `Error` property assignment, all of which Node and Bun +// implement separately. +{ + resetFormReportDedupe(); + /** @type {any[]} */ + const seen = []; + const reporting = await createRequestHandler({ + appDir: dir, dev: false, onError: (e) => seen.push(e), + }); + await reporting.warmup(); + + // A page GET carrying the reserved field in the QUERY STRING: the fingerprint + // of a bound submitter submitted through an UNBOUND form. + const got = await reporting.handle(new Request(`http://x/signup?${FIELD}=abc123%2Fsignup`)); + assert.equal(got.status, 200, `[${runtime}] the GET still renders (detect only)`); + assert.equal(seen.length, 1, `[${runtime}] the query-string GET is reported once`); + assert.equal(seen[0].code, 'WEBJS_FORM_SUBMITTED_AS_GET', `[${runtime}] with a groupable code`); + + // Deduplicated per process on method + pathname, so a crafted flood cannot + // amplify into a paid APM sink. + await reporting.handle(new Request(`http://x/signup?${FIELD}=abc123%2Fsignup`)); + assert.equal(seen.length, 1, `[${runtime}] a second identical request adds no report`); + + // A submission carrying no identity: still a 405, now with a report naming + // the submitted field NAMES and none of the values. + const missing = await reporting.handle(new Request('http://x/signup', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'http://x' }, + body: new URLSearchParams({ email: 'a@b.com' }).toString(), + })); + assert.equal(missing.status, 405, `[${runtime}] the 405 is unchanged`); + const bodyless = seen.find((e) => e.code === 'WEBJS_FORM_ACTION_MISSING'); + assert.ok(bodyless, `[${runtime}] the bind-nothing 405 is reported`); + assert.deepEqual(bodyless.fields, ['email'], `[${runtime}] field names ride, values do not`); + assert.ok(!JSON.stringify(bodyless.fields).includes('a@b.com'), `[${runtime}] no user data`); +} + rmSync(dir, { recursive: true, force: true }); -console.log(`[form-action-dispatch] #1155 dispatch OK on ${runtime} (identity ${id})`); +console.log(`[form-action-dispatch] #1155 dispatch + #1307 reporting OK on ${runtime} (identity ${id})`); diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts index 0af393b2e..57e606c40 100644 --- a/website/app/docs/progressive-enhancement/page.ts +++ b/website/app/docs/progressive-enhancement/page.ts @@ -173,7 +173,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">. 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. + A form that binds nothing gets a 405: there is no page action export to catch a bare <form method="post">. The quieter half of that is worth knowing too. A form with no method at all submits as a GET, so there is no body and no 405; the page simply re-renders with a 200. 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. +

+ +

+ A per-button action needs its enclosing form bound. method="post" and the enctype are supplied on the form's start tag, which is already emitted by the time the renderer reaches the button, so a submitter cannot retrofit them. An unbound host form the renderer can see is refused at render. One it CANNOT see is not: a submitter inside a component is a cannot-tell, because the component renders its own template in a separate pass with no view of the host page, and cannot-tell has to bind (refusing there would drop the component from a page that still returned 200). So a formaction=\${action} button in a component inside an unbound form ships. If that form still declares method="post" it works, because the identity rides the button's own name/value pair into the body; if it declares no method it submits as a GET, puts the reserved identity in the query string, and the action never runs. Run webjs check: the submitter-needs-bound-form rule reads every template in the app at once, so it resolves the enclosing form across modules and flags this at edit time.

diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts index 23e39c361..476462e1c 100644 --- a/website/app/docs/troubleshooting/page.ts +++ b/website/app/docs/troubleshooting/page.ts @@ -54,6 +54,11 @@ export default function Troubleshooting() {

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 button submits and the page just re-renders

+

Symptom: clicking a formaction=\${action} button reloads the same page with a 200, nothing was written, no error appears anywhere, and the address bar has grown a ?__webjs_action= parameter.

+

Cause: the enclosing <form> binds no action AND declares no method="post", so the browser submits it as a GET. (An unbound form that DOES declare method="post" still works, because the identity rides the button's own name/value pair and reaches the server in the body.) A GET sends no body, so the identity the button carries rides the query string instead, and a GET at a page url just renders the page. That is why there is no 405 and no log line. The renderer refuses this shape when it can see both halves in one template, but a submitter inside a COMPONENT is a cannot-tell (the component renders in its own pass with no view of the host page) and cannot-tell has to bind, so a split-across-modules form reaches production.

+

Fix: bind the enclosing form too (<form action=\${saveAll}>), which is what supplies method="post" and the enctype. Run webjs check to find it: the submitter-needs-bound-form rule reads every template in the app at once, so it resolves the enclosing form across module boundaries and transitively through intermediate components. It is conservative and stays silent when a tag is rendered in a bound form somewhere and an unbound one elsewhere, so a clean check is not by itself proof the form is bound. Two runtime signals back it up: in dev the browser console carries one [webjs] error at submit time naming the fix, and in production the onError hook receives the fingerprint with err.code === 'WEBJS_FORM_SUBMITTED_AS_GET'. The 405 case has its own code, WEBJS_FORM_ACTION_MISSING. Neither changes the response.

+

A render fails on a button's formmethod or formenctype

Symptom: a page fails at render with <button formenctype="text/plain" formaction=\${action}> cannot work, or the same message naming formmethod.

Cause: that button BINDS an action and also tells itself to submit in a way the action could never read. 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, text/plain is flagged by the HTML spec as not intended for machine parsing, and formmethod="dialog" dismisses a <dialog> rather than submitting. The renderer supplies formmethod="post" and the enctype on a bound submitter, so writing a conflicting value on the same button is a straight contradiction.