Skip to content

feat: resolve form-submitter boundness in webjs check and make the residual loud #1307

Description

@vivek7405

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\

`whererow-btnrendershtml`Go``) emits:

<form><row-btn data-wj-host><!--webjs-hydrate--><button name="__webjs_action" value="abc123/doThing">Go</button></row-btn></form>

So the three real outcomes are:

  1. 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).
  2. 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.
  3. 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

const s = '<form>';
export default () => html`<form action=${save} class="x">${rows.map(r => html`<button formaction=${del}>x</button>`)}</form>`;

redactToPlaceholders returns

"const s = '__STR_0__';\nexport default () => html`__STR_1__${save}__STR_2__${rows.map(r => html`__STR_3__${del}__STR_4__`)}__STR_5__`;"
literals: 0:"<form>"  1:"<form action="  2:" class=\"x\">"  3:"<button formaction="  4:">x</button>"  5:"</form>"

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' }>,
 * }}
 */
export function scanHtmlFormScopes(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}
 */
export function classifyActionHole(literalBefore) {  }

and rewrite the loop at check.js:1408-1420 to call it. Today that loop reads:

        for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
          const before = literals[Number(m[1])] || '';
          const tagAt = before.lastIndexOf('<');
          if (tagAt < 0) continue;
          const tag = before.slice(tagAt);
          if (tag.includes('>')) continue;
          const bindsForm = /^<form\b/i.test(tag) && /\saction=$/i.test(tag);
          const bindsSubmitter = /^<(?:button|input)\b/i.test(tag) && /\sformaction=$/i.test(tag);
          if (!bindsForm && !bindsSubmitter) continue;
          bound.add(m[2]);
        }

and after the extraction reads:

        for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
          if (!classifyActionHole(literals[Number(m[1])] || '')) continue;
          bound.add(m[2]);
        }

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
 */
function checkSubmitterNeedsBoundForm(files, violations) {  }

Body, in order:

  1. 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.
  2. 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.
  3. 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.
  4. Flag the definite same-scan case. Every submitter at 'unbound' in the
    whole-file scan is a violation immediately, with no cross-module step.
  5. 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'.
  6. 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 */
function warnOnce(key, message) {
  if (warnedKeys.has(key)) return;
  warnedKeys.add(key);
  if (typeof console !== 'undefined' && console.warn) console.warn(message);
}

and after this change 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.
 */
function warnOnce(key, message, level = 'warn') {
  if (warnedKeys.has(key)) return;
  warnedKeys.add(key);
  const sink = typeof console !== '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
 */
function warnIfActionSubmissionCannotDeliver(form, submitter, method, body) {
  if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
  if (!body.has(FORM_ACTION_FIELD)) return;   // binds nothing: an ordinary form
  const enctype = (submitter && submitter.getAttribute('formenctype'))
    || form.getAttribute('enctype')
    || 'application/x-www-form-urlencoded';
  const path = (() => { try { return new URL(form.action || location.href, location.href).pathname; } catch { return location.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:

  const body = buildSubmitFormData(form, submitter);

  warnIfActionSubmissionCannotDeliver(form, submitter, method, body);

  e.preventDefault();

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>}
 */
const reportedFormFingerprints = new Set();
const FINGERPRINT_CAP = 256;

/** @param {string} key @returns {boolean} true the first time only */
function firstSighting(key) {
  if (reportedFormFingerprints.has(key)) return false;
  if (reportedFormFingerprints.size >= FINGERPRINT_CAP) return false;
  reportedFormFingerprints.add(key);
  return true;
}

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
 */
export function reportFormActionMissing(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]
 */
export function reportFormSubmittedAsGet(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:

  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 `<form method="post">`
  // that binds no action. Nothing to run, and the page only renders.
  if (typeof id !== 'string' || !id) return methodNotAllowed();

and after this change reads:

  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 `<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 (typeof id !== 'string' || !id) {
    reportFormActionMissing(url, req, formData, onError);
    return methodNotAllowed();
  }

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:

  1. RULES lists submitter-needs-bound-form (the registration test at
    form-action-not-a-get-action.test.js:38-40).
  2. 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.
  3. Silent when the page's form IS bound (<form action=${save}>).
  4. 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.
  5. Silent when the tag has ZERO call sites in the app.
  6. 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.
  7. Silent when the file registers two tags (ambiguous attribution).
  8. Fires on the same-scan case, <form><button formaction=${act}></form> in one
    template, with no cross-module step involved.
  9. 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.
  10. Cycle: <a-one> renders <b-two> and <b-two> renders <a-one>. Silent,
    and no hang.
  11. The docs-sample carve-out. A page whose template contains the TEXT
    &lt;form&gt; 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.

  1. packages/server/src/check.js RULES 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.

  2. 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.

  3. .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.

  4. 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.

  5. 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.

  6. 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions