From e5086c29d3bb5291a8c950ddb6531b614b917003 Mon Sep 17 00:00:00 2001
From: Vivek
Date: Tue, 11 Aug 2026 01:41:11 +0530
Subject: [PATCH 1/3] fix: drop submitter-needs-bound-form, which contradicts
#1307
The rule and its renderer differential were written against behaviour the
renderer does not have. #1307 made a bound submitter self-sufficient: the
renderer stamps formmethod="post" and formenctype on the button itself, and
a submitter's formmethod overrides the form's method per HTML. So a bound
button posts correctly inside an unbound, method-less, or method="get"
form, and the rule's premise that it "submits as a GET with the identity in
the query string" is false for every shape it flagged.
It was flagging #1307's own demonstration fixtures in examples/blog, and
the differential asserted an error string that exists nowhere in core or
server. Both have reddened main since 7683c1ba, on webjs check, the unit
suite, and the Bun matrix, blocking every open PR.
The runtime diagnostics in form-dispatch.js are untouched: they detect a
request shape server-side and stay reachable however the request was made.
---
.../webjs/references/data-and-actions.md | 2 +-
.../webjs/references/muscle-memory-gotchas.md | 11 +-
packages/server/src/check.js | 455 +----------
.../check/submitter-needs-bound-form.test.js | 716 ------------------
.../test/scanner/html-form-scopes.test.js | 30 +-
.../app/docs/progressive-enhancement/page.ts | 2 +-
website/app/docs/troubleshooting/page.ts | 4 +-
7 files changed, 23 insertions(+), 1197 deletions(-)
delete mode 100644 packages/server/test/check/submitter-needs-bound-form.test.js
diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md
index 7ec2dcfd5..b79f12e08 100644
--- a/.agents/skills/webjs/references/data-and-actions.md
+++ b/.agents/skills/webjs/references/data-and-actions.md
@@ -146,7 +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, ``}>`,
- { 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
diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts
index 57e606c40..b1c0201b4 100644
--- a/website/app/docs/progressive-enhancement/page.ts
+++ b/website/app/docs/progressive-enhancement/page.ts
@@ -177,7 +177,7 @@ export default function NewPost({ actionData }: {
- 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.
+ A per-button action is self-sufficient. The renderer supplies the submission attributes at the level where the action is bound, so a formaction=\${action} button gains formmethod="post" and formenctype ON THE BUTTON, alongside the identity riding its own name/value pair. A submitter's formmethod overrides the form's method per HTML, so the button posts correctly inside a bound form, an unbound form, a form with no method, or a method="get" form. That is what makes a submitter inside a component safe: the component renders its own template in a separate pass with no view of the host page, and it no longer needs one. Bind the enclosing form when the FORM itself should run an action on a plain submit.
diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts
index 476462e1c..6044369a7 100644
--- a/website/app/docs/troubleshooting/page.ts
+++ b/website/app/docs/troubleshooting/page.ts
@@ -56,8 +56,8 @@ export default function Troubleshooting() {
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.
+
Cause: the submission went out as a GET, so it carried no body and the identity rode 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. A submitter you bound with formaction=\${action} does NOT reach this on its own: the renderer gives it formmethod="post", which overrides the form's method per HTML, so it posts correctly inside a form that is unbound, method-less, or even method="get". What reaches this is a formmethod="get" or method="get" written by hand and deliberately honoured, or a hand-authored form carrying the reserved field.
+
Fix: remove the explicit formmethod="get" or method="get" from the submission path you meant to write. Two runtime signals find it: 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.
From ec59af0c59753c9bb0321d56d33b927257728177 Mon Sep 17 00:00:00 2001
From: Vivek
Date: Tue, 11 Aug 2026 01:50:30 +0530
Subject: [PATCH 2/3] fix: clear the removed rule's premise from the surfaces
it outlived
Review of the removal turned up five places still asserting, or still
built for, the enclosing-form question #1307 deleted.
The gallery's submit-todo comment contradicted itself four lines apart,
saying the enclosing form does NOT have to be bound and then that it does,
and pointed readers at the rule this branch removes. That file is scaffold
payload, so it ships into every generated app.
The production onError message for WEBJS_FORM_SUBMITTED_AS_GET named the
enclosing form as the cause; the dev logger four lines above it was already
correct, so the two disagreed on one event. render-server.js still carried
the four-state form-scope comment and passed a vestigial third argument to
a two-parameter render(), both left over from the same removal. check.js
kept two imports whose only uses were inside the deleted function, and the
now-callerless scanner's JSDoc still described the consumer it lost.
---
.../todo/actions/submit-todo.server.ts | 17 ++++------
packages/core/src/render-server.js | 31 ++++---------------
packages/server/src/check.js | 3 +-
packages/server/src/form-dispatch.js | 2 +-
packages/server/src/js-scan.js | 19 ++++++++----
5 files changed, 27 insertions(+), 45 deletions(-)
diff --git a/gallery/modules/todo/actions/submit-todo.server.ts b/gallery/modules/todo/actions/submit-todo.server.ts
index cf9d24bfa..3ba7bba1c 100644
--- a/gallery/modules/todo/actions/submit-todo.server.ts
+++ b/gallery/modules/todo/actions/submit-todo.server.ts
@@ -21,17 +21,12 @@ 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.
+// Third thing to know: no `formaction` url is emitted, because the identity
+// travels in the body instead. So the submission targets whatever the FORM
+// targets, and a form declaring `action="/x"` sends its buttons there. The
+// action still runs when `/x` is a PAGE route; against a `route.ts` or another
+// origin the identity is ignored and nothing runs, which the dev-time client
+// guard reports at submit time.
//
// With JS the component intercepts the submit and calls the underlying action
// directly for the optimistic path, so this runs only with JS off.
diff --git a/packages/core/src/render-server.js b/packages/core/src/render-server.js
index e5e1d28cc..9e9683759 100644
--- a/packages/core/src/render-server.js
+++ b/packages/core/src/render-server.js
@@ -182,23 +182,6 @@ async function renderTemplate(tr, ctx) {
let pendingPropAttrs = [];
/** @type {string[]} */
let pendingSubmitterProps = [];
- // Whether the tag stream is currently inside a form that BOUND an action
- // (#1207), as THIS scan can see it. Three states, and the third is the point:
- // 'bound' an enclosing
opened here and bound an action
- // 'unbound' an enclosing
opened here and bound nothing
- // 'none' there is conclusively no enclosing
- // 'unknown' there may be one, but this scan cannot see it
- //
- // The last two look alike and must not be merged, which is what a boolean did.
- // A component's template is rendered by a SEPARATE pass (`injectDSD` calls
- // `render` on it), so a `` inside a component inside
- // a bound form read as "no form", was refused, and in production vanished
- // from a page that still returned 200. That pass now says 'unknown' and the
- // boundness question is skipped, exactly as the client skips it when it
- // cannot reach the form, so both renderers are best effort in the same place
- // and for the same reason. A top-level scan that simply contains no form
- // stays 'none' and is still refused, because there the answer IS known.
- //
let isCloseTag = false;
// A bound `action=${fn}` is committed at its hole, but the edits it implies
@@ -1037,14 +1020,12 @@ async function injectDSD(html, ctx, ancestors = [], dev) {
// Render the template to HTML. injectDSD recurses on the result so
// nested custom elements (e.g. inside )
// get their own DSD pass.
- // 'unknown' for the form scope (#1207): this is a SEPARATE render pass
- // over one component's own template, driven by walking the already-emitted
- // HTML, so it has no idea whether the host tag sits inside a bound
- // `
`. Passing the default 'none' claimed there was no form at all,
- // which refused a perfectly good `` in a
- // component inside a bound form and, because component SSR errors are
- // isolated, made the button vanish from a page that still returned 200.
- const rawInner = await render(tpl, ctx, 'unknown');
+ // This is a SEPARATE render pass over one component's own template,
+ // driven by walking the already-emitted HTML, so it has no idea whether
+ // the host tag sits inside a `
`. It does not need to: a bound
+ // submitter carries its whole submission (#1307), so nothing here asks
+ // about an enclosing form.
+ const rawInner = await render(tpl, ctx);
if (isShadow) {
// Shadow DOM: native stays as-is in the DSD template. The
diff --git a/packages/server/src/check.js b/packages/server/src/check.js
index ceaaba3cf..b74289029 100644
--- a/packages/server/src/check.js
+++ b/packages/server/src/check.js
@@ -6,12 +6,11 @@ import {
redactToPlaceholders,
extractWebComponentClassBodies,
matchClosingBrace,
- matchClosingParenthesis,
parsePropEntries,
classifyActionHole,
} from './js-scan.js';
import { buildModuleGraph, transitiveDeps, resolveImport } from './module-graph.js';
-import { scanComponents, extractComponents } from './component-scanner.js';
+import { scanComponents } from './component-scanner.js';
import { buildRouteTable } from './router.js';
import { analyzeElision } from './component-elision.js';
import { RESERVED_CONFIG } from './action-config.js';
diff --git a/packages/server/src/form-dispatch.js b/packages/server/src/form-dispatch.js
index 00fb0f717..12d9ea18e 100644
--- a/packages/server/src/form-dispatch.js
+++ b/packages/server/src/form-dispatch.js
@@ -359,7 +359,7 @@ export function reportFormSubmittedAsGet(url, req, onError, logger, dev, route)
}
if (!willReport) return;
const err = new Error(
- `A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. The submitter's enclosing
binds no action.`,
+ `A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. A bound submitter carries its own formmethod="post", so look for a formmethod="get" on the button that was pressed, or a method="get" on its form: a submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
);
/** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET';
/** @type {any} */ (err).method = req.method;
diff --git a/packages/server/src/js-scan.js b/packages/server/src/js-scan.js
index 7236c904f..7d6a76244 100644
--- a/packages/server/src/js-scan.js
+++ b/packages/server/src/js-scan.js
@@ -895,13 +895,20 @@ function isInlineStartTagHole(tagName, literalBefore) {
* submitter action hole (``) and each
* custom-element start tag, the enclosing `
` scope at that point (#1307).
*
+ * NOTE: this currently has NO production caller. It was written for the
+ * `submitter-needs-bound-form` check rule, which #1384 removed because its
+ * premise was false: a bound submitter is self-sufficient, so the enclosing
+ * form's boundness does not decide whether the action runs. The scan itself is
+ * correct about what it reports and is kept for a future consumer, but nothing
+ * reads it today, so treat it as unproven against real-world input.
+ *
* Only an `html`-tagged literal is entered, so `const s = '
'` and a `css`
* or `sql` template are never read as markup. That carve-out matters: the
* framework's own website renders `
` as a code SAMPLE.
*
* A template nested inside a CHILD-position hole INHERITS the enclosing scope,
- * because that is what the renderer does (`render` threads `formScope` through
- * arrays, `repeat`, and nested templates).
+ * because that is where the renderer places it (through arrays, `repeat`, and
+ * nested templates).
*
* One in a START-TAG hole is `'handed'`: an attribute or property value whose
* placement this scan cannot speak for. Worth being exact about why, because the
@@ -922,10 +929,10 @@ function isInlineStartTagHole(tagName, literalBefore) {
* the scan started in, mirroring `handleTagEnd` in `render-server.js`.
*
* `opensForm` reports whether ANY `
Date: Tue, 11 Aug 2026 02:03:50 +0530
Subject: [PATCH 3/3] fix: delete the form-scope scan, whose delivers verdict
is now wrong
The second review round found the scan is not merely callerless. Its
`delivers` field encodes the premise this branch removes:
`unboundFormDelivers` answers false for a form with no method or a
method="get" one, but a bound submitter now carries its own
formmethod="post", which overrides the form, so those forms DO deliver.
Its tests asserted the wrong values, and the blog fixture it would score
false is the one the e2e proves runs its action.
Salvaged the two live surfaces into form-action-holes.test.js:
classifyActionHole, which the form-action-not-a-get-action rule still
uses, and the enctype pin that keeps the renderer allowlist and the client
denylist from drifting.
Also cleared four more residuals of the same premise: the onError message
claimed a bound submitter's formmethod="get" is honoured when it is
refused at render, the renderer comment said a submission targets the page
rather than whatever the form targets, the streaming machine kept an
orphaned form-scope comment, and three doc surfaces called a refused
method="get" on a bound form a warning.
---
.agents/skills/webjs/SKILL.md | 2 +-
.../webjs/references/data-and-actions.md | 2 +-
.../webjs/references/muscle-memory-gotchas.md | 4 +-
packages/core/src/render-client.js | 9 +-
packages/core/src/render-server.js | 13 +-
packages/server/src/form-dispatch.js | 2 +-
packages/server/src/js-scan.js | 385 ------------------
.../test/scanner/form-action-holes.test.js | 70 ++++
.../test/scanner/html-form-scopes.test.js | 325 ---------------
9 files changed, 84 insertions(+), 728 deletions(-)
create mode 100644 packages/server/test/scanner/form-action-holes.test.js
delete mode 100644 packages/server/test/scanner/html-form-scopes.test.js
diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md
index fb5a1e10e..5f069802f 100644
--- a/.agents/skills/webjs/SKILL.md
+++ b/.agents/skills/webjs/SKILL.md
@@ -240,7 +240,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
- Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
- Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
- Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
-- Writing `method="get"` on a bound `
`. WebJs supplies `method="post"` and `formenctype` automatically; manually setting `method="get"` triggers a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning.
+- Writing `method="get"` on a bound `
`. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md
index b79f12e08..5eb4c60c6 100644
--- a/.agents/skills/webjs/references/data-and-actions.md
+++ b/.agents/skills/webjs/references/data-and-actions.md
@@ -169,7 +169,7 @@ A `'use server'` action is a POST by default. Reserved sibling exports, read sta
1. **Form-Bound Actions (`
` / ``):**
- **MUST be POST.** Leave unannotated (default) or export `export const method = 'POST'`.
- **NEVER export `export const method = 'GET'` for form actions.** The HTML renderer automatically emits `method="post"` and `formenctype` for form actions. Binding a `method = 'GET'` action to a form returns a `405 Method Not Allowed` at runtime and triggers a `webjs check` error (`form-action-not-a-get-action`).
- - **NEVER add `method="get"` to a bound `
`.** WebJs manages form submission semantics automatically; adding `method="get"` causes a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning.
+ - **NEVER add `method="get"` to a bound `
`.** WebJs manages form submission semantics automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
2. **Programmatic / RPC Read Actions (Queries):**
- **ALWAYS export `export const method = 'GET'` for read-only queries.**
diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md
index b18835b12..d6c9c8ecd 100644
--- a/.agents/skills/webjs/references/muscle-memory-gotchas.md
+++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md
@@ -120,7 +120,7 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
| `export const method = 'GET'` on a form-bound action file | yes | form-bound actions strictly enforce `POST`. Binding a GET action to a form produces a 405 runtime refusal and `webjs check` error (`form-action-not-a-get-action`) |
-| `method="get"` on a bound `
` | yes | WebJs supplies `method="post"` and `formenctype` automatically; manually setting `method="get"` triggers a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning |
+| `method="get"` on a bound `
` | yes | WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read |
| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
@@ -156,7 +156,7 @@ The renderer supplies the submission attributes at the level where the action is
That is also why the renderer refuses only a SAME-ELEMENT contradiction (a bound submitter's own `formmethod` other than post, an `formenctype` the server cannot parse, `formmethod="dialog"`) and never a cross-element one. A component renders its template in a separate pass with no view of the host page, so the cross-element question is unanswerable at render time, and self-sufficiency leaves nothing for it to answer. One consequence: no `formaction` url is emitted, so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs when `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin nothing runs, which the dev-time client guard reports at submit time.
-**Two runtime signals cover what is left.** 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. Since a bound submitter carries its own `formmethod="post"`, what reaches the first one is an explicit `formmethod="get"` or `method="get"` an author wrote on a PLAIN submitter and the renderer deliberately honours, or a hand-written form. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.
+**Two runtime signals cover what is left.** 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. A BOUND submitter carries its own `formmethod="post"`, and a bound form is refused a `method="get"` outright, so what reaches the first one is a PLAIN submitter's `formmethod="get"`, which native precedence lets win and the renderer deliberately honours, or a hand-authored form carrying the reserved field. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.
**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.
diff --git a/packages/core/src/render-client.js b/packages/core/src/render-client.js
index 7cbc4ce26..af1084481 100644
--- a/packages/core/src/render-client.js
+++ b/packages/core/src/render-client.js
@@ -750,11 +750,10 @@ function buildFormActionRecord(el, onEl, parts) {
* record.
*
* FORMS FIRST, submitters second, regardless of the order the records were
- * collected in. A submitter asks whether its enclosing form is bound, and the
- * cheapest true answer is the identity field the form's own reconcile just
- * inserted. Document order gets this right for a form and a button in the SAME
- * template, but not for a `
` whose submitter arrives through a nested
- * template, so the ordering is made explicit rather than relied upon.
+ * collected in. Nothing reads an enclosing form's boundness any more (#1307
+ * made a bound submitter self-sufficient), but the ordering still makes a
+ * form's RELEASE run before its submitters reconcile, so it is kept rather
+ * than churned. See the note in the body.
*
* @param {FormActionRecord[] | null} formActions
* @param {BoundPart[]} bound
diff --git a/packages/core/src/render-server.js b/packages/core/src/render-server.js
index 9e9683759..b96d2b041 100644
--- a/packages/core/src/render-server.js
+++ b/packages/core/src/render-server.js
@@ -468,14 +468,13 @@ async function renderTemplate(tr, ctx) {
// #1207: the submitter binding. The identity replaces the
// `formaction=` hole IN PLACE with the button's own name/value
// pair, the one channel a browser submits for the pressed button
- // alone. No `formaction` url is emitted, so the submission still
- // targets the page and the form-level identity is simply overridden
- // by this later entry.
+ // alone. No `formaction` url is emitted, so the submission targets
+ // whatever the FORM targets, and a form-level identity is simply
+ // overridden by this later entry.
//
// Refused here rather than at the `>` only where the answer cannot
- // change later: boundness of the ENCLOSING form is already decided
- // (its start tag is emitted), and an attribute written BEFORE the
- // hole is already in `out`. Everything else waits for the close,
+ // change later: an attribute written BEFORE the hole is already in
+ // `out`. Everything else waits for the close,
// where `assertSubmitterStartTag` sees the whole tag.
// A second binding hole on this same tag, refused here so the
// author gets the duplicate message rather than a confusing
@@ -2083,8 +2082,6 @@ async function streamTemplate(tr, ctx, controller) {
let pendingSubmitterProps = [];
/** @type {string | null} */
let pendingSubmitterTag = null; // tag of a bound submitter, until it closes
- // See the buffered machine: seeded from the caller so a nested template
- // rendered into a bound form's children knows it is inside one.
let isCloseTag = false;
// See the buffered machine for why this runs at the `>` rather than at the
diff --git a/packages/server/src/form-dispatch.js b/packages/server/src/form-dispatch.js
index 12d9ea18e..2e1c7f17e 100644
--- a/packages/server/src/form-dispatch.js
+++ b/packages/server/src/form-dispatch.js
@@ -359,7 +359,7 @@ export function reportFormSubmittedAsGet(url, req, onError, logger, dev, route)
}
if (!willReport) return;
const err = new Error(
- `A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. A bound submitter carries its own formmethod="post", so look for a formmethod="get" on the button that was pressed, or a method="get" on its form: a submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
+ `A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. A BOUND submitter carries its own formmethod="post" and a bound form is refused a method="get" outright, so look instead for a PLAIN submitter's formmethod="get", or a hand-authored form carrying the reserved field: a plain submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
);
/** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET';
/** @type {any} */ (err).method = req.method;
diff --git a/packages/server/src/js-scan.js b/packages/server/src/js-scan.js
index 7d6a76244..18ef93406 100644
--- a/packages/server/src/js-scan.js
+++ b/packages/server/src/js-scan.js
@@ -764,388 +764,3 @@ export function classifyActionHole(literalBefore) {
return null;
}
-/**
- * The ONE `enctype` keyword that loses a form body the server could otherwise
- * read.
- *
- * Stated as a denylist rather than an allowlist because `enctype` is an
- * enumerated attribute whose missing value default AND invalid value default are
- * both `application/x-www-form-urlencoded`. So `enctype="nonsense"` falls back to
- * urlencoded and submits a perfectly parseable body; only the third valid
- * keyword, `text/plain`, is a real loss. An allowlist inverts that and reports a
- * working form as broken, which this rule must never do.
- *
- * The renderer's `PARSEABLE_ENCTYPES` (`form-action.js`) refuses the wider set,
- * and that divergence is deliberate on BOTH sides rather than an inconsistency
- * to unify. The two ask different questions. This rule asks whether the
- * identity ARRIVES, and under an invalid enctype it does. The renderer asks
- * whether the form will do what the author wrote, and there an invalid value is
- * the dangerous case: `enctype="multipart/form-dat"` falls back to urlencoded,
- * which silently drops every FILE from the submission, so throwing at render is
- * the only way the author finds out. Unifying them would either make the
- * renderer accept a typo that loses uploads, or make this rule report a working
- * form as broken.
- */
-const UNPARSEABLE_FORM_ENCTYPE = 'text/plain';
-
-/**
- * Read one attribute's literal value out of a start tag's accumulated text.
- * Returns null when absent, and the raw value otherwise (quoted or bare).
- *
- * @param {string} tagText
- * @param {string} name
- * @returns {string | null}
- */
-function startTagAttr(tagText, name) {
- const re = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
- const m = re.exec(tagText);
- if (!m) return null;
- return m[1] ?? m[2] ?? m[3] ?? '';
-}
-
-/**
- * Would an UNBOUND `
` still deliver a submitter's action identity?
- *
- * This is the difference between a broken write path and a working one, and it
- * is not intuitive. A submitter carries its identity in its OWN `name`/`value`
- * pair, which a browser submits for the pressed button alone, so an unbound
- * form that still sends a parseable POST body delivers it and the action RUNS
- * (the dispatcher takes the last `__webjs_action` entry it finds). What breaks
- * is a form that sends no body the server can read: no `method` at all or
- * `method="get"` (a GET puts the identity in the query string and the page just
- * re-renders), or an enctype `parseFormBody` cannot parse (a 405).
- *
- * Both are enumerated attributes matched against exact keywords with no
- * whitespace stripping, and their defaults pull in OPPOSITE directions, which is
- * why they are tested differently. `method` defaults to GET, so anything that is
- * not exactly `post` loses the body (a padded `method=" post "` included).
- * `enctype` defaults to `application/x-www-form-urlencoded` for both a missing
- * AND an invalid value, so only the `text/plain` keyword actually loses it.
- *
- * @param {string} tagText the form's start tag, from `
`
- * @returns {boolean}
- */
-function unboundFormDelivers(tagText) {
- const method = startTagAttr(tagText, 'method');
- if (method === null || method.toLowerCase() !== 'post') return false;
- const enctype = startTagAttr(tagText, 'enctype');
- if (enctype !== null && enctype.toLowerCase() === UNPARSEABLE_FORM_ENCTYPE) return false;
- return true;
-}
-
-/**
- * The ONE start-tag hole the renderer renders INLINE, in the enclosing scan and
- * with the enclosing form scope, rather than handing to the receiving element:
- * `` (#471).
- *
- * A custom-element property applies only at HYDRATION, which is too late for a
- * placeholder that has to be in the first flushed bytes, so `render-server.js`
- * renders the fallback right there and carries the HTML as
- * `data-webjs-fallback`. (The timing is the operative reason on its own. A
- * serializable `TemplateResult` would otherwise ride as `data-webjs-prop-*`
- * perfectly well; only one carrying a function fails to serialize.) That means a submitter in a fallback IS judged against
- * the enclosing form, and the renderer really does throw for an unbound one.
- * Treating it as handed off would make the same-scan half blind to the one shape
- * it exists to pre-warn about.
- *
- * @param {string} tagName lowercased
- * @param {string} literalBefore the literal segment immediately before the hole
- * @returns {boolean}
- */
-function isInlineStartTagHole(tagName, literalBefore) {
- // The property name is a JS identifier, so it is case-SENSITIVE, unlike an
- // attribute name.
- return tagName === 'webjs-suspense' && /\.fallback=$/.test(literalBefore);
-}
-
-/**
- * @typedef {'none'|'unbound'|'bound'|'handed'} FormScope
- *
- * `'none'` and `'handed'` are BOTH "no enclosing form in this scan", and they
- * are deliberately separate values because a caller must treat them
- * differently. `'none'` is a cannot-tell that the file's own component may
- * legitimately own, so a caller may attribute it and resolve it against that
- * component's call sites. `'handed'` is a template sitting in a start-tag hole,
- * which is an attribute or property VALUE: some OTHER element received it and
- * decides where it renders, so this file's call sites say nothing about it and
- * it can never be attributed to anything. Collapsing the two makes a handed-off
- * template resolve through the wrong component's call sites.
- *
- * @typedef {{
- * tag: string,
- * scope: FormScope,
- * delivers: boolean | null,
- * expr: string | null,
- * }} FormScopeSite
- *
- * `expr` is the submitter hole's expression text, verbatim from the redacted
- * source, and null for a tag use. A caller MUST look at it before treating the
- * hole as an action binding: this scan is lexical, while the renderer binds only
- * when the value is a FUNCTION (`isBoundFormAction`), so
- * `formaction=${'/api/' + id}` is an ordinary url attribute that ships fine.
- *
- * `delivers` is meaningful only when `scope` is `'unbound'`: true when that
- * form would still carry a submitter's identity to the server, false when it
- * would not, and null when a hole in its start tag makes the answer dynamic and
- * therefore unknowable.
- */
-
-/**
- * Walk every `` html`...` `` template literal in `src` and report, for each
- * submitter action hole (``) and each
- * custom-element start tag, the enclosing `
` scope at that point (#1307).
- *
- * NOTE: this currently has NO production caller. It was written for the
- * `submitter-needs-bound-form` check rule, which #1384 removed because its
- * premise was false: a bound submitter is self-sufficient, so the enclosing
- * form's boundness does not decide whether the action runs. The scan itself is
- * correct about what it reports and is kept for a future consumer, but nothing
- * reads it today, so treat it as unproven against real-world input.
- *
- * Only an `html`-tagged literal is entered, so `const s = '
'` and a `css`
- * or `sql` template are never read as markup. That carve-out matters: the
- * framework's own website renders `
` as a code SAMPLE.
- *
- * A template nested inside a CHILD-position hole INHERITS the enclosing scope,
- * because that is where the renderer places it (through arrays, `repeat`, and
- * nested templates).
- *
- * One in a START-TAG hole is `'handed'`: an attribute or property value whose
- * placement this scan cannot speak for. Worth being exact about why, because the
- * obvious reason is not the operative one. SSR does NOT render such a template
- * in place: a serializable value rides to the receiving element as
- * `data-webjs-prop-*` and is applied at HYDRATION, and one carrying a function
- * (a bound submitter, by definition) fails to serialize, so `render-server.js`
- * DROPS the binding with a warning and emits nothing for it at all. Either way
- * the element that receives the property decides where the content lands, in the
- * browser, which is exactly what this scan cannot see.
- *
- * The single exception is ``, which the renderer
- * really does render inline with the enclosing scope (see
- * `isInlineStartTagHole`), so that one inherits.
- *
- * A separate top-level template starts fresh at
- * `'none'`, because it is its own scan there too. `
` returns to the scope
- * the scan started in, mirroring `handleTagEnd` in `render-server.js`.
- *
- * `opensForm` reports whether ANY `
` is one tag split
- * into three pieces by the scan. `text` accumulates the tag's literal
- * source so its attributes can be read at the `>`, and `dynamicAttrs`
- * records that a hole other than the action binding landed in it, which
- * makes those attributes unknowable.
- * @type {null | { name: string, isClose: boolean, quote: string | null, formHole: boolean, submitterHole: boolean, submitterExpr: string | null, text: string, dynamicAttrs: boolean }}
- */
- let tag = null;
- let inComment = false;
- let lastLiteral = '';
-
- const closeTag = () => {
- const t = tag;
- tag = null;
- if (!t) return;
- if (t.isClose) {
- // Back to the scope this scan STARTED in, not a flat 'none': a nested
- // template closing a form of its own learns nothing about the form its
- // caller may have opened.
- if (t.name === 'form') { scope = startScope; delivers = startDelivers; }
- return;
- }
- if (t.name === 'form') {
- opensForm = true;
- // A form that opened and bound NOTHING still opens a scope: a submitter
- // inside it is a different answer from one with no form at all. Whether
- // that unbound form would still DELIVER a submitter's identity is a
- // separate question, and the one that decides if the shape is broken.
- scope = t.formHole ? 'bound' : 'unbound';
- delivers = t.formHole ? null : (t.dynamicAttrs ? null : unboundFormDelivers(t.text));
- return;
- }
- if (t.submitterHole) submitters.push({ tag: t.name, scope, delivers, expr: t.submitterExpr || null });
- if (t.name.includes('-')) tagUses.push({ tag: t.name, scope, delivers, expr: null });
- };
-
- /** @param {string} text one literal segment, read as markup */
- const consumeMarkup = (text) => {
- let p = 0;
- while (p < text.length) {
- if (inComment) {
- const end = text.indexOf('-->', p);
- if (end < 0) return;
- inComment = false;
- p = end + 3;
- continue;
- }
- if (tag) {
- const ch = text[p];
- if (tag.quote) {
- if (ch === tag.quote) tag.quote = null;
- tag.text += ch;
- p++;
- continue;
- }
- if (ch === '"' || ch === "'") { tag.quote = ch; tag.text += ch; p++; continue; }
- if (ch === '>') { p++; closeTag(); continue; }
- tag.text += ch;
- p++;
- continue;
- }
- const lt = text.indexOf('<', p);
- if (lt < 0) return;
- if (text.startsWith('x`';
- 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`x`; }',
- '}',
- ].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 + 'x
`',
- ).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`x`').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`x`; }',
- '}',
- ].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`x
`}>`';
- 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`x`}
`';
- 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\`
x
\``;
- 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('a start-tag hole is handed off or inherited, and SSR agrees', 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), so the scanner reads
- // it against that form rather than treating it as handed off.
- 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 });
-
- // Read against the enclosing form, not handed off. Nothing is asserted about
- // a render-time refusal here: a bound submitter is SELF-SUFFICIENT (#1307), so
- // the renderer stamps it with its own `formmethod="post"` and refuses only a
- // same-element contradiction, never a cross-element one.
- const fallbackSrc = 'html`
x
`}>
`';
- assert.deepEqual(scanHtmlFormScopes(fallbackSrc).submitters,
- [{ tag: 'button', scope: 'unbound', delivers: false, expr: 'p' }]);
-
- // 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`
`,
- { ssr: true },
- );
- } finally { console.warn = quiet; }
- assert.doesNotMatch(out, / /unserializable value during SSR/.test(w)),
- 'and it says so, which is the proof SSR never judged this submitter');
-
- // The tag half of the same carve-out. A hyphenated tag in a fallback IS a real
- // call site (rendered inline, inside the enclosing form); one in an ordinary
- // property hole is not.
- const fbTag = 'html`
`';
- assert.deepEqual(scanHtmlFormScopes(propTag).tagUses.filter((u) => u.tag === 'todo-row'),
- [{ tag: 'todo-row', scope: 'handed', delivers: null, expr: null }]);
-});
-
-test('the enctype divergence from the renderer is deliberate, on both sides', async () => {
- // The scanner uses a one-keyword denylist and the renderer an allowlist, and
- // it would be easy to "unify" them later. This pins WHY they differ, so that
- // change breaks a test carrying its own reason.
- //
- // They ask different questions. This rule asks whether the identity ARRIVES,
- // and an invalid enctype falls back to urlencoded, so it does. The renderer
- // asks whether the form does what the author wrote, and an invalid value is
- // the dangerous case: a typo'd `multipart/form-dat` falls back to urlencoded
- // and silently drops every FILE from the submission.
- 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/save');
- const save = async () => ({ success: true });
-
- const sub = (formTag) => scanHtmlFormScopes(
- 'html`' + formTag + 'x`',
- ).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('