From 20288d5fe045deb9d2fedffc9408b4e16de167f0 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:10:57 +0530 Subject: [PATCH 01/23] feat: resolve form-submitter boundness across modules in check A ``; } +} +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 a form with no `method`, so the browser submits it as a GET and 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. + +**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 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, or a reference cycle. 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/AGENTS.md b/AGENTS.md index 154012550..6f78ca21c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -483,7 +483,7 @@ const result = await optimistic(liked, true, () => likePost(postId)); 11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a ``, an `[expr]` subscript, or a `foo()` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs ` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing). -12. **A form that writes binds its action: `
`, and a form whose buttons run different actions binds each on its submitter, `
`'; + assert.deepEqual(scanHtmlFormScopes(src).submitters, [{ tag: 'button', scope: 'none' }]); +}); + +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' }]); +}); diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts index fcd321d6d..667dad8db 100644 --- a/website/app/docs/progressive-enhancement/page.ts +++ b/website/app/docs/progressive-enhancement/page.ts @@ -169,7 +169,11 @@ export default function NewPost({ actionData }: {

- A form that binds nothing gets a 405: there is no page action export to catch a bare <form method="post">. 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, 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/server-actions/page.ts b/website/app/docs/server-actions/page.ts index ab320a5b0..5143eaa03 100644 --- a/website/app/docs/server-actions/page.ts +++ b/website/app/docs/server-actions/page.ts @@ -474,6 +474,8 @@ export default function NewPost({ actionData }: {

Everything the action declares applies here too, or an action would be protected over RPC and open over a form: validate runs on the submitted FormData, the middleware chain runs (with the page's params / searchParams / url on ctx), and invalidates is evicted when the action actually ran. The submission is Origin-verified exactly like an RPC call, so a no-JS form needs no CSRF token field. An action declaring method = 'GET' cannot be bound to a form (it rides its arguments in the URL and skips the CSRF check), which is a 405 at runtime and the form-action-not-a-get-action error in webjs check. A streamed return is refused on this path: a submission is answered with a redirect or a page, and with JS off there is no consumer for frames.

A form whose buttons run different actions binds each one on its submitter, with the same unquoted spelling one level down: <form action=\${saveDraft}>…<button formaction=\${publishPost}>Publish</button></form>. The identity rides the pressed button's own name/value pair, which a browser submits for that button alone, so no formaction url is emitted and the whole thing works with JavaScript off. Both identities reach the server and the LAST wins, which is the submitter's whenever one was pressed.

The submitter must be a <button> inside a form that is itself bound, and it cannot carry its own name, value, form, or a static formaction, because the identity already occupies that name/value pair. Two input controls are refused for the same underlying reason. <input type="image"> submits name.x / name.y coordinates rather than name=value, so the identity would never arrive. <input type="submit"> would receive the identity in its value, which on that control is also the visible caption, so it would render captioned with the action id and the only way to label it is the channel the identity needs. A <button> avoids both, because its label is its children. A .prop spelling of any of these (.name, .value, .formAction, .formMethod, .formEnctype) is refused as well: all reflect on a submitter, so the write is dropped at SSR and lands in the attribute in the browser. Separately, formmethod="get" and an unparseable formenctype like text/plain are refused on ANY submitter inside a bound form, binding or not, since neither can carry the action's body. formmethod="dialog" and a plain formaction="/url" are left alone, because neither submits to the bound action.

+ +

The enclosing form has to be bound, and that is the one requirement the renderer cannot always enforce. SSR reads a linear byte stream one template at a time, and a component renders its own template in a separate pass with no view of the host page, so a formaction=\${action} button inside a component is a cannot-tell. Cannot-tell binds anyway, deliberately: refusing there would reject a per-row button in a list and a button inside a component, and an SSR refusal is isolated per component, so production would return 200 with the button silently gone. The cost is that a submitter in a component inside an UNBOUND form reaches production. It has no method, so the browser submits it as a GET, the reserved identity rides the query string, the action never runs, and the page re-renders with a 200 and no error. webjs check's submitter-needs-bound-form rule closes that gap: it reads every template in the app at once, so it resolves the enclosing form across module boundaries and transitively through intermediate components, and it stays silent on anything indefinite (a tag rendered in a bound form somewhere and an unbound one elsewhere, a tag with no call site, a submitter in a bare html helper, a file registering more than one tag, a reference cycle).

With JavaScript off this is a native round-trip (the browser submits, follows the 303, or renders the 422). With JavaScript on the client router applies the 422 in place (no reload, typed input preserved) and follows the 303 via fetch. Both ends of the progressive-enhancement spectrum from one piece of code, no form library. See the client router docs for the rendering behavior, and progressive enhancement for the full write-path pattern.

`; } diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts index 0e35f0e20..4df7a3a69 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, so it has no method and the browser submits it as a GET. 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.

+

A render fails on a button's formmethod or formenctype

Symptom: a page that renders fine with JavaScript on fails at render with <button formenctype="text/plain"> inside a bound <form action=\${action}> cannot work, or the same message naming formmethod.

Cause: a submitter's formmethod / formenctype overrides the form's own for that button, and a bound action needs a POST body the server can parse as multipart/form-data or application/x-www-form-urlencoded. A GET sends no body at all, and text/plain is flagged by the HTML spec as not intended for machine parsing. Either one submits fine with JavaScript (the client router posts FormData and ignores the attribute) and is a bare 405 without it, so it is refused at render rather than shipped as a form that works one way only. The check runs on EVERY submitter inside a bound form, whether or not that button binds an action of its own, which is why it can fire on a button you never touched.

From a7af25f26e73406657a180a2387f76883a1336d5 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:22:27 +0530 Subject: [PATCH 02/23] feat: report a form submission that cannot deliver its action A submitter bound inside a component whose host form is unbound is the one form-binding mistake that reaches production, and until now it was invisible everywhere. Measured rather than reasoned: the dominant case is not a 405. An unbound form has no `method`, so the browser submits it as a GET, the reserved identity rides the query string, and the page re-renders with a 200. An unbound `` actually WORKS, because the submitter's own name/value pair carries the identity into the body. Only an unparseable enctype reaches the 405. Two layers, both detect-only. In dev the client logs once at submit time. The client cannot answer boundness at reconcile time, but by submit time both the form and the body are in hand. It logs and never throws: the listener is delegated at the document, so a throw would escape uncaught AND abort before preventDefault, making dev behave differently from production. In production both server-visible fingerprints reach the onError hook with a code to group on. A page GET carrying `__webjs_action` in the query string is `WEBJS_FORM_SUBMITTED_AS_GET`; nothing else in the framework ever puts that reserved field in a url. A form body carrying no identity is `WEBJS_FORM_ACTION_MISSING`. The response never changes, because answering a GET differently on a query parameter would hand any visitor a way to turn any page into an error. Reports carry the field NAMES, which are template constants that say WHICH form posted nowhere, and never the values, which are user data. Both are deduplicated per process on method plus pathname with a 256-entry cap, since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid APM sink. The other two 405s are left alone: a non-form POST is answered before the body is read and is the cheapest thing on the file to flood, and the GET-declared-action refusal already has its own check rule. --- .agents/skills/webjs/references/built-ins.md | 2 +- .../webjs/references/data-and-actions.md | 2 +- .../webjs/references/muscle-memory-gotchas.md | 2 + AGENTS.md | 2 +- packages/core/src/router-client.js | 66 +++++++++- .../browser/form-action-submit.test.js | 56 ++++++++ packages/server/src/dev.js | 18 ++- packages/server/src/form-dispatch.js | 121 +++++++++++++++++- .../server/test/routing/form-dispatch.test.js | 83 ++++++++++++ test/bun/form-action-dispatch.mjs | 43 ++++++- website/app/docs/deployment/page.ts | 2 + website/app/docs/troubleshooting/page.ts | 2 +- 12 files changed, 385 insertions(+), 14 deletions(-) diff --git a/.agents/skills/webjs/references/built-ins.md b/.agents/skills/webjs/references/built-ins.md index 4c550adca..ec90e8543 100644 --- a/.agents/skills/webjs/references/built-ins.md +++ b/.agents/skills/webjs/references/built-ins.md @@ -233,7 +233,7 @@ Wired at the single response funnel, covering pages, routes, actions, and assets - **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed. - **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope). -- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM. +- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM. It also carries two framework DIAGNOSTICS that are not request failures, each with an `err.code` to group or filter on, both under `phase: 'action'`: `WEBJS_FORM_SUBMITTED_AS_GET` (a page GET carrying the reserved `__webjs_action` field in its query string, which only a bound submitter inside an UNBOUND form produces, #1307) and `WEBJS_FORM_ACTION_MISSING` (a form body carrying no identity, the 405). Both are detect-only, so the 200 and the 405 are unchanged; both carry `method`, `pathname`, and for the second the submitted field NAMES, never the values; and both are deduplicated per process on method plus pathname with a 256-entry cap, since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink. ```ts const app = await createRequestHandler({ diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 69ece5b24..3e3c3a6c6 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, ` + + `, container); + // A DISPATCHED submit event, not a click. The router handles it exactly + // the same way, but a synthetic event never triggers the browser's own + // submission, so a GET promotion cannot navigate the runner page away. + const fire = () => { + const btn = container.querySelector('button'); + container.querySelector('form').dispatchEvent( + new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter: btn }), + ); + }; + fire(); + await tick(); + + assert.equal(errors.length, 1, 'exactly one console error'); + assert.ok(errors[0].includes('[webjs]'), 'the message is framework-prefixed'); + assert.ok(errors[0].includes('
handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue }); + const next = () => handleCore(req, { state, appDir, coreDir, dev, logger, reportError, reportDevError, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue }); if (state.middleware) { try { return await state.middleware(req, next); @@ -1976,7 +1976,7 @@ async function tryServeFrameworkStatic(path, method, ctx) { } async function handleCore(req, ctx) { - const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled, allowedOrigins } = ctx; + const { state, appDir, coreDir, dev, logger, reportError, reportDevError, cspEnabled, allowedOrigins } = ctx; const url = new URL(req.url); // Decode percent-encoded characters so filesystem lookups match real // filenames. Dynamic route segments like `[slug]` and route groups like @@ -2252,6 +2252,17 @@ async function handleCore(req, ctx) { : undefined, }; if (method === 'GET' || method === 'HEAD') { + // #1307: `__webjs_action` in the QUERY STRING is the fingerprint of a + // bound submitter submitted through an UNBOUND form. Nothing else in + // the framework ever puts that reserved field in a url. Detect only, so + // the render below is unchanged: answering a GET differently because of + // a query parameter would hand any visitor a way to turn any page into + // an error. + reportFormSubmittedAsGet( + url, req, + reportError ? (e) => reportError(e, req, 'action') : undefined, + logger, dev, + ); // A successful render of URL U supersedes a RETAINED render error for // that same URL (#1047), so a reconnecting tab is not handed a frame the // page has since recovered from. Keyed on BOTH frame identity and url: @@ -2281,6 +2292,7 @@ async function handleCore(req, ctx) { actionIndex: state.actionIndex, allowedOrigins, onError: reportError ? (e) => reportError(e, req, 'action') : undefined, + logger, }; const handler = () => runFormAction(page.route, page.params, url, req, ssrOpts, deps); return runWithSegmentMiddleware(req, page.route.middlewares, handler, dev); diff --git a/packages/server/src/form-dispatch.js b/packages/server/src/form-dispatch.js index 1dc31bb9d..94d6cea7b 100644 --- a/packages/server/src/form-dispatch.js +++ b/packages/server/src/form-dispatch.js @@ -214,6 +214,114 @@ function looksLikeFormSubmission(req) { return /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct); } +/** + * Fingerprints already reported this process, keyed `METHOD /path`. Capped, and + * never cleared. + * + * Both signals below are reachable by anyone: an empty urlencoded POST to any + * page path, or `?__webjs_action=x` appended to any url. Reporting every hit + * would turn a public endpoint into a free amplifier into a paid APM sink. The + * cap also matches the intent, since an app needs to learn the SHAPE exists, + * not count it, and a real bug reproduces on the next boot. + * + * @type {Set} + */ +const reportedFormFingerprints = new Set(); +const FINGERPRINT_CAP = 256; + +/** + * @param {string} key + * @returns {boolean} true the first time only, and false once the cap is hit + */ +function firstSighting(key) { + if (reportedFormFingerprints.has(key)) return false; + if (reportedFormFingerprints.size >= FINGERPRINT_CAP) return false; + reportedFormFingerprints.add(key); + return true; +} + +/** + * Reset the per-process report dedupe. Test seam only: the cap and the + * never-cleared set are the point in production. + */ +export function resetFormReportDedupe() { + reportedFormFingerprints.clear(); +} + +/** + * A form posted to a page and carried no action identity, so nothing ran and + * the answer is a 405. Route it to the APM sink with a code an app can group + * on, instead of leaving an anonymous 405 in an access log. + * + * Carries the field NAMES, which are template constants and are what identify + * WHICH form posted nowhere. Never the values, which are user data. The form's + * own `action` attribute is not carried because a bound form has none (the + * renderer strips it so the form posts to its own url), so the request url + * already is that information. + * + * @param {URL} url + * @param {Request} req + * @param {FormData} formData + * @param {((error: unknown) => void) | undefined} onError + * @param {{ warn?: (msg: string, meta?: Record) => void }} [logger] + * @param {boolean} [dev] + */ +export function reportFormActionMissing(url, req, formData, onError, logger, dev) { + if (!firstSighting(`${req.method} ${url.pathname}`)) return; + const fields = [...new Set([...formData.keys()])]; + if (dev && logger && logger.warn) { + logger.warn( + `[webjs] a form posted to ${url.pathname} carrying no action identity, so nothing ran (405). Bind the action: .`, + { fields }, + ); + } + if (typeof onError !== 'function') return; + const err = new Error( + `A form submission to ${url.pathname} carried no \`${FORM_ACTION_FIELD}\` identity, so no server action ran and the request was answered with a 405.`, + ); + /** @type {any} */ (err).code = 'WEBJS_FORM_ACTION_MISSING'; + /** @type {any} */ (err).method = req.method; + /** @type {any} */ (err).pathname = url.pathname; + /** @type {any} */ (err).fields = fields; + onError(err); +} + +/** + * A page GET carrying `__webjs_action` in the QUERY STRING (#1307). Nothing in + * this framework ever puts the reserved field in a url, so this can only be a + * bound submitter submitted through an UNBOUND form: the form defaulted to GET, + * the browser (or `performSubmission`, which promotes a safe-method body to the + * query string) put the identity in the url, and the page is about to render as + * if nothing was submitted. + * + * DETECTS ONLY. The GET keeps rendering its 200 page, because answering + * differently on a query parameter would hand any visitor a way to turn any + * page into an error. + * + * @param {URL} url + * @param {Request} req + * @param {((error: unknown) => void) | undefined} onError + * @param {{ warn?: (msg: string, meta?: Record) => void }} [logger] + * @param {boolean} [dev] + */ +export function reportFormSubmittedAsGet(url, req, onError, logger, dev) { + if (!url.searchParams.has(FORM_ACTION_FIELD)) return; + if (!firstSighting(`${req.method} ${url.pathname}`)) return; + if (dev && logger && logger.warn) { + logger.warn( + `[webjs] ${url.pathname} was requested with \`${FORM_ACTION_FIELD}\` in the query string, which only a bound submitter inside an UNBOUND produces. The form had no method, so the browser submitted it as a GET, the action never ran, and this page is simply re-rendering. Bind the enclosing form: . The submitter-needs-bound-form rule finds these statically.`, + ); + } + if (typeof onError !== 'function') 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.`, + ); + /** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET'; + /** @type {any} */ (err).method = req.method; + /** @type {any} */ (err).pathname = url.pathname; + onError(err); +} + /** * The submitted TEXT fields as a plain record, for repopulating a form the * dispatcher could not run. @@ -258,11 +366,12 @@ function methodNotAllowed() { * actionIndex: import('./actions.js').ActionIndex, * allowedOrigins?: string[], * onError?: (error: unknown) => void, + * logger?: { warn?: (msg: string, meta?: Record) => void }, * }} deps * @returns {Promise} */ export async function runFormAction(route, params, url, req, ssrOpts, deps) { - const { actionIndex, allowedOrigins = [], onError } = deps; + const { actionIndex, allowedOrigins = [], onError, logger } = deps; // Not a form body at all (a stray JSON POST, a probe): the page path exists // and only renders. Answered before the body is touched. @@ -289,8 +398,14 @@ export async function runFormAction(route, params, url, req, ssrOpts, deps) { const actions = formData.getAll(FORM_ACTION_FIELD); const id = actions.length ? actions[actions.length - 1] : null; // A form body carrying no identity: a hand-written `` - // that binds no action. Nothing to run, and the page only renders. - if (typeof id !== 'string' || !id) return methodNotAllowed(); + // that binds no action, or (#1307) a bound submitter whose host form was + // unbound and carried an enctype the server cannot parse. Nothing to run, and + // the page only renders. Reported before the 405 so the shape is not just an + // anonymous status in an access log. + if (typeof id !== 'string' || !id) { + reportFormActionMissing(url, req, formData, onError, logger, !!ssrOpts.dev); + return methodNotAllowed(); + } // The field is framework wire, not app data. Removing it keeps an action // that iterates the FormData (building a record, echoing values back into a // 422 re-render) from seeing a key it did not put there. diff --git a/packages/server/test/routing/form-dispatch.test.js b/packages/server/test/routing/form-dispatch.test.js index f48f20c88..0a9e92269 100644 --- a/packages/server/test/routing/form-dispatch.test.js +++ b/packages/server/test/routing/form-dispatch.test.js @@ -30,6 +30,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { createRequestHandler } from '../../src/dev.js'; import { hashFile } from '../../src/actions.js'; +import { resetFormReportDedupe } from '../../src/form-dispatch.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); // A tmpdir app fixture cannot resolve the bare `@webjsdev/core` specifier @@ -1069,3 +1070,85 @@ export default ({ actionData }) => html\`

\${actio 'and it failed with a message written for a DIFFERENT action'); } finally { console.warn = quiet; } }); + +/** + * #1307 telemetry. Both fingerprints of a form that posts nowhere reach the + * `onError` sink with a code an app can group on, and neither changes the + * response: the GET keeps its 200 and the bind-nothing submission keeps its + * 405. Answering a GET differently because of a query parameter would hand any + * visitor a way to turn any page into an error. + */ +const READ_ONLY_APP = { + 'app/info/page.ts': `import { html } from ${CORE};\nexport default () => html\`

read-only

\`;\n`, +}; + +test('#1307: a form body carrying no identity reports WEBJS_FORM_ACTION_MISSING', async () => { + resetFormReportDedupe(); + const seen = []; + const app = await createRequestHandler({ + appDir: makeApp(READ_ONLY_APP), + dev: false, + onError: (e) => seen.push(e), + }); + await app.warmup(); + + const post = await app.handle(new Request('http://x/info', form({ email: 'a@b.c', note: 'secret text' }))); + assert.equal(post.status, 405, 'the response is unchanged'); + + assert.equal(seen.length, 1, 'exactly one report'); + assert.equal(seen[0].code, 'WEBJS_FORM_ACTION_MISSING'); + assert.equal(seen[0].pathname, '/info'); + assert.equal(seen[0].method, 'POST'); + // Field NAMES identify WHICH form posted nowhere and are template constants. + assert.deepEqual(seen[0].fields, ['email', 'note']); + // Field VALUES are user data and must never ride the report. + assert.doesNotMatch(JSON.stringify(seen[0].fields) + seen[0].message, /secret text|a@b\.c/); +}); + +test('#1307: a page GET carrying __webjs_action still renders 200 and reports', async () => { + resetFormReportDedupe(); + const seen = []; + const app = await createRequestHandler({ + appDir: makeApp(READ_ONLY_APP), + dev: false, + onError: (e) => seen.push(e), + }); + await app.warmup(); + + const resp = await app.handle(new Request('http://x/info?__webjs_action=abc%2FdoThing')); + assert.equal(resp.status, 200, 'detect only: the page still renders'); + assert.match(await resp.text(), /read-only/); + + assert.equal(seen.length, 1); + assert.equal(seen[0].code, 'WEBJS_FORM_SUBMITTED_AS_GET'); + assert.equal(seen[0].pathname, '/info'); + + // Deduplicated per process on method + pathname: either fingerprint is + // reachable by an unauthenticated attacker, so an unbounded report would be a + // free amplifier into a paid APM sink. + await app.handle(new Request('http://x/info?__webjs_action=abc%2FdoThing')); + await app.handle(new Request('http://x/info?__webjs_action=other')); + assert.equal(seen.length, 1, 'a flood of crafted requests produces one report'); +}); + +test('#1307: an ordinary page GET and a non-form POST report nothing', async () => { + resetFormReportDedupe(); + const seen = []; + const app = await createRequestHandler({ + appDir: makeApp(READ_ONLY_APP), + dev: false, + onError: (e) => seen.push(e), + }); + await app.warmup(); + + assert.equal((await app.handle(new Request('http://x/info'))).status, 200); + // The 405 answered before the body is read: a stray JSON POST or a probe is + // not an app bug, and it is the cheapest thing on the file to flood. + const probe = await app.handle(new Request('http://x/info', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + })); + assert.equal(probe.status, 405); + assert.deepEqual(seen, []); +}); 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/deployment/page.ts b/website/app/docs/deployment/page.ts index 5fd3233da..c09dbde2a 100644 --- a/website/app/docs/deployment/page.ts +++ b/website/app/docs/deployment/page.ts @@ -199,6 +199,8 @@ const app = await createRequestHandler({ });

The contract is best-effort. A throwing onError is caught and ignored so it can never crash the response, and the hook is purely additive: webjs's existing behavior (the sanitized 500, with only error.message in prod and never the stack) is unchanged. The hook fires BEFORE the sanitized response is sent, so the sink always sees the real error.

+

Two framework diagnostics ride the same hook, and neither is a request failure, so both carry an err.code you can group or filter on. WEBJS_FORM_SUBMITTED_AS_GET fires on a page GET carrying the reserved __webjs_action field in its query string, which only a bound submitter inside an UNBOUND form can produce; it is the one form-binding mistake that reaches production silently, because the page still renders a 200. WEBJS_FORM_ACTION_MISSING fires when a form body carries no identity at all, the case answered with a 405. Both are detect-only, so neither changes the status; each carries method and pathname, plus the submitted field NAMES for the second (template constants that identify WHICH form posted nowhere, never the values, which are user data); and each is deduplicated per process on method plus pathname with a 256-entry cap, because both are reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink. In dev the same two detections also log a warning, which is what makes the no-JS write path visible without an APM wired. See Troubleshooting.

+

Build-info endpoint

GET /__webjs/version returns JSON describing the live build, alongside the health and readiness probes. A deploy can curl it to confirm which build is serving. It carries no secrets, and it is answered before the analysis warms (like the other probes), so it responds on a cold instance.

GET /__webjs/version diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts index 4df7a3a69..d0631ead4 100644 --- a/website/app/docs/troubleshooting/page.ts +++ b/website/app/docs/troubleshooting/page.ts @@ -57,7 +57,7 @@ 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, so it has no method and the browser submits it as a GET. 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.

+

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 that renders fine with JavaScript on fails at render with <button formenctype="text/plain"> inside a bound <form action=\${action}> cannot work, or the same message naming formmethod.

From 53590c351469c139e0caab7f84a372f0b19230f7 Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 22:31:24 +0530 Subject: [PATCH 03/23] test: dogfood the cannot-tell submitter shape end to end No in-repo app exercised the fallback. `/feedback/triage` keeps the form and the submitter in ONE template, so the renderer resolves boundness in a single scan and the cannot-tell path is never taken. `/feedback/triage-split` is the other half: the form is bound in the page and the submitter is bound one module over, in a component that renders in its own pass. That is the shape SSR cannot judge and therefore binds on faith, and the e2e submits it with JavaScript off. It doubles as the counterfactual for the whole change. Make cannot-tell refuse and the component renders empty (an SSR component error is isolated), so the button leaves the DOM and the e2e goes red on a page that still returns 200. Also pins the streamed twin of the two KNOWABLE refusals. The streamed state machine is a second implementation of the same scan, so a shape both machines express has to be asserted through both entry points. A cannot-tell cannot arise there at all: the component pass that seeds 'unknown' lives in injectDSD, which both entry points share and `{ ssr: false }` never reaches. --- .../blog/app/feedback/triage-split/page.ts | 48 +++++++++++++++++++ .../feedback/components/publish-button.ts | 29 +++++++++++ .../todo/actions/submit-todo.server.ts | 10 ++++ .../rendering/form-action-binding.test.js | 33 +++++++++++++ test/e2e/e2e.test.mjs | 39 +++++++++++++++ 5 files changed, 159 insertions(+) create mode 100644 examples/blog/app/feedback/triage-split/page.ts create mode 100644 examples/blog/modules/feedback/components/publish-button.ts diff --git a/examples/blog/app/feedback/triage-split/page.ts b/examples/blog/app/feedback/triage-split/page.ts new file mode 100644 index 000000000..c7fb55af5 --- /dev/null +++ b/examples/blog/app/feedback/triage-split/page.ts @@ -0,0 +1,48 @@ +import { html } from '@webjsdev/core'; +import { saveDraft } from '#modules/feedback/actions/save-draft.server.ts'; +import '#modules/feedback/components/publish-button.ts'; + +/** + * The same page as `/feedback/triage`, with the Publish button moved into a + * component (#1307). + * + * `/feedback/triage` keeps the form and the submitter in ONE template, so the + * renderer resolves boundness in a single scan and the cannot-tell path is + * never taken. This route is the other half: the form is bound here and the + * submitter is bound one module over, which is the shape SSR cannot judge in + * one pass and therefore binds on faith. + * + * It is dogfood coverage and an e2e fixture at once. With JavaScript off the + * served markup must still carry the component-rendered button's + * `name="__webjs_action"` and its `/publishDraft` value, and a native + * submit must run the action. If the cannot-tell fallback were ever made to + * refuse, the component would render empty (SSR component errors are isolated), + * the button would not be in the DOM at all, and that e2e would fail. + */ + +type PageCtx = { + actionData?: { fieldErrors?: Record; values?: Record }; +}; + +export const metadata = { title: 'Triage (split) - WebJs Blog' }; + +export default function TriageSplitPage({ actionData }: PageCtx) { + const err = actionData?.fieldErrors?.note; + const val = actionData?.values?.note || ''; + return html` +
+

Triage a note (split)

+ + + ${err ? html`

${err}

` : ''} +
+ + +
+ +
+ `; +} 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..9ca39df26 --- /dev/null +++ b/examples/blog/modules/feedback/components/publish-button.ts @@ -0,0 +1,29 @@ +import { html, WebComponent } from '@webjsdev/core'; +import { publishDraft } from '#modules/feedback/actions/publish-draft.server.ts'; + +/** + * The submitter half of the CANNOT-TELL shape (#1307), split out of its form on + * purpose. + * + * A component renders its own template in a separate pass with no view of the + * host page, so when the renderer reaches this `formaction=${publishDraft}` it + * cannot know whether the enclosing `
` binds an action. That is the + * cannot-tell answer, and cannot-tell BINDS: refusing there would reject a + * per-row button in a list and a button inside a component, both ordinary + * shapes, and an SSR refusal is isolated per component, so production would + * return 200 with this button silently gone. + * + * `/feedback/triage-split` renders this inside a form that IS bound, which is + * the fallback working correctly. It is also the counterfactual for the whole + * change: make cannot-tell refuse and this component renders empty, so the e2e + * that submits this button with JavaScript off goes red. + * + * The mirror image, this button inside an UNBOUND form, is what + * `webjs check`'s `submitter-needs-bound-form` rule exists to catch. + */ +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..1c02e65f0 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 @@ -19,6 +19,16 @@ 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. An unbound host form then submits as +// a GET, 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/core/test/rendering/form-action-binding.test.js b/packages/core/test/rendering/form-action-binding.test.js index 2d097fa3c..cdad7c82b 100644 --- a/packages/core/test/rendering/form-action-binding.test.js +++ b/packages/core/test/rendering/form-action-binding.test.js @@ -698,6 +698,39 @@ test('an UNBOUND form is refused, which is a different answer from cannot-tell', ); }); +test('the STREAMING renderer refuses both knowable submitter shapes too', async () => { + // The streamed state machine is a second implementation of the same scan, so + // the shapes both machines DO express have to be asserted through both entry + // points, or a change to one could pass on the other's coverage. + // + // Only the two knowable answers can be pinned here. A cannot-tell cannot even + // arise inside `streamTemplate`: the component pass that seeds 'unknown' + // lives in `injectDSD`, which is shared by both entry points and is not + // reached by `{ ssr: false }`. + withResolver(); + await assert.rejects( + () => drain(renderToStream(html``, { ssr: false })), + /requires the enclosing to also be bound/, + "'none': the scan can see there is no form at all", + ); + await assert.rejects( + () => drain(renderToStream( + html`
`, + { ssr: false }, + )), + /requires the enclosing
to also be bound/, + "'unbound': the form is right there and binds nothing", + ); + // And the bound shape still streams, so the refusals above are discriminating + // rather than a blanket rejection of every submitter. + const ok = await drain(renderToStream( + html`
`, + { ssr: false }, + )); + assert.equal((ok.match(/name="__webjs_action"/g) || []).length, 2, + 'the form identity and the submitter identity both stream'); +}); + 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 diff --git a/test/e2e/e2e.test.mjs b/test/e2e/e2e.test.mjs index 1ba141386..40fbf3a2c 100644 --- a/test/e2e/e2e.test.mjs +++ b/test/e2e/e2e.test.mjs @@ -3426,6 +3426,45 @@ describe('E2E: form actions (no-JS + enhanced)', { skip: !process.env.WEBJS_E2E } finally { await p.close(); } }); + test('JS DISABLED: a component-rendered submitter still carries its identity (#1307)', async () => { + // `/feedback/triage-split` is the CANNOT-TELL shape: the form is bound in + // the page and the submitter is bound one module over, inside a component + // that renders in its own pass with no view of the host page. SSR cannot + // resolve boundness there, so it binds on faith. + // + // This is the counterfactual for that decision. If the fallback were ever + // made to refuse, the component would render EMPTY (an SSR component error + // is isolated) and the button would not be in the DOM at all, so both + // assertions here would fail on a page that still returned 200. + const p = await paBrowser.newPage(); + await p.setJavaScriptEnabled(false); + try { + await p.goto(`${paBase}/feedback/triage-split`, { waitUntil: 'domcontentloaded', timeout: 10000 }); + const shape = await p.evaluate(() => { + const publish = document.getElementById('publish'); + return { + present: !!publish, + name: publish?.getAttribute('name') || null, + value: publish?.getAttribute('value') || null, + hasFormAction: publish ? publish.hasAttribute('formaction') : null, + }; + }); + assert.ok(shape.present, 'the component-rendered submitter must be in the served markup'); + assert.equal(shape.name, '__webjs_action', 'the cannot-tell fallback binds, so the identity is emitted'); + assert.ok(/^[0-9a-f]{10}\/publishDraft$/.test(shape.value || ''), + `the submitter value must name publishDraft, got ${shape.value}`); + assert.equal(shape.hasFormAction, false, 'no formaction url is emitted, so it posts to this page'); + + await p.type('#note', 'ship it'); + await Promise.all([ + p.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 10000 }), + p.click('#publish'), + ]); + const ran = await p.evaluate(() => document.getElementById('ran')?.textContent || ''); + assert.equal(ran, 'publishDraft', `the component's action must run with JS off, got "${ran}"`); + } finally { await p.close(); } + }); + test('JS DISABLED: a failing submitter action re-renders THIS page at 422', async () => { // The per-button path has to reach the same 422 re-render as the form-level // one, or a validation failure on a submitter action would lose the page. From 72c30c83484f567dc4e4d79e06cc4fb8d0aa7b5c Mon Sep 17 00:00:00 2001 From: Vivek Date: Thu, 6 Aug 2026 23:01:59 +0530 Subject: [PATCH 04/23] fix: separate form boundness from deliverability in the check rule The rule treated "unbound" as the defect, but the defect is "cannot deliver the identity", and the two come apart on exactly the shape the PR body already called out as working. An unbound `
` delivers: a submitter's identity rides its own name/value pair, so it reaches the body and the dispatcher runs the action. Flagging it was a false positive on working code, told through a message describing a GET and a query string that never happen. The scanner now reports, per unbound form, whether it would still carry the identity (`method="post"` plus a parseable enctype), with null when a hole makes the answer dynamic. The cross-module verdict fires only when EVERY call site cannot deliver. The same-scan case still fires whatever its method, because the renderer refuses that shape outright, and its message now says so instead of describing the silent path. Three more from the same read: The dedupe key is the matched ROUTE, not the request pathname. Keyed on the pathname a dynamic route yields unbounded distinct keys, so a few hundred crafted urls filled the 256-entry cap and permanently silenced both diagnostics, which is worse than the amplification the cap exists to stop. The code is part of the key too, so one signal cannot silence the other, and a slot is no longer spent when there is no sink and no dev logger to receive the report. That last one needed `hasOnError` threaded into the request context, because `reportError` is always a function and no-ops internally, so it can never say nobody is listening. The class body is located in the MASK and sliced out of the raw source at the same offsets, so the brace matcher is never asked to lex raw source. It does not handle regex literals, and a component carrying `static re = /[{]/` produced zero class bodies, silently dropping the cross-module half of the rule for that file. A file that opens a `` of its own can no longer attribute a scope-none tag use to its component. A fragment built into a local and spliced into that form inherits the splice point's scope, which is the same reasoning the submitter half already applied to a bare helper. Also brings the per-package module maps in packages/server/AGENTS.md and packages/core/AGENTS.md up to date, and corrects the four doc surfaces that had copied the over-broad "an unbound form submits as a GET" claim. --- .agents/skills/webjs/SKILL.md | 4 +- AGENTS.md | 2 +- packages/core/AGENTS.md | 2 +- packages/server/AGENTS.md | 4 +- packages/server/src/check.js | 76 +++++++--- packages/server/src/dev.js | 13 +- packages/server/src/form-dispatch.js | 66 +++++++-- packages/server/src/js-scan.js | 121 +++++++++++++--- .../check/submitter-needs-bound-form.test.js | 134 ++++++++++++++++-- .../server/test/routing/form-dispatch.test.js | 54 +++++++ .../test/scanner/html-form-scopes.test.js | 78 ++++++++-- website/app/docs/troubleshooting/page.ts | 2 +- 12 files changed, 464 insertions(+), 92 deletions(-) diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index b5f02ebd1..3973d8d52 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -105,7 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection 9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s). 10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators. 11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor). -12. A form that writes binds its action: ``, or a per-button `