feat: make a bound form submitter carry its own submission - #1317
Merged
Conversation
vivek7405
marked this pull request as ready for review
August 7, 2026 13:40
A `<button formaction=${fn}>` used to be half a submission. It carried the
action identity in its name/value pair, while `method="post"` and the enctype
were supplied on the enclosing FORM's start tag. So the button only worked
inside a form that was itself bound, and the renderer refused it anywhere else.
That refusal could not be enforced where it mattered. A component renders its
own template in a separate SSR pass with no view of the host page, and a button
inside a component is the ordinary shape, so the scan had to fall back to
binding whenever it could not tell. The result was a form that submitted as a
GET, put the identity in the query string, re-rendered the page, and ran
nothing: a silent 200 with no log.
Supply the submission attributes at the level where the action is bound, and
never override what the author wrote at that same level. A bound form still
gains `method="post"` plus `enctype`; a bound submitter now gains
`formmethod="post"` plus `formenctype` on the button itself. React does the
same thing for a function `formAction`. The button is then self-sufficient
inside a bound form, an unbound form, a `method="get"` form, or no form at all,
which deletes the failure class rather than reporting it.
Refusals narrow to same-element contradictions, which have no correct fallback.
A cross-element rule always has one, namely whatever native HTML would do, so a
PLAIN button's own `formmethod` / `formenctype` is now honoured rather than
refused: the author typed it deliberately and the form's action simply does not
run. That reverses #1207's Part B, whose stated justification ("either works
under JS and is a bare 405 without it") was only ever true of `formenctype`;
the client router already honours a submitter's `formmethod` with native
precedence, so both paths lost the identity identically.
With nothing left to ask about the enclosing element, the four-state form-scope
tracking goes: the parameter threaded through both SSR state machines, the
Suspense scope carried across the server's boundary drain, and the client's
enclosing-form walk and candidate bookkeeping.
No `formaction` url is emitted, unlike React, because an empty one is an HTML
conformance error and this repo already bans the shape. A bound submitter
inside a form declaring `action="/x"` therefore posts to `/x`, which is native
precedence, and the action still runs because the identity travels in the body.
The client router built a FormData for every submission and sent it with no explicit content type, so fetch always derived multipart/form-data and the authored enctype was never read at all. `application/x-www-form-urlencoded` is the HTML default, so a plain `<form method="post">` MEANS urlencoded. That form sent a urlencoded body with JS off and a multipart body with JS on: one template, two different requests, which is the thing progressive enhancement is supposed to rule out. It is independent of the submitter work but lives in the same function, so it is fixed here rather than left as a known divergence. Resolve the effective enctype with native precedence (a submitter's formenctype over the form's) and encode the body to match. Multipart keeps sending FormData; urlencoded sends URLSearchParams, with a File serialized as its name, which is what the platform's own urlencoded serializer does. Turbo drops file entries here instead, losing a field the no-JS path sends. `text/plain` gets no encoder. The HTML spec calls its payload not reliably interpretable by computer, and `looksLikeFormSubmission` accepts multipart and urlencoded only, so such a POST is answered by a bare 405 before its body is read. The router declines the submission and lets the browser perform it natively, so both paths do the same thing. Turbo enumerates this encoding and then sends FormData anyway, which is the divergence being avoided. The value is matched UNTRIMMED, like every other enumerated attribute here: a padded `enctype=" multipart/form-data "` falls to the invalid-value default and a browser sends urlencoded for it, so trimming would have reintroduced the same disagreement one level down. A test caught that. The real proof is in the browser suite, reading the encoded body off a stubbed fetch, because the linkedom unit harness cannot drive onSubmit as far as preventDefault (`new FormData(formElement)` throws there). That limit is now recorded beside the bail tests it weakens, since they can show a submission was not routed but not that it bailed for the stated reason.
Adds the dogfood route the headline claim needs. `/feedback/triage-split` renders a form with NO action and NO method, so a browser defaults it to GET, and both of its buttons bind their own action. Both shapes are present because they exercise different renderer paths. "Save draft" is inline in the page template, which SSR scans in one pass, and was the case the renderer used to REFUSE outright. "Publish" comes from a component, whose template SSR renders in a separate pass with no view of the host page, and was the case it bound anyway because it could not tell. The second is why the fix had to remove the question rather than answer it better: no scan can see a page from inside a component's own render. The e2e asserts the served markup carries `formmethod` and `formenctype` on the button itself, and then that pressing it actually RUNS the action, in a real browser with scripting disabled. Markup alone would not prove the second. Note for anyone re-checking this: e2e resolves `@webjsdev/core` through the BUILT dist, so `npm run --prefix packages/core build:dist` has to run before the suite or it exercises the previous build. The counterfactual for this test passed vacuously until that was done, and fails correctly after it.
The scanner kept one flat depth counter and incremented it at `${`, but the
matching `}` arrived while it was still in template state, so nothing ever
decremented it. Depth could not return to zero, and a class body containing an
interpolated template was unmatchable.
A template hole is a code context nested INSIDE a template, not a brace in the
enclosing block, so it gets its own frame on a stack. The `}` that returns that
frame to zero pops back into the template rather than counting toward the block
being matched.
Latent rather than live: every caller passes a position-preserving mask in
which holes are already blanked, so no caller has ever fed it a `${`. Fixed
independently of the form-submitter work it was found alongside, and committed
on its own because it is its own defect in a shared lexer.
The new test's counterfactual is the flat counter: it returns -1 for a balanced
body and the template-hole case goes red.
The renderer deliberately stopped refusing a plain submitter's own formmethod and formenctype, because native HTML defines what those mean and an author who typed one meant it. That leaves an honest gap: the shape is also what a mistake looks like, and nothing else in the pipeline can tell the two apart. Submit time can, because it is the only moment the resolved method, the resolved enctype and whether a bound identity is actually in the body all exist together. So a dev-only console.error reports it there, once per shape, and is silent in production where the visitor could not act on it anyway. Placed BEFORE the text/plain bail rather than after. Putting it after made the text/plain branch dead code, which a test caught: that is precisely the case where the router declines the submission, both paths are answered with a 405, and the author gets no other signal. The enctype test is `text/plain` alone, NOT the renderer's parseable-enctype allowlist. `enctype` is an enumerated attribute whose missing and invalid value defaults are both urlencoded, so `enctype="nonsense"` submits a parseable body and the action runs; testing the allowlist would report that working form as broken. Two of the new tests initially passed for the wrong reason. A `submit` listener on the container bubbles BEFORE the router's document-level one, so it set defaultPrevented and onSubmit returned at its first line, meaning the router never made the decision being measured. The suite's nav guard already handles this correctly by listening on window bubble, and its own docs warn about the capture-phase version of the same mistake.
The previous commit moved `buildSubmitFormData` ahead of the origin and file-extension checks so the dev report could precede the text/plain bail. That reached the FormData construction for submissions the router ignores, and two pre-existing unit tests went red: `new FormData(formElement)` throws under the linkedom harness those tests run in. Move the text/plain bail down instead. The order is now the cheap bails, then the body, then the report, then the bail, which keeps the report ahead of the bail (the reason for the original move) without building a body for a submission that was never going to be routed. I committed the previous change with those two tests already failing, because the verifying command was chained with `&&` on a grep that succeeded whatever the tally said. The tally was in the output and I did not read it.
Two detect-only diagnostics, each with an err.code an app can group on. WEBJS_FORM_ACTION_MISSING: a parseable form body carrying no identity, which is answered with a 405 and otherwise leaves nothing but an anonymous status in an access log. WEBJS_FORM_SUBMITTED_AS_GET: a page GET carrying the reserved field in its query string, meaning a submission holding a bound action's identity went out as a GET, so the action never ran and the page still returned 200. Nothing in the framework puts that field in a url. A bound submitter now carries its own formmethod="post", so what reaches this is an explicit formmethod="get" or method="get" the author wrote and the renderer deliberately honours. It is the production counterpart to the dev-time console error the router logs at submit time, and it is why honouring the override rather than refusing it does not mean shipping a silent failure. Both keep rendering their normal response. Answering a GET differently because of a query parameter would hand any visitor a way to turn any page into an error. Both carry field NAMES and never values, and both dedupe per process on code plus method plus matched ROUTE with a 256-entry cap, because either is reachable unauthenticated and an uncapped report is a free amplifier into a paid sink. Keying on the route pattern rather than the pathname is what stops crafted urls on a dynamic route from filling the cap and silencing the diagnostics. Applying the original patch with `git apply --3way` silently reverted unrelated newer work in dev.js, including #1308's orphan warning and the elision verdict fields, because it was computed against an older main. One test caught it. The hunk is hand-applied instead, threading hasOnError and logger through handleCore's context rather than reaching for a binding that is not in scope there.
`buildFormActionRecord` selects the submission pair by element, `method` / `enctype` on a form and `formmethod` / `formenctype` on a submitter. The filter was updated for that; the dispatch one line below still compared against the literal 'method'. So on a submitter every hole-provided `formmethod` fell through to `enctypeParts`. The client then resolved it as the ENCTYPE and refused `formenctype="post"` on a template SSR renders happily: render on the server, throw on hydration, which is the one failure direction this module treats as unacceptable. A STATIC `formmethod="post"` was unaffected, because that reaches the record through `staticMethod`, which already used the right attribute name. Found by auditing every consumer of the record's fields rather than by a test, which is why the fix ships with the differential rows that would have caught it. SSR reads the emitted start tag and is unaffected, so only a row comparing the two renderers on a HOLE-provided value sees this at all. Counterfactual verified: those rows go red against the literal comparison and green with it fixed.
The bind/release cycle had no direct test. It has two halves that can fail independently: a released button that keeps `formmethod="post"` no longer matches what SSR emits for the same template, and one that loses an author's own value has had its markup destroyed by a framework that never owned it. Both are now asserted. Also renames a client test whose comment still named `enclosingForm`, deleted in this branch. The property it guards still holds, but for a better reason than the cache it was written for: there is no longer an enclosing-form question to give two different answers on a first render and an update. Kept as a regression guard, because any future rule that reads outside the element reintroduces exactly that split.
CI caught this, in an e2e file that runs as its own job and that I had not run locally. The test built `<form method="POST">` with no enctype and asserted the body contained `Hello World`, which only held while the router promoted every submission to multipart. Its comment said "FormData is sent multipart by default in browsers". That is backwards, and it is why the assertion looked reasonable: a BROWSER defaults to `application/x-www-form-urlencoded`, and multipart is what `fetch` derives from a FormData object. The test had encoded the bug as the expected behaviour, so the same form sent urlencoded with JS off and multipart with JS on and nothing noticed. It now asserts `title=Hello+World` and that no multipart marker appears, which is exactly what the browser sends for that form with JS disabled. Adds the other half too: a form DECLARING multipart must still get multipart. Without it, someone could satisfy the first assertion by hardcoding urlencoded and silently break every file upload on the no-JS path.
Auditing for shapes this change leaves both unrefused AND unreported turned up exactly one, and it is one the change itself created. A bound submitter emits no `formaction` url, so the submission targets whatever the FORM targets, and a form declaring its own `action="/x"` sends its buttons to `/x` by ordinary native precedence. The renderer used to throw for that, but only where it could SEE the form, which is precisely the cross-element judgement it cannot make from inside a component. Nothing else caught it either: the dev guard stayed silent because the method is post, the enctype is fine and the identity is present and deliverable, and no telemetry fires because the POST never reaches a page GET. Reported at submit time instead, where the resolved target is a fact rather than an inference. A warning rather than an error, because it is not necessarily wrong. If `/x` is a PAGE route the action really does run there and the 422 re-render simply lands on that page. It is dead only against a `route.ts`, another origin, or nothing at all, and the client cannot tell which from here. The counterfactual ships with it: a bound FORM has its action stripped by the renderer so it always posts to its own page, and must never trip this. Without that row the guard could fire on every submission and the positive test would still pass.
The parity suite covered a plain `attr` hole for a bound submitter's
`formmethod` / `formenctype`, which is one commit branch of four. An
`attr-mixed` value is assembled from statics plus values, and a FALSY boolean
hole emits nothing so the framework supplies the attribute as if the template
were silent. Each resolves through a different path and none was pinned.
All four now render byte-identically on both renderers, in three real browsers.
Adds the truthy boolean row too: `?formmethod=${true}` emits `formmethod=""`,
an empty enumerated value that cannot submit, so it must refuse rather than be
mistaken for absent and quietly supplied.
Found by working the review areas by hand after two review agents failed to
report. The other areas came back clean: the GET query-string promotion is
byte-identical over a FormData and a URLSearchParams including File-to-name,
and the telemetry dedupe keys on the matched route rather than the pathname.
vivek7405
force-pushed
the
feat/self-sufficient-form-submitter
branch
from
August 7, 2026 15:12
0459867 to
e7be0d0
Compare
This was referenced Aug 7, 2026
vivek7405
added a commit
that referenced
this pull request
Aug 7, 2026
…#1318) Clears the four packages carrying unreleased work since 0.7.47. The other tracked packages (ui, intellisense, and the two editor packages) picked up nothing user-facing in the range: ui's only change was a corrected doc block and its test, so bumping it would ship an empty changelog. Three features and two fixes ride along. The bound-form submitter now carries its own submission (#1317): a `formaction=${action}` button gains its own `formmethod` and enctype rather than relying on the enclosing form's start tag, so it works in a bound form, an unbound form, a `method="get"` form, or no form at all. That deletes the silent-GET failure the previous release could only report, and reverses the part of #1207 that refused a plain button's own `formmethod`. The client router also sends the enctype the author declared, which a plain `<form method="post">` never got: it sent urlencoded with JS off and multipart with JS on. The elision verdict becomes inspectable (#1312): `webjs elision` prints the per-module verdict with the evidence behind every ship, `--json` emits it, `--verify` diffs masked SSR bytes with `WEBJS_ELIDE` flipped across the app's own route corpus, and the same report reaches `webjs doctor` and the MCP `list_elision` tool. SSR action seeding becomes observable in dev (#1311) through `X-Webjs-Seed`, the access log, and a browser warning that names only causes it can prove. Raises packages/server's declared `@webjsdev/core` range from ^0.7.47 to ^0.7.48. No new core export is imported statically, so this is not the load-time skew the rule was written for, but both features span the two packages over a shared wire contract (the seed header and marker that core's client reads, the submitter attributes SSR emits and core's renderer commits), and the release PR is the only place that bump is legal. The generated notes were curated before committing: each entry is rewritten to the package's own slice rather than the shared commit subject, the seeding feature's fourteen follow-up fixes are folded into the feature they hardened, and the cli entry is narrowed to what actually changed in the scaffold.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1307
What this changes
A
<button formaction=${fn}>used to be half a submission. It carried the action identity in itsname/valuepair, whilemethod="post"and the enctype were supplied on the enclosing form's start tag. So the button only worked inside a form that was itself bound.That requirement could not be enforced where it mattered. SSR renders a component's template in a separate pass with no view of the host page, and a button inside a component is the ordinary shape, so the scan had a third answer, cannot-tell, and had to bind on it. Refusing there would have been worse: a component's SSR error is isolated, so production would return 200 with the button silently missing. Binding produced a form that submitted as a GET, put the identity in the query string, re-rendered the page, and ran nothing. A silent 200 with no log.
The fix supplies submission attributes at the level where the action is bound. A bound form still gains
method="post"plusenctype; a bound submitter now gainsformmethod="post"plusformenctypeon the button itself, which is what React does for a functionformAction. The button becomes self-sufficient, so the failure class is deleted rather than reported.Every attribute emitted is spec-defined HTML on a submit button. With JavaScript off no framework code runs at all: the browser reads them and produces the POST.
The rule the refusals now follow
Refuse a same-element contradiction; never a rule about the author's other elements. A same-element contradiction has no correct fallback. A cross-element rule always has one, namely whatever native HTML would do.
So a plain
<button formmethod="get">inside a bound form is now honoured rather than refused. This reverses #1207's Part B, and it is worth recording why: Part B's stated justification was that either attribute "works under JS (the router postsFormData) and is a bare 405 without it". That was only ever true offormenctype.getSubmitMethodalready honoured a submitter'sformmethodwith native precedence andperformSubmissionpromoted a safe-method body to the query string, so both paths lost the identity identically. There was no works-one-way-only half to refuse. That correction is now recorded in the issue, because the false premise is what would justify reinstating the rule later.A second, independent defect fixed in the same path
The client router built a
FormDatafor every submission and sent it with no content type, sofetchalways derivedmultipart/form-dataand the authoredenctypewas never read. Since urlencoded is the HTML default, a plain<form method="post">sent urlencoded without JS and multipart with it. One template, two different requests.The router now resolves the enctype with native precedence and encodes to match.
text/plaingets no encoder: the spec calls its payload not reliably interpretable, andlooksLikeFormSubmissionaccepts multipart and urlencoded only, so the router declines the submission and lets the browser perform it natively. Both paths then agree. Turbo enumerates that encoding and sendsFormDataanyway, which is the divergence avoided here.What is deleted
The four-state form-scope concept: tracked through both SSR state machines, threaded through the Suspense boundary drain in
ssr.js, and reimplemented on the client with a parent walk and a candidateWeakMap. HTML has no such concept, and it existed only to refuse markup browsers handle fine.assertSubmitterFormIsBound,assertBoundFormSubmitters,enclosingForm,formActionCandidatesandformScopeare all at zero occurrences.Deliberate divergence from React
React emits
formAction=""on such buttons. This does not, because an empty URL is an HTML conformance error and the repo already bans the shape with a scaffold guard. Consequence, stated rather than left to be discovered: a bound submitter inside a form declaringaction="/x"posts to/x. That is native precedence, and the action still runs if/xis a page route.Also carried, so the allowed shapes stay observable
text/plainbail, since that is exactly the case the router declines.onErrorcodes,WEBJS_FORM_ACTION_MISSINGandWEBJS_FORM_SUBMITTED_AS_GET, deduped per process on code plus method plus matched route pattern so crafted urls on a dynamic route cannot exhaust the cap.matchClosingBracefix (own commit): a${incremented a depth counter nothing decremented, so a class body holding a template hole was unmatchable. Latent because every caller passes a masked source.Verification
npm testwebjs checkexamples/blogandwebsitewebjs doctorThe headline is an e2e with JavaScript disabled: a component-rendered button inside a form with no
actionand nomethodruns its action end to end. Counterfactuals verified for that test, for the enctype encoding, and for the scanner fix.ssr-client-parity.test.jsgained the rows that moved out of the refusal tables and now proves SSR and client emit byte-identical markup for all of them. ItsSSR_ONLY_REFUSEStable is empty and deleted, with a note explaining that a new entry there signals a cross-element rule creeping back in.Notes for the reviewer
submitlistener pre-empted the router) and one counterfactual passed vacuously (e2e resolves core through the built bundle, which I had not rebuilt). Both fixed; the dist trap is now recorded in the test's own comment.grepthat succeeded regardless of the tally. Fixed in the next commit, which says so.AGENTS.mdinvariant 12 and four skill references to teach the detection model this contradicts, and AI agents are the primary readers of those files.