You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Line anchors below are against HEAD ddfc5547. Every anchor in the previous
version of this body was re-verified, and the four that had drifted or were
wrong are corrected inline with a note.
Problem
Form-submitter boundness is a FOUR-state inference in the renderer
('none' / 'unbound' / 'bound' / 'unknown', typedef at packages/core/src/render-server.js:66), and the 'unknown' branch binds
anyway. That fallback is correct and must stay: refusing on cannot-tell would
reject a per-row button in a list and a button inside a component, both ordinary
shapes, and at SSR a refused component is ISOLATED, so the button would silently
vanish from a page that still returned 200. The rationale is recorded at packages/core/src/form-action.js:354-374, which also records that an earlier
version of that comment wrongly claimed SSR always knows.
The residual is that a formaction=${fn} button whose enclosing <form> is
genuinely NOT bound reaches production whenever the two live in different
modules. That is the worst failure of the three inference-based subsystems
(elision, action seeding, form boundness): it breaks a WRITE path, it hits the
no-JS path hardest, and today it is invisible everywhere.
Correction to the previous problem statement, measured rather than reasoned.
The old body claimed the production symptom is "the form defaults to GET, sends
no body, and the submission is answered with a bare 405". The 405 half is wrong.
Running the shape through the real renderer
(renderToString on html\
An unbound <form> with no method (the dominant case) is a silent
200. The browser submits it as a GET to the page's own url with __webjs_action in the QUERY STRING. packages/server/src/dev.js:2254
routes a GET at a page path straight to ssrPage, so the page simply
re-renders. No action runs, no 405, no log, no telemetry. With JS the outcome
is identical, because performSubmission promotes a safe-method body to the
query string (packages/core/src/router-client.js:1449-1456).
An unbound <form method="post"> actually WORKS. The submitter's own name/value pair carries the identity into the body, and the dispatcher
takes the last __webjs_action it finds
(packages/server/src/form-dispatch.js:289-291), so the action runs. Nothing
to report here.
An unbound form with an unparseable enctype is the 405.parseFormBody
only parses multipart and urlencoded, so formData comes back empty, no
identity is found, and packages/server/src/form-dispatch.js:293 answers methodNotAllowed().
The previous body put that 405 at form-dispatch.js:241. Line 241 is inside looksLikeFormSubmission's doc comment. The bind-nothing 405 is line 293; methodNotAllowed() itself is defined at line 239, and the second 405, the one
carrying allow: GET, is at line 338 and belongs to the GET-declared-action
refusal, which is a different rule with its own check.
This changes what "make the residual loud" has to mean. The dominant residual
never reaches the 405 at all, so telemetry hung only on that 405 would report
almost nothing. It does, however, leave a perfect fingerprint: a page GET
carrying __webjs_action in the query string can ONLY be produced by a bound
submitter submitted through an unbound form. Nothing else in the framework ever
puts that reserved field in a url.
The ambiguity is resolvable, just not by either renderer. SSR reads a linear
byte stream one template at a time, and a component renders its own template in
a separate pass seeded 'unknown' with no view of the host page
(packages/core/src/render-server.js:1063-1070). The client has its own version
of the limit (packages/core/src/render-client.js:910, asked once per element,
skipped while the fragment is detached). webjs check has NEITHER limitation:
it reads every template in the app at once and already knows which holes bind
actions.
Design / approach
Three layers, in priority order. No file under packages/core/src/ that
implements the render-time refusal matrix changes. The cannot-tell fallback
keeps binding, assertSubmitterFormIsBound keeps its signature and message, and
neither SSR state machine is edited.
Layer 1: a new webjs check rule, submitter-needs-bound-form
Name changed from the issue's original submitter-form-is-bound. None of
the 20 existing rules uses an is copula. The registry splits into no-<bad thing> names and subject-predicate names, and the closest existing shape is use-server-needs-extension (subject, needs, object), which also names the
fix. submitter-needs-bound-form matches it exactly. "Submitter" is the domain
word invariant 12 and form-action.js already use, so it stays.
Error, not warning, and there is no third option.Violation in packages/server/src/check.js:36-41 has no severity field, so check has no
severity axis to place a rule on. The only question is whether the rule belongs
in the tool at all, and AGENTS.md settles it with one test: could a sensible app
legitimately want this to pass? A form that posts nowhere is a broken write path
under any project's conventions, so it is a correctness rule.
The resolution algorithm. For every app file, walk the html templates and
record two event kinds with the enclosing form scope at each point: submitter
action holes (<button|input formaction=${...}>) and custom-element start tags
(<x-y>). Scope is 'bound' when the enclosing <form> in the same scan
carried an action=${...} hole, 'unbound' when it did not, 'none' when
there is no enclosing form in this scan. Then:
A submitter at 'bound' is fine. Silent.
A submitter at 'unbound' is flagged. The renderer refuses this same shape at
render time, but only if that branch actually renders, so catching it
statically is strictly earlier and can never false-positive.
A submitter at 'none' is the cross-module question. It is attributed to a
component only when the file registers exactly ONE tag and declares exactly
ONE WebComponent class, and only when the submitter sits inside that class
body. Anything else is UNKNOWABLE and stays silent, because a bare html fragment returned by a helper is rendered inside the CALLER's scan with
the caller's scope (render() passes formScope through arrays, repeat,
and nested templates, see render-server.js:137, :142, :155), so a helper
that looks form-less at rest can be perfectly bound at runtime.
For an attributed component tag, resolve every call site of that tag across
the app. A call site at 'bound' or at 'none' inside an unknowable module
makes the verdict UNKNOWABLE. A call site at 'none' inside another
single-component module recurses into that component's verdict, memoized, with
a cycle guard that returns UNKNOWABLE. Zero call sites is UNKNOWABLE too,
because a tag no app template renders may still be rendered by markup the scan
cannot see. Only when there is at least one call site and EVERY one resolves
to 'unbound' does the rule fire.
The recursion is deliberate rather than a one-level lookup: a page renders <form action=${fn}> around <todo-list>, which renders <todo-row>, which
renders the button, is an ordinary shape, and a one-level rule would go silent
on it, which is the case the rule exists for.
Conservatism prior art.no-missing-local-import (registry at check.js:154, implementation at check.js:1320-1370) is the model: it treats a
module as UNKNOWABLE and never flags through it when its exports are not fully
enumerable. no-server-import-in-browser-module (check.js:1539) is the model
for the other half, reusing the build's own verdict rather than re-deriving a
parallel notion of what ships. This rule takes the tag-to-module map from extractComponents (packages/server/src/component-scanner.js:51) applied to
the files array checkConventions has already read, so it needs no second
filesystem walk and no scanComponents(appDir) call.
The docs-sample hazard is inherited and already solved. The framework's own
website renders <form action=${fn}> as a code SAMPLE, which is why the
existing rule uses redactToPlaceholders rather than either blanking mask (see
the comment at check.js:1389-1396). The new scanner adds one more guard on top:
it only enters a template frame for an html-tagged literal, so a plain string
such as const s = '<form>' is never read as markup.
Layer 2: a dev-only client guard at submit time
The client cannot tell at reconcile time, but it always can by submission time,
because the form and its submitter are both in the tree and the FormData is
already built. onSubmit in packages/core/src/router-client.js:564 is the one
delegated submit path, and buildSubmitFormData at line 593 produces the exact
body the submission will carry.
It logs, it never throws.onSubmit is a document-level delegated listener,
so a throw there escapes as an uncaught error AND aborts before e.preventDefault() and performSubmission, which would make the page behave
differently in dev than in production. The prior art agrees on both counts:
Turbo builds an Error for a submission it cannot use
(~/Documents/Projects/frameworks/turbo/src/core/drive/form_submission.js:149)
and its navigator delegate console.errors it rather than rethrowing
(~/Documents/Projects/frameworks/turbo/src/core/drive/navigator.js:110).
It uses console.error, not console.warn. The existing warnOnce helper
(router-client.js:763-776) emits console.warn for degradations that are
correct but suboptimal. A form that posts nowhere is a broken write path, which
is the severity console.error carries, and the WebJs dev overlay does not hook console.error (packages/server/src/dev-overlay.js registers only click, webjs:navigate, webjs:before-cache and popstate listeners), so raising the
level pops nothing. warnOnce gains an optional level argument rather than
growing a second once-per-key set beside it.
Production is silenced by the same NODE_ENV === 'production' early return every
other client diagnostic uses (router-client.js:956, :980, :1002).
Layer 3: production telemetry on both server-visible fingerprints
onError already reaches runFormAction through deps.onError
(packages/server/src/dev.js:2280-2285, wired to reportError with phase 'action'), and reportError fans out to the programmatic createRequestHandler({ onError }) option AND to any sink an instrumentation.{js,ts} installed via setOnError
(packages/server/src/dev.js:552). So no new plumbing is needed, only two call
sites and an error shape.
WEBJS_FORM_SUBMITTED_AS_GET on a page GET whose url carries __webjs_action. This is the dominant residual and today it is completely
invisible.
WEBJS_FORM_ACTION_MISSING on the bind-nothing 405 at form-dispatch.js:293.
The code rides on err.code, matching the one existing precedent for a coded
framework error (packages/server/src/ts-strip.js:109 sets e.code = 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX').
The response never changes. The GET keeps rendering its 200 page and the
405 stays a 405. Answering a GET differently because of a query parameter would
hand any visitor a way to turn any page into an error, and this layer is
diagnostics, not enforcement.
What the signal carries, and what it must not. The method and the pathname,
plus for the missing-identity case the submitted field NAMES. Field names are
template constants that identify WHICH form posted nowhere, which is the whole
diagnostic value; field VALUES are user data and are never included. The form's
own action attribute is deliberately not carried, because a bound form has
none: the renderer strips it so the form posts to the page's own url
(bindFormActionStartTag, packages/core/src/form-action.js:677), so the
request url already is that information.
Both signals are deduplicated per process, keyed on method + pathname, with
a 256-entry cap. Either fingerprint is reachable by an unauthenticated
attacker, who can POST an empty urlencoded body to any page path or append ?__webjs_action=x to any url. Without a cap that is a free amplifier into a
paid APM sink. Deduplication also matches the intent: an app needs to learn the
shape exists once, not count it. A real bug reproduces on the next boot.
In dev the same two detections also emit a logger.warn, which is what makes
the no-JS write path loud for a developer who has no APM sink wired. That is the
path progressive enhancement exists to protect and the path layer 2 cannot see.
Alternatives considered and rejected
Make the cannot-tell fallback refuse. Rejected for the reason already
recorded at packages/core/src/form-action.js:354-374. It rejects ordinary
shapes, and at SSR the refusal is isolated per component, so production
returns 200 with the button silently gone. Strictly worse than the bug.
Have SSR pass the real form scope into the component pass. The component
pass walks already-emitted HTML in injectDSD
(render-server.js:932, seeding at :1070), so the enclosing form is in the
emitted byte stream and could in principle be read back. Rejected: it makes
the DSD pass depend on parsing its own output for HTML structure, it cannot
help the client at all, and it still leaves the pure-client render path
uncovered. The static analyser sees more than either renderer can and costs
the renderers nothing.
A separate severity or an opt-out for the new rule. Rejected: webjs check rules are unconditional by construction (check.js:20-26),
and adding
a severity axis for one rule would change the tool's contract.
Report the 405 without deduplication, as Next does. Next throws a named
error at the dispatch boundary when the action id cannot be resolved
(~/Documents/Projects/frameworks/next.js/packages/next/src/server/app-render/action-handler.ts:871)
and warns through warnBadServerActionRequest
(action-handler.ts:672), with no per-path dedupe. Rejected here because
WebJs routes the signal into a user-supplied APM sink rather than only into
its own logger, so unbounded attacker-triggerable calls have a cost Next's
console warning does not.
Do nothing at the server because the shape is already refused when it is
knowable. Rejected because the measurement above shows the dominant case is
a silent 200 with no diagnostic anywhere. Rails is the contrast worth naming:
a form posting to a controller action that does not exist raises AbstractController::ActionNotFound
(~/Documents/Projects/frameworks/rails/actionpack/lib/abstract_controller/base.rb:150)
rather than quietly rendering something. A write path that goes nowhere should
never be silent.
Implementation plan
Step 1. Add the shared template scanner to packages/server/src/js-scan.js
New export at the end of the file, after redactToPlaceholders
(js-scan.js:687-690). It consumes that function's output, which was verified
against the real implementation to produce exactly the shape this needs. For the
source
so backticks and holes survive as code, each literal BODY is one token, and the
string in code position is token 0, indistinguishable from a real form only if
the scanner ignores which template it is in. Hence:
/** * Walk every `html`-tagged template literal in `src` and report, for each * submitter action hole and each custom-element start tag, the enclosing * `<form>` scope at that point. * * Only an `html`-tagged literal is entered, so `const s = '<form>'` and a `css` * or `sql` template are never read as markup. A template nested inside a hole * INHERITS the enclosing scope, because that is what the renderer does: `render` * threads `formScope` through arrays, `repeat`, and nested templates * (render-server.js:137, :142, :155). A separate top-level template starts * fresh at 'none', because it is its own scan there too. * * @param {string} src * @returns {{ * submitters: Array<{ tag: string, scope: 'none'|'unbound'|'bound' }>, * tagUses: Array<{ tag: string, scope: 'none'|'unbound'|'bound' }>, * }} */exportfunctionscanHtmlFormScopes(src){…}
The walk mirrors the renderer's own state machine at packages/core/src/render-server.js:207-250, which is the reference
implementation to read before writing this. Per literal segment fed into the
walker, track:
an open <form start tag, and whether an action hole landed inside it before
its >, so > pushes a frame with scope 'bound' or 'unbound'
(the renderer's equivalent is closeBoundFormTag, render-server.js:216-248);
</form> pops back to the enclosing frame, or to 'none' at the top;
a start tag whose name contains a hyphen emits a tagUses entry at the
current scope;
a <button or <input start tag emits a submitters entry at the current
scope when a hole lands on its formaction= attribute.
The hole-to-attribute test is the one already written at check.js:1408-1420:
take the literal immediately before the hole, find the last < with no >
after it, and match the tag and attribute as a PAIR so <form formaction=${x}>
and <button action=${x}> stay out.
Extract that pair test rather than duplicating it. Add a second small export
beside the scanner:
/** * Classify a template hole by the start tag and attribute it commits. * Returns 'form' for `<form action=`, 'submitter' for `<button|input * formaction=`, and null for anything else. * * @param {string} literalBefore the literal segment immediately before the hole * @returns {'form' | 'submitter' | null} */exportfunctionclassifyActionHole(literalBefore){…}
and rewrite the loop at check.js:1408-1420 to call it. Today that loop reads:
form-action-not-a-get-action treats both kinds identically, so collapsing them
to a truthiness test is behaviour-preserving, and its existing suite at packages/server/test/check/form-action-not-a-get-action.test.js is the
regression proof. Keep the long comment at check.js:1389-1406 where it is: it
explains why placeholder redaction is forced, which is still true of the block
that survives.
Step 2. Register the rule in packages/server/src/check.js
Insert a new entry into RULES immediately after form-action-not-a-get-action
(the object at check.js:138-142, closing brace at 142) and before no-redirect-in-api-route (check.js:143), so the two form-action rules sit
together. The description IS the rule's documentation, since webjs check --rules prints it, so it has to carry the full contract:
{name: 'submitter-needs-bound-form',description:
'Flags a `<button formaction=${action}>` submitter (#1207) that a whole-app scan proves sits in a `<form>` carrying no `action=${action}` binding. An unbound form defaults to GET, so the submission rides the reserved `__webjs_action` field in the QUERY STRING and the page simply re-renders: the action never runs, and nothing anywhere reports it. The renderers cannot catch this alone. SSR reads 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 submitter in a component inside an unbound form is a cannot-tell, and cannot-tell has to bind (refusing there would drop an isolated component from a page that still returned 200). This rule has neither limit: it reads every template in the app at once. Deliberately conservative so it can never false-positive. A submitter whose enclosing form is bound in the same scan is fine; one whose enclosing form is UNBOUND in the same scan is flagged (the renderer refuses that shape too, but only if the branch renders). A submitter with no enclosing form in its own scan is attributed to a component only when its file registers exactly one tag, declares exactly one WebComponent class, and holds the submitter inside that class body; every other shape (a bare `html` helper, a multi-component file) is UNKNOWABLE and never flagged, because a fragment is rendered inside the CALLER\'s scan and inherits the caller\'s form scope. An attributed tag is then resolved across every call site, recursively and memoized: any call site that is bound, unknowable, or part of a cycle makes the verdict unknowable, and a tag with no call site at all is unknowable too. The rule fires only when at least one call site exists and EVERY one places the tag in an unbound form. Fix by binding the enclosing form (`<form action=${formAction}>`), which is what supplies `method="post"` and the enctype at form start, too late to add from the button.',},
Step 3. Implement the rule in packages/server/src/check.js
Add the dispatch line inside checkConventions immediately after the closing
brace of the form-action-not-a-get-action block (check.js:1454-1455, the two
lines before return violations; at 1457):
// --- Rule: submitter-needs-bound-form (#1307) ---// A submitter bound in a component whose host form is unbound posts nowhere:// the form defaults to GET, the identity rides the query string, and the page// re-renders with the action never having run. Neither renderer can see it// (each judges one template scan), so the whole-app scan is the only place// the answer exists.checkSubmitterNeedsBoundForm(files,violations);
It is synchronous and takes only files, so unlike checkServerImportInBrowserModule (check.js:1539) it needs no appDir, no
module graph, and no second filesystem walk.
Implement it as a factored function beside that one, after checkServerImportInBrowserModule ends, following the same
factored-because-it-is-whole-app pattern:
/** * Implements `submitter-needs-bound-form` (#1307). Factored out because it is * whole-app: a submitter's enclosing form can live in a different module, so the * verdict is a fixed point over every template in the app rather than a * per-file scan. * * @param {{ abs: string, rel: string, content: string, scan: string }[]} files * @param {Violation[]} violations appended to in place */functioncheckSubmitterNeedsBoundForm(files,violations){…}
Body, in order:
Build the per-file scan. For each file, scanHtmlFormScopes(f.content)
for tagUses and for whole-file submitters. Skip a file whose content has
no formaction and no < followed by a hyphenated tag, as a cheap bail.
Build the tag-to-owner map. For each file, extractComponents(f.content)
(imported from ./component-scanner.js, already imported at check.js:12
for scanComponents; add extractComponents to that import). Mirror scanComponents's own filter (component-scanner.js:93-96) and skip .test. / .spec. / .server. files, so a fixture in an app tree cannot
claim a tag. A file with exactly one registered tag AND exactly one extractWebComponentClassBodies(f.content) entry is an ATTRIBUTABLE owner;
anything else is recorded as unknowable.
Attribute the scope-'none' submitters. For an attributable file, run scanHtmlFormScopes a second time over that single class body string
(extractWebComponentClassBodies returns raw body substrings, so the scanner
consumes it directly and no offset mapping is needed). A submitter at 'none' in the class-body scan is the component's cannot-tell submitter.
A submitter at 'none' in the whole-file scan that is NOT in the class-body
scan came from a helper outside the class and is dropped as unknowable.
Flag the definite same-scan case. Every submitter at 'unbound' in the
whole-file scan is a violation immediately, with no cross-module step.
Resolve each attributed tag. A memoized resolveTagScope(tag) over the
union of tagUses for that tag across all files, with an in-progress set so a
cycle returns 'unknowable'. Combine as described in Design: one 'bound'
or one 'unknowable' makes the whole verdict 'unknowable'; zero call sites
is 'unknowable'; all-'unbound' is 'unbound'.
Emit. For each attributed component whose verdict is 'unbound', push
one violation per cannot-tell submitter in that component:
violations.push({rule: 'submitter-needs-bound-form',file: rel,message: `Binds an action with \`formaction=\${…}\` on a <${tag}>, but every place <${componentTag}> is rendered puts it in a <form> with no \`action=\${action}\` binding. An unbound form submits as a GET, so the identity rides the query string, the action never runs, and the page simply re-renders.`,fix: `Bind the enclosing <form> too (\`<form action=\${formAction}>\`), which is what supplies method="post" and the enctype at form start. A per-button action cannot retrofit them.`,});
and for the same-scan case at step 4, the same rule name with a message naming
the unbound form in that file rather than the call sites.
Step 4. Add the dev submit guard to packages/core/src/router-client.js
First widen warnOnce (router-client.js:771-776), which today reads:
/** * @param {string} key * @param {string} message * @param {'warn' | 'error'} [level] 'error' for a broken write path, which is a * different severity from a correct-but-suboptimal degradation. The dev * overlay does not hook console.error, so raising the level pops nothing. */functionwarnOnce(key,message,level='warn'){if(warnedKeys.has(key))return;warnedKeys.add(key);constsink=typeofconsole!=='undefined'&&console[level];if(sink)sink(message);}
The once-per-key set stays shared, so no second piece of state appears.
Then add the guard function beside the other dev-only hints (after warnDropped, router-client.js:977-984):
/** * Dev-only, fire-once hint: this submission carries a bound action's identity * but cannot deliver it, so the action will never run and nothing will say so. * * Reachable only through the cannot-tell fallback (form-action.js:354-374): a * submitter bound inside a component whose host form is unbound. The client * cannot answer that at reconcile time (render-client.js:910 is skipped while * the fragment is detached), but by submit time both the form and the body are * in hand, so the answer is always available here. * * Logs, never throws. This runs in a delegated document-level listener, so a * throw would escape uncaught AND abort before preventDefault and * performSubmission, making dev behave differently from production. Turbo makes * the same call for a submission it cannot use (core/drive/navigator.js:110). * * @param {HTMLFormElement} form * @param {HTMLElement | null} submitter * @param {string} method the resolved lowercase submission method * @param {FormData} body the body the submission will actually carry */functionwarnIfActionSubmissionCannotDeliver(form,submitter,method,body){if(typeofprocess!=='undefined'&&process.env&&process.env.NODE_ENV==='production')return;if(!body.has(FORM_ACTION_FIELD))return;// binds nothing: an ordinary formconstenctype=(submitter&&submitter.getAttribute('formenctype'))||form.getAttribute('enctype')||'application/x-www-form-urlencoded';constpath=(()=>{try{returnnewURL(form.action||location.href,location.href).pathname;}catch{returnlocation.pathname;}})();if(method!=='post'){warnOnce(`submit-nowhere:${path}:${method}`,`[webjs] this form carries a bound server action but submits as ${method.toUpperCase()}, which sends no body, so the identity rides the query string and the action never runs. Bind the enclosing <form> (<form action=\${formAction}>), which is what supplies method="post" at form start.`,'error',);return;}if(!PARSEABLE_ENCTYPES.has(enctype.toLowerCase())){warnOnce(`submit-nowhere:${path}:${enctype}`,`[webjs] this form carries a bound server action but submits enctype="${enctype}", which the server parses as neither multipart/form-data nor application/x-www-form-urlencoded, so the submission is a 405. Drop the enctype and let the binding supply it.`,'error',);}}
FORM_ACTION_FIELD and PARSEABLE_ENCTYPES both already exist in packages/core/src/form-action.js (lines 89 and 616) and are exported; add them
to the existing import in router-client.js. The wording reuses assertSubmitterSubmission's shapes (form-action.js:472-513), which name the
mechanism and then the fix, so the three messages stay consistent.
Call it in onSubmit (router-client.js:564) on exactly one line, between the
body build at line 593 and the e.preventDefault() at line 595:
Placed after the body is built (that is where body.has(FORM_ACTION_FIELD)
becomes answerable) and before preventDefault, so it observes the submission
the browser was about to make and changes nothing about it.
Do NOT touch assertBoundFormSubmitters (form-action.js:1024). It is the
reconcile-time sweep for a submitter's own formmethod / formenctype inside
an already-bound form, its own doc comment records why its limits are left alone
(form-action.js:1000-1021), and this guard answers a different question at a
different moment.
Step 5. Add both telemetry signals to packages/server/src/form-dispatch.js
Add the shared dedupe and the two reporters after looksLikeFormSubmission
(form-dispatch.js:213):
/** * 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<string>} */constreportedFormFingerprints=newSet();constFINGERPRINT_CAP=256;/** @param {string} key @returns {boolean} true the first time only */functionfirstSighting(key){if(reportedFormFingerprints.has(key))returnfalse;if(reportedFormFingerprints.size>=FINGERPRINT_CAP)returnfalse;reportedFormFingerprints.add(key);returntrue;}
Then the two reporters, the second of which dev.js calls:
/** * 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, `bindFormActionStartTag` * at form-action.js:677), so the request url already is that information. * * @param {URL} url * @param {Request} req * @param {FormData} formData * @param {((error: unknown) => void) | undefined} onError */exportfunctionreportFormActionMissing(url,req,formData,onError){…}/** * A page GET carrying `__webjs_action` in the QUERY STRING (#1307). Nothing in * WebJs 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, router-client.js:1449-1456) 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<string, unknown>) => void }} [logger] * @param {boolean} [dev] */exportfunctionreportFormSubmittedAsGet(url,req,onError,logger,dev){…}
Each builds an Error, sets err.code ('WEBJS_FORM_ACTION_MISSING' and 'WEBJS_FORM_SUBMITTED_AS_GET', matching the e.code precedent at packages/server/src/ts-strip.js:109), gates on firstSighting, calls onError when it is a function, and in dev also emits logger.warn. That dev logger.warn is what makes the no-JS write path loud for a developer with no
APM sink wired, which is the one path the client guard in step 4 cannot see.
Wire the first at the existing 405. form-dispatch.js:289-293 reads today:
constactions=formData.getAll(FORM_ACTION_FIELD);constid=actions.length ? actions[actions.length-1] : null;// A form body carrying no identity: a hand-written `<form method="post">`// that binds no action. Nothing to run, and the page only renders.if(typeofid!=='string'||!id)returnmethodNotAllowed();
and after this change reads:
constactions=formData.getAll(FORM_ACTION_FIELD);constid=actions.length ? actions[actions.length-1] : null;// A form body carrying no identity: a hand-written `<form method="post">`// 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(typeofid!=='string'||!id){reportFormActionMissing(url,req,formData,onError);returnmethodNotAllowed();}
url is already a parameter of runFormAction (form-dispatch.js:264), so
nothing new has to be threaded in.
Leave the other two 405s alone. form-dispatch.js:269
(if (!looksLikeFormSubmission(req)) return methodNotAllowed();) answers a
stray JSON POST or a probe before the body is read, which is not an app bug and
is the cheapest thing on the file to flood. form-dispatch.js:338 is the
GET-declared-action refusal, which already has form-action-not-a-get-action
and its own docs entry.
Step 6. Call the GET detector from packages/server/src/dev.js
dev.js:2254 opens the page-render branch:
if(method==='GET'||method==='HEAD'){
Insert immediately inside it, before the handler is built:
if(method==='GET'||method==='HEAD'){// #1307: `__webjs_action` in the QUERY STRING is the fingerprint of a// bound submitter submitted through an UNBOUND form. Detect only, so// the render below is unchanged.reportFormSubmittedAsGet(url,req,reportError ? (e)=>reportError(e,req,'action') : undefined,logger,dev,);
reportError is in the same closure (defined at dev.js:561, already used for
the SSR sink at dev.js:2245 and the action sink at dev.js:2283) and so is logger (dev.js:534). The phase label stays 'action' so both #1307 signals
group with the rest of the form-dispatch reporting. Add reportFormSubmittedAsGet to the existing ./form-dispatch.js import at dev.js:18.
Step 7. Add the dogfood route the e2e needs
examples/blog has /feedback/triage today
(examples/blog/app/feedback/triage/page.ts:31 and :39), where the form and
the submitter sit in the SAME template, so the renderer resolves boundness in
one scan and the cannot-tell path is never taken. No in-repo app exercises the
fallback end to end. Add the cross-module shape beside it, which is both the
dogfood coverage and the e2e fixture:
examples/blog/modules/feedback/components/publish-button.ts, a component
rendering html\Publish`and callingPublishButton.register('publish-button')`.
examples/blog/app/feedback/triage-split/page.ts, the same page as /feedback/triage but with <publish-button></publish-button> in place of
the inline button, so the form is bound in the page and the submitter is bound
in a component.
That is the cannot-tell shape rendering CORRECTLY, and it is the counterfactual
for the whole change: if the fallback is ever made to refuse, this page's
component renders empty (SSR component errors are isolated) and the e2e goes
red. The precedent for adding a blog route for exactly this purpose is /feedback/live, added for the hydrated half of the #1155 coverage
(test/e2e/e2e.test.mjs:3244-3246).
Tests
Every layer this change touches, by exact path. npm test does NOT run browser,
e2e, or Bun, so run those explicitly and report the result.
Check rule unit (new file)
packages/server/test/check/submitter-needs-bound-form.test.js, mirroring packages/server/test/check/form-action-not-a-get-action.test.js (its makeApp
tmpdir helper at lines 20-27 and its hits(v) filter at line 29 are the pattern
to copy verbatim). Cases:
RULES lists submitter-needs-bound-form (the registration test at form-action-not-a-get-action.test.js:38-40).
The counterfactual that fails when the change is reverted. A component
file rendering <button formaction=${act}> plus a page rendering <form><row-btn></row-btn></form> produces exactly one violation naming the
component file. Reverting the rule leaves zero.
Silent when the page's form IS bound (<form action=${save}>).
Silent when the tag has TWO call sites, one bound and one unbound. This is
the indefinite tier and the single most important non-firing case.
Silent when the tag has ZERO call sites in the app.
Silent when the submitter lives in a bare html helper rather than inside a
WebComponent class body, even in a file that registers a tag.
Silent when the file registers two tags (ambiguous attribution).
Fires on the same-scan case, <form><button formaction=${act}></form> in one
template, with no cross-module step involved.
Transitive: a page binds the form around <todo-list>, todo-list renders <todo-row>, and todo-row holds the submitter. Silent, because the outer
form is bound. Flip the page's form to unbound and it fires.
Cycle: <a-one> renders <b-two> and <b-two> renders <a-one>. Silent,
and no hang.
The docs-sample carve-out. A page whose template contains the TEXT <form> plus a const s = '<form>' string stays clean. That is the
hazard check.js:1389-1396 records, and the framework's own website is the
live instance of it.
Renderer unit (existing files, regression plus streamed twins)
packages/core/test/rendering/form-action-binding.test.js already pins the
cannot-tell contract at lines 643-720: a component-rendered submitter inside a
bound form binds, a conclusively form-less submitter is still refused, an 'unbound' form is refused, and a component's OWN unbound form refuses. Those
must stay green with no assertion changed.
Add streamed twins there, which is the "both SSR paths agree" coverage. One correction to the premise for whoever writes them. The previous body
listed render-server.js:1892, :1985, :2083-2087 and :2249-2253 as a
duplicated scan a change could miss. Those lines are real and do duplicate the
logic, but they belong to streamTemplate, which renderToStream(v, { ssr: true }) never reaches: the default path renders
buffered and then runs injectDSD (render-server.js:1861-1871), so the
streamed state machine is reached only through renderToStream(v, { ssr: false }), which no page render uses (the source says
so at render-server.js:2264-2268). The component pass that seeds 'unknown'
(render-server.js:1070) lives inside injectDSD and is therefore SHARED by
both entry points, so a cannot-tell submitter cannot even arise inside streamTemplate. What the streamed twins can and should pin is the shapes both
machines do express, namely the 'none' and 'unbound' refusals under drain(renderToStream(tpl, { ssr: false })), matching how every other refusal in
that suite is doubled (lines 194-195 and 236 are the model). A test that fails
if only one machine were changed is a refusal asserted through both entry
points, and that is what these are.
form-action-attr-guard.test.js and the two -client twins
(form-action-binding-client.test.js, form-action-attr-guard-client.test.js)
need no new assertions, since the refusal matrix and the leak guard are
untouched. Run them as regression.
Server unit
packages/server/test/routing/form-dispatch.test.js already has the 405 cases
(lines 255-293) and an onError collector pattern (lines 451-453). Add there:
the bind-nothing 405 also produces exactly one error with code === 'WEBJS_FORM_ACTION_MISSING' carrying the field NAMES and none of the
values; a page GET with ?__webjs_action=x still returns 200 and produces one WEBJS_FORM_SUBMITTED_AS_GET; a second identical request produces NO second
report (the dedupe); and the non-form POST 405 at form-dispatch.js:269
produces no report at all.
Browser
packages/core/test/routing/browser/form-action-submit.test.js is the right
home for the dev guard, since it already calls enableClientRouter(), stubs fetch, and installs the nav guard from test/browser-nav-guard.js. Add a
suite that renders a bound-submitter-in-unbound-form shape, stubs console.error, dispatches a real submit, and asserts one [webjs] error
naming the fix, that it fires ONCE across two submits, and that the submission
still proceeds (the stubbed fetch or the promoted GET still happens), which is
the proof the guard changed nothing.
packages/core/test/rendering/browser/form-action-guard.test.js covers the
render-time refusal matrix, which does not change. Regression only.
e2e, the no-JS write path
The previous body named the wrong file. test/e2e/form-submission-and-race.test.mjs runs against the website dev server
at :5001 and synthesizes form endpoints with Playwright page.route() mocks
over DOM injected via evaluate() (its own header says so at lines 19-26), so
it cannot exercise server form handling at all. The real no-JS form e2e is the E2E: form actions (no-JS + enhanced) block at test/e2e/e2e.test.mjs:3108,
which boots a real examples/blog server and drives a real browser with setJavaScriptEnabled(false). Its submitter half is at test/e2e/e2e.test.mjs:3341-3390.
Add a case there for the new /feedback/triage-split route from step 7: with JS
OFF, the served markup carries the component-rendered button's name="__webjs_action" and its <hash>/publishDraft value, and a native submit
runs publishDraft and PRG-redirects. That is the counterfactual proving an
isolated component is not dropped, because if the fallback ever refused, the
component would render empty and the button would not be in the DOM at all.
Run with WEBJS_E2E=1.
Bun parity (mandatory)
Form dispatch is a runtime-sensitive surface, so this is part of the task rather
than an afterthought. test/bun/form-action-dispatch.mjs already asserts the
bind-nothing 405 (lines 151-154) and already builds its app through createRequestHandler (line 64), which accepts onError. Extend it to
construct the handler with an onError collector and assert that the 405 also
produced exactly one error whose .code is WEBJS_FORM_ACTION_MISSING, and
that a page GET carrying ?__webjs_action=x returns 200 AND produced one WEBJS_FORM_SUBMITTED_AS_GET. Both assertions are cross-runtime by
construction, since the file runs under node test/bun/form-action-dispatch.mjs
and bun test/bun/form-action-dispatch.mjs through scripts/run-bun-tests.js,
and is re-entered by the Node runner via test/bun/form-action-dispatch.test.mjs.
test/bun/form-action-submitter-parity.test.mjs covers the render-time
submitter refusals, which do not change. Extend it with the cannot-tell shape (a
component-rendered submitter inside a bound form renders identically on both
runtimes) so the fallback itself is pinned cross-runtime.
The Bun-sensitive parts of the new code are url.searchParams.has(), formData.keys(), and Error property assignment, all standard, which is why
the parity assertion is an outcome check rather than a shape check.
Layers that do NOT apply
webjs doctor. This is a code-correctness rule, not a project-health
check, so it belongs in check and gets no webjs.doctor.gate code.
Smoke (test/examples/*/smoke/*). The new blog route is covered by the
e2e that motivates it, and a smoke test would only re-assert that the route
boots.
Config schema and type-drift tests. No webjs.* key is added, so nothing
touches WebjsConfig, the JSON Schema, or the reader lockstep.
Docs
A task is not done until every doc surface the change touches is in sync.
Invoke the webjs-doc-sync skill. Six surfaces, each with what changes.
packages/server/src/check.jsRULES description (step 2). This IS the
rule's documentation, because webjs check --rules prints it. It is the only
place the full conservatism contract is written down.
AGENTS.md invariant 12, line 486. Its last sentences today read:
Each renderer therefore distinguishes three answers: bound (fine), unbound
(refused), and cannot-tell (bind anyway). Cannot-tell has to bind: [...] The
refusal still fires wherever the answer is genuinely known.
Append, without touching anything earlier in that paragraph: webjs check's submitter-needs-bound-form resolves the cannot-tell case statically across
modules and flags it at edit time, where a whole-app scan proves every call
site puts the tag in an unbound form; in dev the client logs a console error
at submit time when the submission cannot carry the identity; and in
production both server-visible fingerprints reach the onError hook with a
code (WEBJS_FORM_SUBMITTED_AS_GET for the query-string GET, WEBJS_FORM_ACTION_MISSING for a body carrying no identity). Correct the
sentence "A page has no action export, so a bare <form method="post"> is
a 405" only if the doc-sync pass finds it ambiguous; it is accurate.
.agents/skills/webjs/references/data-and-actions.md. The bullet list at
lines 143-148 and the response paragraph at line 150. Add one bullet beside
the existing method = 'GET' bullet (line 148, which is the model: symptom,
then the rule name) covering the unbound-host-form case, its GET/query-string
symptom, and submitter-needs-bound-form. Amend line 150's "a submission
carrying no identity is a 405" to name the onError code. This file is
also the scaffold's copy: packages/cli/lib/create.js:665-673 copies the
repo-root .agents/skills/webjs/ into a generated app, so there is no second
scaffold file to edit and none should be created.
website/app/docs/server-actions/page.ts, lines 475-476. The
per-submitter paragraph and the submitter-refusals paragraph. Add that the
enclosing form must be bound, that the renderers cannot always tell across a
component boundary, and that webjs check closes the gap. Line 474 already
names form-action-not-a-get-action in the same idiom to copy.
website/app/docs/progressive-enhancement/page.ts, lines 172 and 176.
Line 172 says a form that binds nothing gets a 405; that is true for a
hand-written <form method="post"> and needs the second, quieter outcome
added, namely that an unbound form with no method submits as a GET and
silently re-renders. Line 176 is the submitter-defeats-the-form paragraph and
is where the check rule belongs on this page.
website/app/docs/troubleshooting/page.ts, lines 54 and 60. The page is
keyed by symptom and line 54 already covers the 405 causes. Add the silent
symptom, which has no entry today: a button submits and the page just
re-renders with ?__webjs_action= in the address bar. Name the check rule,
the dev console error, and the onError code as the three ways to see it.
website/app/docs/conventions/page.ts explains the check-versus-convention
dividing line but does not enumerate rules by name (it points at webjs check --rules, line 43), so it needs no edit.
Acceptance criteria
webjs check reports submitter-needs-bound-form for a formaction=${fn}
submitter that a whole-app scan proves sits in an unbound form, across
module boundaries and transitively through intermediate components
The rule is silent on every indefinite case: a tag with mixed call sites,
a tag with no call site, a submitter in a bare html helper, a
multi-component file, and a reference cycle
The rule is silent on a docs page that shows <form action=${fn}> as a
code sample, and on examples/blog, website, docs, packages/ui/packages/website, and a freshly scaffolded gallery app
webjs check --rules lists submitter-needs-bound-form with a description
that states the conservatism contract
In dev, submitting a form that carries a bound action's identity but cannot
deliver it logs one [webjs] console error naming the fix, fires once per
shape, and does not change what the submission does
Nothing is logged in production (NODE_ENV=production)
A page GET carrying __webjs_action in the query string still renders 200
and reaches the onError hook with code === 'WEBJS_FORM_SUBMITTED_AS_GET'
A form POST carrying no identity is still a 405 and reaches onError with code === 'WEBJS_FORM_ACTION_MISSING', carrying the field names and never
the field values
Both signals report once per method + pathname per process, so a flood of
crafted requests produces one report
The cannot-tell fallback still BINDS: /feedback/triage-split serves the
component-rendered button with its identity and submits successfully with
JavaScript off
packages/core/src/render-server.js, form-action.js, and render-client.js have no behaviour change, and the existing cannot-tell
tests at form-action-binding.test.js:643-720 pass untouched
Both SSR entry points refuse the knowable shapes identically, asserted
through renderToString and through renderToStream(v, { ssr: false })
Tests green at every layer: packages/server/test/check/, packages/server/test/routing/form-dispatch.test.js, packages/core/test/rendering/, npm run test:browser, WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs, and node scripts/run-bun-tests.js plus bun test/bun/form-action-dispatch.mjs
Docs updated on all six surfaces listed above
Out of scope
Changing the cannot-tell fallback to refuse. Settled and recorded at packages/core/src/form-action.js:354-374. Do not revisit it.
Editing either SSR state machine. No change to renderTemplate
(render-server.js:165) or streamTemplate (render-server.js:1987). If the
work seems to need one, the design is wrong.
Changing any HTTP status. The GET stays 200, the bind-nothing submission
stays 405. This issue makes failures observable, it does not re-litigate what
they answer.
The other two 405s in form-dispatch.js. Line 269 (a non-form POST,
answered before the body is read) and line 338 (a GET-declared action, which
has form-action-not-a-get-action and its own docs) stay exactly as they are.
A severity or per-project disabling axis for webjs check. The tool is
unconditional by construction (check.js:20-26).
A general cross-module template analyser. The scanner added here answers
one question, form scope at a submitter and at a component tag. Do not grow it
into a shared framework for other rules in this PR.
packages/ TypeScript.packages/ is plain .js with JSDoc and ships
buildless. No .ts file is added anywhere under it.
Follow-up issues. Anything this work turns up goes in the PR description
for the owner to decide, not into a new issue.
Cross-issue landmines
#1308 and #1309 are being planned in parallel. Code surfaces are disjoint, but
two doc files are shared. AGENTS.md is touched by all three in DIFFERENT
sections (this issue edits only invariant 12, line 486), and .agents/skills/webjs/references/data-and-actions.md is touched by this issue
and #1309 (this issue edits only the bound-form section, lines 143-150). Keep
every diff inside those ranges so the three PRs merge without conflict, and
rebase on origin/main before opening the PR rather than resolving a conflict
after the fact.
Problem
Form-submitter boundness is a FOUR-state inference in the renderer
(
'none'/'unbound'/'bound'/'unknown', typedef atpackages/core/src/render-server.js:66), and the'unknown'branch bindsanyway. That fallback is correct and must stay: refusing on cannot-tell would
reject a per-row button in a list and a button inside a component, both ordinary
shapes, and at SSR a refused component is ISOLATED, so the button would silently
vanish from a page that still returned 200. The rationale is recorded at
packages/core/src/form-action.js:354-374, which also records that an earlierversion of that comment wrongly claimed SSR always knows.
The residual is that a
formaction=${fn}button whose enclosing<form>isgenuinely NOT bound reaches production whenever the two live in different
modules. That is the worst failure of the three inference-based subsystems
(elision, action seeding, form boundness): it breaks a WRITE path, it hits the
no-JS path hardest, and today it is invisible everywhere.
Correction to the previous problem statement, measured rather than reasoned.
`The old body claimed the production symptom is "the form defaults to GET, sends
no body, and the submission is answered with a bare 405". The 405 half is wrong.
Running the shape through the real renderer
(
renderToStringonhtml\whererow-btnrendershtml`Go``) emits:So the three real outcomes are:
<form>with nomethod(the dominant case) is a silent200. The browser submits it as a GET to the page's own url with
__webjs_actionin the QUERY STRING.packages/server/src/dev.js:2254routes a GET at a page path straight to
ssrPage, so the page simplyre-renders. No action runs, no 405, no log, no telemetry. With JS the outcome
is identical, because
performSubmissionpromotes a safe-method body to thequery string (
packages/core/src/router-client.js:1449-1456).<form method="post">actually WORKS. The submitter's ownname/valuepair carries the identity into the body, and the dispatchertakes the last
__webjs_actionit finds(
packages/server/src/form-dispatch.js:289-291), so the action runs. Nothingto report here.
parseFormBodyonly parses multipart and urlencoded, so
formDatacomes back empty, noidentity is found, and
packages/server/src/form-dispatch.js:293answersmethodNotAllowed().The previous body put that 405 at
form-dispatch.js:241. Line 241 is insidelooksLikeFormSubmission's doc comment. The bind-nothing 405 is line 293;methodNotAllowed()itself is defined at line 239, and the second 405, the onecarrying
allow: GET, is at line 338 and belongs to the GET-declared-actionrefusal, which is a different rule with its own check.
This changes what "make the residual loud" has to mean. The dominant residual
never reaches the 405 at all, so telemetry hung only on that 405 would report
almost nothing. It does, however, leave a perfect fingerprint: a page GET
carrying
__webjs_actionin the query string can ONLY be produced by a boundsubmitter submitted through an unbound form. Nothing else in the framework ever
puts that reserved field in a url.
The ambiguity is resolvable, just not by either renderer. SSR reads a linear
byte stream one template at a time, and a component renders its own template in
a separate pass seeded
'unknown'with no view of the host page(
packages/core/src/render-server.js:1063-1070). The client has its own versionof the limit (
packages/core/src/render-client.js:910, asked once per element,skipped while the fragment is detached).
webjs checkhas NEITHER limitation:it reads every template in the app at once and already knows which holes bind
actions.
Design / approach
Three layers, in priority order. No file under
packages/core/src/thatimplements the render-time refusal matrix changes. The cannot-tell fallback
keeps binding,
assertSubmitterFormIsBoundkeeps its signature and message, andneither SSR state machine is edited.
Layer 1: a new
webjs checkrule,submitter-needs-bound-formName changed from the issue's original
submitter-form-is-bound. None ofthe 20 existing rules uses an
iscopula. The registry splits intono-<bad thing>names and subject-predicate names, and the closest existing shape isuse-server-needs-extension(subject,needs, object), which also names thefix.
submitter-needs-bound-formmatches it exactly. "Submitter" is the domainword invariant 12 and
form-action.jsalready use, so it stays.Error, not warning, and there is no third option.
Violationinpackages/server/src/check.js:36-41has no severity field, socheckhas noseverity axis to place a rule on. The only question is whether the rule belongs
in the tool at all, and AGENTS.md settles it with one test: could a sensible app
legitimately want this to pass? A form that posts nowhere is a broken write path
under any project's conventions, so it is a correctness rule.
The resolution algorithm. For every app file, walk the
htmltemplates andrecord two event kinds with the enclosing form scope at each point: submitter
action holes (
<button|input formaction=${...}>) and custom-element start tags(
<x-y>). Scope is'bound'when the enclosing<form>in the same scancarried an
action=${...}hole,'unbound'when it did not,'none'whenthere is no enclosing form in this scan. Then:
'bound'is fine. Silent.'unbound'is flagged. The renderer refuses this same shape atrender time, but only if that branch actually renders, so catching it
statically is strictly earlier and can never false-positive.
'none'is the cross-module question. It is attributed to acomponent only when the file registers exactly ONE tag and declares exactly
ONE
WebComponentclass, and only when the submitter sits inside that classbody. Anything else is UNKNOWABLE and stays silent, because a bare
htmlfragment returned by a helper is rendered inside the CALLER's scan withthe caller's scope (
render()passesformScopethrough arrays,repeat,and nested templates, see
render-server.js:137,:142,:155), so a helperthat looks form-less at rest can be perfectly bound at runtime.
the app. A call site at
'bound'or at'none'inside an unknowable modulemakes the verdict UNKNOWABLE. A call site at
'none'inside anothersingle-component module recurses into that component's verdict, memoized, with
a cycle guard that returns UNKNOWABLE. Zero call sites is UNKNOWABLE too,
because a tag no app template renders may still be rendered by markup the scan
cannot see. Only when there is at least one call site and EVERY one resolves
to
'unbound'does the rule fire.The recursion is deliberate rather than a one-level lookup: a page renders
<form action=${fn}>around<todo-list>, which renders<todo-row>, whichrenders the button, is an ordinary shape, and a one-level rule would go silent
on it, which is the case the rule exists for.
Conservatism prior art.
no-missing-local-import(registry atcheck.js:154, implementation atcheck.js:1320-1370) is the model: it treats amodule as UNKNOWABLE and never flags through it when its exports are not fully
enumerable.
no-server-import-in-browser-module(check.js:1539) is the modelfor the other half, reusing the build's own verdict rather than re-deriving a
parallel notion of what ships. This rule takes the tag-to-module map from
extractComponents(packages/server/src/component-scanner.js:51) applied tothe
filesarraycheckConventionshas already read, so it needs no secondfilesystem walk and no
scanComponents(appDir)call.The docs-sample hazard is inherited and already solved. The framework's own
website renders
<form action=${fn}>as a code SAMPLE, which is why theexisting rule uses
redactToPlaceholdersrather than either blanking mask (seethe comment at
check.js:1389-1396). The new scanner adds one more guard on top:it only enters a template frame for an
html-tagged literal, so a plain stringsuch as
const s = '<form>'is never read as markup.Layer 2: a dev-only client guard at submit time
The client cannot tell at reconcile time, but it always can by submission time,
because the form and its submitter are both in the tree and the FormData is
already built.
onSubmitinpackages/core/src/router-client.js:564is the onedelegated submit path, and
buildSubmitFormDataat line 593 produces the exactbody the submission will carry.
It logs, it never throws.
onSubmitis a document-level delegated listener,so a throw there escapes as an uncaught error AND aborts before
e.preventDefault()andperformSubmission, which would make the page behavedifferently in dev than in production. The prior art agrees on both counts:
Turbo builds an
Errorfor a submission it cannot use(
~/Documents/Projects/frameworks/turbo/src/core/drive/form_submission.js:149)and its navigator delegate
console.errors it rather than rethrowing(
~/Documents/Projects/frameworks/turbo/src/core/drive/navigator.js:110).It uses
console.error, notconsole.warn. The existingwarnOncehelper(
router-client.js:763-776) emitsconsole.warnfor degradations that arecorrect but suboptimal. A form that posts nowhere is a broken write path, which
is the severity
console.errorcarries, and the WebJs dev overlay does not hookconsole.error(packages/server/src/dev-overlay.jsregisters only click,webjs:navigate,webjs:before-cacheandpopstatelisteners), so raising thelevel pops nothing.
warnOncegains an optional level argument rather thangrowing a second once-per-key set beside it.
Production is silenced by the same
NODE_ENV === 'production'early return everyother client diagnostic uses (
router-client.js:956,:980,:1002).Layer 3: production telemetry on both server-visible fingerprints
onErroralready reachesrunFormActionthroughdeps.onError(
packages/server/src/dev.js:2280-2285, wired toreportErrorwith phase'action'), andreportErrorfans out to the programmaticcreateRequestHandler({ onError })option AND to any sink aninstrumentation.{js,ts}installed viasetOnError(
packages/server/src/dev.js:552). So no new plumbing is needed, only two callsites and an error shape.
WEBJS_FORM_SUBMITTED_AS_GETon a page GET whose url carries__webjs_action. This is the dominant residual and today it is completelyinvisible.
WEBJS_FORM_ACTION_MISSINGon the bind-nothing 405 atform-dispatch.js:293.The code rides on
err.code, matching the one existing precedent for a codedframework error (
packages/server/src/ts-strip.js:109setse.code = 'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX').The response never changes. The GET keeps rendering its 200 page and the
405 stays a 405. Answering a GET differently because of a query parameter would
hand any visitor a way to turn any page into an error, and this layer is
diagnostics, not enforcement.
What the signal carries, and what it must not. The method and the pathname,
plus for the missing-identity case the submitted field NAMES. Field names are
template constants that identify WHICH form posted nowhere, which is the whole
diagnostic value; field VALUES are user data and are never included. The form's
own
actionattribute is deliberately not carried, because a bound form hasnone: the renderer strips it so the form posts to the page's own url
(
bindFormActionStartTag,packages/core/src/form-action.js:677), so therequest url already is that information.
Both signals are deduplicated per process, keyed on
method + pathname, witha 256-entry cap. Either fingerprint is reachable by an unauthenticated
attacker, who can POST an empty urlencoded body to any page path or append
?__webjs_action=xto any url. Without a cap that is a free amplifier into apaid APM sink. Deduplication also matches the intent: an app needs to learn the
shape exists once, not count it. A real bug reproduces on the next boot.
In dev the same two detections also emit a
logger.warn, which is what makesthe no-JS write path loud for a developer who has no APM sink wired. That is the
path progressive enhancement exists to protect and the path layer 2 cannot see.
Alternatives considered and rejected
recorded at
packages/core/src/form-action.js:354-374. It rejects ordinaryshapes, and at SSR the refusal is isolated per component, so production
returns 200 with the button silently gone. Strictly worse than the bug.
pass walks already-emitted HTML in
injectDSD(
render-server.js:932, seeding at:1070), so the enclosing form is in theemitted byte stream and could in principle be read back. Rejected: it makes
the DSD pass depend on parsing its own output for HTML structure, it cannot
help the client at all, and it still leaves the pure-client render path
uncovered. The static analyser sees more than either renderer can and costs
the renderers nothing.
webjs checkrules are unconditional by construction (check.js:20-26),and adding
a severity axis for one rule would change the tool's contract.
error at the dispatch boundary when the action id cannot be resolved
(
~/Documents/Projects/frameworks/next.js/packages/next/src/server/app-render/action-handler.ts:871)and warns through
warnBadServerActionRequest(
action-handler.ts:672), with no per-path dedupe. Rejected here becauseWebJs routes the signal into a user-supplied APM sink rather than only into
its own logger, so unbounded attacker-triggerable calls have a cost Next's
console warning does not.
knowable. Rejected because the measurement above shows the dominant case is
a silent 200 with no diagnostic anywhere. Rails is the contrast worth naming:
a form posting to a controller action that does not exist raises
AbstractController::ActionNotFound(
~/Documents/Projects/frameworks/rails/actionpack/lib/abstract_controller/base.rb:150)rather than quietly rendering something. A write path that goes nowhere should
never be silent.
Implementation plan
Step 1. Add the shared template scanner to
packages/server/src/js-scan.jsNew export at the end of the file, after
redactToPlaceholders(
js-scan.js:687-690). It consumes that function's output, which was verifiedagainst the real implementation to produce exactly the shape this needs. For the
source
redactToPlaceholdersreturnsso backticks and holes survive as code, each literal BODY is one token, and the
string in code position is token 0, indistinguishable from a real form only if
the scanner ignores which template it is in. Hence:
The walk mirrors the renderer's own state machine at
packages/core/src/render-server.js:207-250, which is the referenceimplementation to read before writing this. Per literal segment fed into the
walker, track:
<formstart tag, and whether an action hole landed inside it beforeits
>, so>pushes a frame with scope'bound'or'unbound'(the renderer's equivalent is
closeBoundFormTag,render-server.js:216-248);</form>pops back to the enclosing frame, or to'none'at the top;tagUsesentry at thecurrent scope;
<buttonor<inputstart tag emits asubmittersentry at the currentscope when a hole lands on its
formaction=attribute.The hole-to-attribute test is the one already written at
check.js:1408-1420:take the literal immediately before the hole, find the last
<with no>after it, and match the tag and attribute as a PAIR so
<form formaction=${x}>and
<button action=${x}>stay out.Extract that pair test rather than duplicating it. Add a second small export
beside the scanner:
and rewrite the loop at
check.js:1408-1420to call it. Today that loop reads:and after the extraction reads:
form-action-not-a-get-actiontreats both kinds identically, so collapsing themto a truthiness test is behaviour-preserving, and its existing suite at
packages/server/test/check/form-action-not-a-get-action.test.jsis theregression proof. Keep the long comment at
check.js:1389-1406where it is: itexplains why placeholder redaction is forced, which is still true of the block
that survives.
Step 2. Register the rule in
packages/server/src/check.jsInsert a new entry into
RULESimmediately afterform-action-not-a-get-action(the object at
check.js:138-142, closing brace at 142) and beforeno-redirect-in-api-route(check.js:143), so the two form-action rules sittogether. The description IS the rule's documentation, since
webjs check --rulesprints it, so it has to carry the full contract:Step 3. Implement the rule in
packages/server/src/check.jsAdd the dispatch line inside
checkConventionsimmediately after the closingbrace of the
form-action-not-a-get-actionblock (check.js:1454-1455, the twolines before
return violations;at 1457):It is synchronous and takes only
files, so unlikecheckServerImportInBrowserModule(check.js:1539) it needs noappDir, nomodule graph, and no second filesystem walk.
Implement it as a factored function beside that one, after
checkServerImportInBrowserModuleends, following the samefactored-because-it-is-whole-app pattern:
Body, in order:
scanHtmlFormScopes(f.content)for
tagUsesand for whole-file submitters. Skip a file whosecontenthasno
formactionand no<followed by a hyphenated tag, as a cheap bail.extractComponents(f.content)(imported from
./component-scanner.js, already imported atcheck.js:12for
scanComponents; addextractComponentsto that import). MirrorscanComponents's own filter (component-scanner.js:93-96) and skip.test./.spec./.server.files, so a fixture in an app tree cannotclaim a tag. A file with exactly one registered tag AND exactly one
extractWebComponentClassBodies(f.content)entry is an ATTRIBUTABLE owner;anything else is recorded as unknowable.
'none'submitters. For an attributable file, runscanHtmlFormScopesa second time over that single class body string(
extractWebComponentClassBodiesreturns raw body substrings, so the scannerconsumes it directly and no offset mapping is needed). A submitter at
'none'in the class-body scan is the component's cannot-tell submitter.A submitter at
'none'in the whole-file scan that is NOT in the class-bodyscan came from a helper outside the class and is dropped as unknowable.
'unbound'in thewhole-file scan is a violation immediately, with no cross-module step.
resolveTagScope(tag)over theunion of
tagUsesfor that tag across all files, with an in-progress set so acycle returns
'unknowable'. Combine as described in Design: one'bound'or one
'unknowable'makes the whole verdict'unknowable'; zero call sitesis
'unknowable'; all-'unbound'is'unbound'.'unbound', pushone violation per cannot-tell submitter in that component:
and for the same-scan case at step 4, the same rule name with a message naming
the unbound form in that file rather than the call sites.
Step 4. Add the dev submit guard to
packages/core/src/router-client.jsFirst widen
warnOnce(router-client.js:771-776), which today reads:and after this change reads:
The once-per-key set stays shared, so no second piece of state appears.
Then add the guard function beside the other dev-only hints (after
warnDropped,router-client.js:977-984):FORM_ACTION_FIELDandPARSEABLE_ENCTYPESboth already exist inpackages/core/src/form-action.js(lines 89 and 616) and are exported; add themto the existing import in
router-client.js. The wording reusesassertSubmitterSubmission's shapes (form-action.js:472-513), which name themechanism and then the fix, so the three messages stay consistent.
Call it in
onSubmit(router-client.js:564) on exactly one line, between thebody build at line 593 and the
e.preventDefault()at line 595:Placed after the body is built (that is where
body.has(FORM_ACTION_FIELD)becomes answerable) and before
preventDefault, so it observes the submissionthe browser was about to make and changes nothing about it.
Do NOT touch
assertBoundFormSubmitters(form-action.js:1024). It is thereconcile-time sweep for a submitter's own
formmethod/formenctypeinsidean already-bound form, its own doc comment records why its limits are left alone
(
form-action.js:1000-1021), and this guard answers a different question at adifferent moment.
Step 5. Add both telemetry signals to
packages/server/src/form-dispatch.jsAdd the shared dedupe and the two reporters after
looksLikeFormSubmission(
form-dispatch.js:213):Then the two reporters, the second of which
dev.jscalls:Each builds an
Error, setserr.code('WEBJS_FORM_ACTION_MISSING'and'WEBJS_FORM_SUBMITTED_AS_GET', matching thee.codeprecedent atpackages/server/src/ts-strip.js:109), gates onfirstSighting, callsonErrorwhen it is a function, and in dev also emitslogger.warn. That devlogger.warnis what makes the no-JS write path loud for a developer with noAPM sink wired, which is the one path the client guard in step 4 cannot see.
Wire the first at the existing 405.
form-dispatch.js:289-293reads today:and after this change reads:
urlis already a parameter ofrunFormAction(form-dispatch.js:264), sonothing new has to be threaded in.
Leave the other two 405s alone.
form-dispatch.js:269(
if (!looksLikeFormSubmission(req)) return methodNotAllowed();) answers astray JSON POST or a probe before the body is read, which is not an app bug and
is the cheapest thing on the file to flood.
form-dispatch.js:338is theGET-declared-action refusal, which already has
form-action-not-a-get-actionand its own docs entry.
Step 6. Call the GET detector from
packages/server/src/dev.jsdev.js:2254opens the page-render branch:Insert immediately inside it, before the
handleris built:reportErroris in the same closure (defined atdev.js:561, already used forthe SSR sink at
dev.js:2245and the action sink atdev.js:2283) and so islogger(dev.js:534). The phase label stays'action'so both #1307 signalsgroup with the rest of the form-dispatch reporting. Add
reportFormSubmittedAsGetto the existing./form-dispatch.jsimport atdev.js:18.Step 7. Add the dogfood route the e2e needs
examples/bloghas/feedback/triagetoday(
examples/blog/app/feedback/triage/page.ts:31and:39), where the form andthe submitter sit in the SAME template, so the renderer resolves boundness in
one scan and the cannot-tell path is never taken. No in-repo app exercises the
fallback end to end. Add the cross-module shape beside it, which is both the
dogfood coverage and the e2e fixture:
examples/blog/modules/feedback/components/publish-button.ts, a componentrendering
html\Publish`and callingPublishButton.register('publish-button')`.examples/blog/app/feedback/triage-split/page.ts, the same page as/feedback/triagebut with<publish-button></publish-button>in place ofthe inline button, so the form is bound in the page and the submitter is bound
in a component.
That is the cannot-tell shape rendering CORRECTLY, and it is the counterfactual
for the whole change: if the fallback is ever made to refuse, this page's
component renders empty (SSR component errors are isolated) and the e2e goes
red. The precedent for adding a blog route for exactly this purpose is
/feedback/live, added for the hydrated half of the #1155 coverage(
test/e2e/e2e.test.mjs:3244-3246).Tests
Every layer this change touches, by exact path.
npm testdoes NOT run browser,e2e, or Bun, so run those explicitly and report the result.
Check rule unit (new file)
packages/server/test/check/submitter-needs-bound-form.test.js, mirroringpackages/server/test/check/form-action-not-a-get-action.test.js(itsmakeApptmpdir helper at lines 20-27 and its
hits(v)filter at line 29 are the patternto copy verbatim). Cases:
RULESlistssubmitter-needs-bound-form(the registration test atform-action-not-a-get-action.test.js:38-40).file rendering
<button formaction=${act}>plus a page rendering<form><row-btn></row-btn></form>produces exactly one violation naming thecomponent file. Reverting the rule leaves zero.
<form action=${save}>).the indefinite tier and the single most important non-firing case.
htmlhelper rather than inside aWebComponent class body, even in a file that registers a tag.
<form><button formaction=${act}></form>in onetemplate, with no cross-module step involved.
<todo-list>,todo-listrenders<todo-row>, andtodo-rowholds the submitter. Silent, because the outerform is bound. Flip the page's form to unbound and it fires.
<a-one>renders<b-two>and<b-two>renders<a-one>. Silent,and no hang.
<form>plus aconst s = '<form>'string stays clean. That is thehazard
check.js:1389-1396records, and the framework's own website is thelive instance of it.
Renderer unit (existing files, regression plus streamed twins)
packages/core/test/rendering/form-action-binding.test.jsalready pins thecannot-tell contract at lines 643-720: a component-rendered submitter inside a
bound form binds, a conclusively form-less submitter is still refused, an
'unbound'form is refused, and a component's OWN unbound form refuses. Thosemust stay green with no assertion changed.
Add streamed twins there, which is the "both SSR paths agree" coverage.
One correction to the premise for whoever writes them. The previous body
listed
render-server.js:1892,:1985,:2083-2087and:2249-2253as aduplicated scan a change could miss. Those lines are real and do duplicate the
logic, but they belong to
streamTemplate, whichrenderToStream(v, { ssr: true })never reaches: the default path rendersbuffered and then runs
injectDSD(render-server.js:1861-1871), so thestreamed state machine is reached only through
renderToStream(v, { ssr: false }), which no page render uses (the source saysso at
render-server.js:2264-2268). The component pass that seeds'unknown'(
render-server.js:1070) lives insideinjectDSDand is therefore SHARED byboth entry points, so a cannot-tell submitter cannot even arise inside
streamTemplate. What the streamed twins can and should pin is the shapes bothmachines do express, namely the
'none'and'unbound'refusals underdrain(renderToStream(tpl, { ssr: false })), matching how every other refusal inthat suite is doubled (lines 194-195 and 236 are the model). A test that fails
if only one machine were changed is a refusal asserted through both entry
points, and that is what these are.
form-action-attr-guard.test.jsand the two-clienttwins(
form-action-binding-client.test.js,form-action-attr-guard-client.test.js)need no new assertions, since the refusal matrix and the leak guard are
untouched. Run them as regression.
Server unit
packages/server/test/routing/form-dispatch.test.jsalready has the 405 cases(lines 255-293) and an
onErrorcollector pattern (lines 451-453). Add there:the bind-nothing 405 also produces exactly one error with
code === 'WEBJS_FORM_ACTION_MISSING'carrying the field NAMES and none of thevalues; a page GET with
?__webjs_action=xstill returns 200 and produces oneWEBJS_FORM_SUBMITTED_AS_GET; a second identical request produces NO secondreport (the dedupe); and the non-form POST 405 at
form-dispatch.js:269produces no report at all.
Browser
packages/core/test/routing/browser/form-action-submit.test.jsis the righthome for the dev guard, since it already calls
enableClientRouter(), stubsfetch, and installs the nav guard fromtest/browser-nav-guard.js. Add asuite that renders a bound-submitter-in-unbound-form shape, stubs
console.error, dispatches a real submit, and asserts one[webjs]errornaming the fix, that it fires ONCE across two submits, and that the submission
still proceeds (the stubbed fetch or the promoted GET still happens), which is
the proof the guard changed nothing.
packages/core/test/rendering/browser/form-action-guard.test.jscovers therender-time refusal matrix, which does not change. Regression only.
e2e, the no-JS write path
The previous body named the wrong file.
test/e2e/form-submission-and-race.test.mjsruns against the website dev serverat :5001 and synthesizes form endpoints with Playwright
page.route()mocksover DOM injected via
evaluate()(its own header says so at lines 19-26), soit cannot exercise server form handling at all. The real no-JS form e2e is the
E2E: form actions (no-JS + enhanced)block attest/e2e/e2e.test.mjs:3108,which boots a real
examples/blogserver and drives a real browser withsetJavaScriptEnabled(false). Its submitter half is attest/e2e/e2e.test.mjs:3341-3390.Add a case there for the new
/feedback/triage-splitroute from step 7: with JSOFF, the served markup carries the component-rendered button's
name="__webjs_action"and its<hash>/publishDraftvalue, and a native submitruns
publishDraftand PRG-redirects. That is the counterfactual proving anisolated component is not dropped, because if the fallback ever refused, the
component would render empty and the button would not be in the DOM at all.
Run with
WEBJS_E2E=1.Bun parity (mandatory)
Form dispatch is a runtime-sensitive surface, so this is part of the task rather
than an afterthought.
test/bun/form-action-dispatch.mjsalready asserts thebind-nothing 405 (lines 151-154) and already builds its app through
createRequestHandler(line 64), which acceptsonError. Extend it toconstruct the handler with an
onErrorcollector and assert that the 405 alsoproduced exactly one error whose
.codeisWEBJS_FORM_ACTION_MISSING, andthat a page GET carrying
?__webjs_action=xreturns 200 AND produced oneWEBJS_FORM_SUBMITTED_AS_GET. Both assertions are cross-runtime byconstruction, since the file runs under
node test/bun/form-action-dispatch.mjsand
bun test/bun/form-action-dispatch.mjsthroughscripts/run-bun-tests.js,and is re-entered by the Node runner via
test/bun/form-action-dispatch.test.mjs.test/bun/form-action-submitter-parity.test.mjscovers the render-timesubmitter refusals, which do not change. Extend it with the cannot-tell shape (a
component-rendered submitter inside a bound form renders identically on both
runtimes) so the fallback itself is pinned cross-runtime.
The Bun-sensitive parts of the new code are
url.searchParams.has(),formData.keys(), andErrorproperty assignment, all standard, which is whythe parity assertion is an outcome check rather than a shape check.
Layers that do NOT apply
webjs doctor. This is a code-correctness rule, not a project-healthcheck, so it belongs in
checkand gets nowebjs.doctor.gatecode.test/examples/*/smoke/*). The new blog route is covered by thee2e that motivates it, and a smoke test would only re-assert that the route
boots.
webjs.*key is added, so nothingtouches
WebjsConfig, the JSON Schema, or the reader lockstep.Docs
A task is not done until every doc surface the change touches is in sync.
Invoke the
webjs-doc-syncskill. Six surfaces, each with what changes.packages/server/src/check.jsRULESdescription (step 2). This IS therule's documentation, because
webjs check --rulesprints it. It is the onlyplace the full conservatism contract is written down.
AGENTS.mdinvariant 12, line 486. Its last sentences today read:Append, without touching anything earlier in that paragraph:
webjs check'ssubmitter-needs-bound-formresolves the cannot-tell case statically acrossmodules and flags it at edit time, where a whole-app scan proves every call
site puts the tag in an unbound form; in dev the client logs a console error
at submit time when the submission cannot carry the identity; and in
production both server-visible fingerprints reach the
onErrorhook with acode (
WEBJS_FORM_SUBMITTED_AS_GETfor the query-string GET,WEBJS_FORM_ACTION_MISSINGfor a body carrying no identity). Correct thesentence "A page has no
actionexport, so a bare<form method="post">isa
405" only if the doc-sync pass finds it ambiguous; it is accurate..agents/skills/webjs/references/data-and-actions.md. The bullet list atlines 143-148 and the response paragraph at line 150. Add one bullet beside
the existing
method = 'GET'bullet (line 148, which is the model: symptom,then the rule name) covering the unbound-host-form case, its GET/query-string
symptom, and
submitter-needs-bound-form. Amend line 150's "a submissioncarrying no identity is a
405" to name theonErrorcode. This file isalso the scaffold's copy:
packages/cli/lib/create.js:665-673copies therepo-root
.agents/skills/webjs/into a generated app, so there is no secondscaffold file to edit and none should be created.
website/app/docs/server-actions/page.ts, lines 475-476. Theper-submitter paragraph and the submitter-refusals paragraph. Add that the
enclosing form must be bound, that the renderers cannot always tell across a
component boundary, and that
webjs checkcloses the gap. Line 474 alreadynames
form-action-not-a-get-actionin the same idiom to copy.website/app/docs/progressive-enhancement/page.ts, lines 172 and 176.Line 172 says a form that binds nothing gets a
405; that is true for ahand-written
<form method="post">and needs the second, quieter outcomeadded, namely that an unbound form with no
methodsubmits as a GET andsilently re-renders. Line 176 is the submitter-defeats-the-form paragraph and
is where the check rule belongs on this page.
website/app/docs/troubleshooting/page.ts, lines 54 and 60. The page iskeyed by symptom and line 54 already covers the 405 causes. Add the silent
symptom, which has no entry today: a button submits and the page just
re-renders with
?__webjs_action=in the address bar. Name the check rule,the dev console error, and the
onErrorcode as the three ways to see it.website/app/docs/conventions/page.tsexplains the check-versus-conventiondividing line but does not enumerate rules by name (it points at
webjs check --rules, line 43), so it needs no edit.Acceptance criteria
webjs checkreportssubmitter-needs-bound-formfor aformaction=${fn}submitter that a whole-app scan proves sits in an unbound form, across
module boundaries and transitively through intermediate components
a tag with no call site, a submitter in a bare
htmlhelper, amulti-component file, and a reference cycle
<form action=${fn}>as acode sample, and on
examples/blog,website,docs,packages/ui/packages/website, and a freshly scaffolded gallery appwebjs check --ruleslistssubmitter-needs-bound-formwith a descriptionthat states the conservatism contract
deliver it logs one
[webjs]console error naming the fix, fires once pershape, and does not change what the submission does
NODE_ENV=production)__webjs_actionin the query string still renders 200and reaches the
onErrorhook withcode === 'WEBJS_FORM_SUBMITTED_AS_GET'onErrorwithcode === 'WEBJS_FORM_ACTION_MISSING', carrying the field names and neverthe field values
method + pathnameper process, so a flood ofcrafted requests produces one report
/feedback/triage-splitserves thecomponent-rendered button with its identity and submits successfully with
JavaScript off
packages/core/src/render-server.js,form-action.js, andrender-client.jshave no behaviour change, and the existing cannot-telltests at
form-action-binding.test.js:643-720pass untouchedthrough
renderToStringand throughrenderToStream(v, { ssr: false })packages/server/test/check/,packages/server/test/routing/form-dispatch.test.js,packages/core/test/rendering/,npm run test:browser,WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs, andnode scripts/run-bun-tests.jsplusbun test/bun/form-action-dispatch.mjsOut of scope
packages/core/src/form-action.js:354-374. Do not revisit it.renderTemplate(
render-server.js:165) orstreamTemplate(render-server.js:1987). If thework seems to need one, the design is wrong.
stays 405. This issue makes failures observable, it does not re-litigate what
they answer.
form-dispatch.js. Line 269 (a non-form POST,answered before the body is read) and line 338 (a GET-declared action, which
has
form-action-not-a-get-actionand its own docs) stay exactly as they are.webjs check. The tool isunconditional by construction (
check.js:20-26).one question, form scope at a submitter and at a component tag. Do not grow it
into a shared framework for other rules in this PR.
packages/TypeScript.packages/is plain.jswith JSDoc and shipsbuildless. No
.tsfile is added anywhere under it.for the owner to decide, not into a new issue.
Cross-issue landmines
#1308 and #1309 are being planned in parallel. Code surfaces are disjoint, but
two doc files are shared.
AGENTS.mdis touched by all three in DIFFERENTsections (this issue edits only invariant 12, line 486), and
.agents/skills/webjs/references/data-and-actions.mdis touched by this issueand #1309 (this issue edits only the bound-form section, lines 143-150). Keep
every diff inside those ranges so the three PRs merge without conflict, and
rebase on
origin/mainbefore opening the PR rather than resolving a conflictafter the fact.