Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
20288d5
feat: resolve form-submitter boundness across modules in check
vivek7405 Aug 6, 2026
a7af25f
feat: report a form submission that cannot deliver its action
vivek7405 Aug 6, 2026
53590c3
test: dogfood the cannot-tell submitter shape end to end
vivek7405 Aug 6, 2026
72c30c8
fix: separate form boundness from deliverability in the check rule
vivek7405 Aug 6, 2026
ac94186
fix: stop the rule reporting two more shapes that actually work
vivek7405 Aug 6, 2026
b739e08
fix: give a handed-off template its own scope, not the cannot-tell one
vivek7405 Aug 6, 2026
06e3032
fix: the suspense fallback is the start-tag hole rendered inline
vivek7405 Aug 6, 2026
5b4885f
test: make the hand-off differential actually discriminate
vivek7405 Aug 6, 2026
fd181da
test: pin why the scanner and renderer disagree on an invalid enctype
vivek7405 Aug 6, 2026
b19347c
docs: correct the serializer claim behind the suspense fallback
vivek7405 Aug 6, 2026
705699e
docs: name the suspense exception in the rule text, and unbreak the list
vivek7405 Aug 6, 2026
d3d63dc
fix: require a real action binding before judging a formaction hole
vivek7405 Aug 6, 2026
f26a650
fix: resolve the binding to a provably callable export
vivek7405 Aug 6, 2026
f91ed30
docs: name the new silence class in all four lists
vivek7405 Aug 6, 2026
d6bebaf
fix: require the arrow, not just an open paren, to prove a callable
vivek7405 Aug 6, 2026
03b7a78
fix: skip a type annotation that contains its own arrow
vivek7405 Aug 6, 2026
e7ea22b
fix: read a TypeScript annotation the way TypeScript writes one
vivek7405 Aug 6, 2026
be56861
test: sweep the binding resolver across TypeScript spellings
vivek7405 Aug 6, 2026
403d637
fix: depth-guard the return-type walk, like the declaration one
vivek7405 Aug 6, 2026
0116913
fix: handle a nested arrow and a generic arrow, and mutation-test the…
vivek7405 Aug 6, 2026
c6f650d
fix: reach the generic walk after a space-less async, and pin its arr…
vivek7405 Aug 7, 2026
a30205b
fix: pin the type-parameter depth guard, and stop asserting a kill tally
vivek7405 Aug 7, 2026
ae2c660
fix: pin the last two reachable guards in the binding resolver
vivek7405 Aug 7, 2026
1c2c1c9
Merge origin/main into feat/submitter-needs-bound-form
vivek7405 Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/webjs/references/data-and-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Everything the action declares applies here too, or an action would be protected
- `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
- `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
- `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
- A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`, inside a form that is itself bound. **Bind the enclosing form**, because `method="post"` and the enctype are supplied on the form's start tag and a per-button action cannot retrofit them. The renderer refuses an unbound host form it can see, but a submitter in a COMPONENT is a cannot-tell (the component renders in its own pass with no view of the host page) and binds anyway. What happens then depends on the host form. One that still sends a parseable POST body WORKS, because the identity rides the button's own `name`/`value` pair into the body. One with no `method` (or `method="get"`) submits a GET, so the identity rides the query string, the action never runs, and the page re-renders with a 200 with nothing thrown and nothing logged. `webjs check`'s `submitter-needs-bound-form` resolves this across modules and flags it at edit time; in dev the client logs one `console.error` at submit time, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for the query-string GET, `WEBJS_FORM_ACTION_MISSING` for a body carrying no identity). See `muscle-memory-gotchas.md` for the shape.

The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.

Expand Down
23 changes: 23 additions & 0 deletions .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
| `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
| `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |

| `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
| `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
Expand All @@ -136,6 +137,28 @@ That last row is the one to remember: quoting a binding hole turns it back into

`.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.

**A submitter in a component whose host form is unbound AND cannot carry a body is the one failure the renderers cannot throw on.** It is the shape to check by hand whenever you split a form across modules:

```ts
// components/publish-button.ts <- the submitter lives here
class PublishButton extends WebComponent({}) {
render() { return html`<button formaction=${publishDraft}>Publish</button>`; }
}
PublishButton.register('publish-button');

// app/triage/page.ts <- the form lives here
// WRONG: the form binds nothing, and NOTHING throws.
html`<form><publish-button></publish-button></form>`;
// RIGHT: bind the enclosing form too.
html`<form action=${saveAll}><publish-button></publish-button></form>`;
```

The component renders its own template in a separate pass with no view of the host page, so the renderer sees a cannot-tell and binds anyway (refusing would drop an isolated component from a page that still returned 200, which is worse). What ships is a button carrying the reserved `__webjs_action` identity inside whatever form the page wrote. Whether that is broken depends on the form, and the distinction is easy to miss: one that still sends a parseable POST body WORKS, because the identity rides the button's own `name`/`value` pair into the body and the dispatcher runs the action. One with no `method` (or `method="get"`) submits a GET, so the identity rides the QUERY STRING, the action never runs, the page re-renders, the status is 200, and there is no throw, no log, and no 405. A silent write path is the whole failure mode, so treat the address bar growing a `?__webjs_action=` as the fingerprint.

**Two runtime signals back the check up.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.

**Run `webjs check` and it catches this for you.** The `submitter-needs-bound-form` rule reads every template in the app at once, which neither renderer can do, so it resolves the enclosing form across module boundaries and transitively through intermediate components (a page's form around `<todo-list>` around `<todo-row>` around the button). It is conservative by design and says nothing when it cannot be sure: a tag rendered in a bound form somewhere and an unbound one elsewhere, a tag whose host form is unbound but still DELIVERS (that shape works), a form whose `method` or `enctype` comes from a hole, a tag with no call site in the app, a submitter in a bare `html` helper rather than a component class body, a file registering more than one tag, a file that opens a form of its own, a submitter or tag handed to another element through a start-tag hole (`<my-thing .tpl=${html`…`}>`), a `formaction` hole that is not a proven action binding (a url string or CONSTANT, a factory-produced export, a namespace or default import, a barrel re-export, or a non-identifier expression like `acts.publishDraft`), or a reference cycle. The one start-tag hole it DOES judge is `<webjs-suspense .fallback=${html`…`}>`, because the renderer renders a fallback inline in the enclosing form rather than handing it off. Silence from the rule is therefore not proof the form is bound; a green check plus the shape above still deserves a look.

**Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.

Two things that "renders it empty" understates, both worth knowing before you go looking:
Expand Down
12 changes: 12 additions & 0 deletions gallery/modules/todo/actions/submit-todo.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ import { deleteTodo } from './delete-todo.server.ts';
// control's visible label), and a bound submitter cannot carry its own
// `name`/`value`, which is exactly the channel `name="intent"` uses below.
//
// Third thing to know, and the one that fails silently: the enclosing <form>
// has to be bound too, because `method="post"` and the enctype are supplied on
// the form's start tag and a per-button action cannot retrofit them. The
// renderer refuses an unbound form it can see, but a submitter inside a
// COMPONENT is a cannot-tell (the component renders in its own pass with no
// view of the host page) and binds anyway. What happens then depends on that
// form: one still declaring `method="post"` works (the identity rides the
// button's own name/value pair into the body), but one with no method submits as
// a GET, so the identity rides the query string and the action never runs while
// the page returns 200. Run `webjs check`: `submitter-needs-bound-form` finds
// these across modules.
//
// With JS the component intercepts the submit and calls the underlying action
// directly for the optimistic path, so this runs only with JS off.
export async function submitTodo(formData: FormData) {
Expand Down
Loading