Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 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
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
3 changes: 2 additions & 1 deletion .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>` inside a bound form. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, `formmethod="get"` or an unparseable `formenctype` on ANY submitter in a bound form, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405.
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>` inside a bound form. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, `formmethod="get"` or an unparseable `formenctype` on ANY submitter in a bound form, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405. The ONE shape that does not throw is a submitter whose enclosing form the renderer cannot see (the button in a component, the form in the page): that is a cannot-tell and binds anyway. An unbound host form that still sends a parseable POST body works (the identity rides the button's own name/value pair), but one with no `method` submits as a GET with the identity in the query string, so the action silently never runs. `webjs check`'s `submitter-needs-bound-form` catches it statically.

## Export Map

Expand Down Expand Up @@ -237,6 +237,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
- Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
- Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
- Writing `formmethod="get"` or `formenctype="text/plain"` on any button inside a bound form. Neither can carry the action's body, so both are refused even when the button binds nothing.
- Putting a `formaction=${fn}` submitter in a COMPONENT and forgetting to bind the `<form>` in the page that renders it. This is the one near-miss that does NOT throw: the component renders in a separate pass with no view of the host page, so the renderer binds anyway. If that host form has no `method` it submits as a GET, the identity rides the query string, the action never runs, and the page re-renders with a 200. (An unbound `<form method="post">` does deliver, so that one works.) Run `webjs check` (`submitter-needs-bound-form`) and see `muscle-memory-gotchas.md`.
- Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/built-ins.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ Wired at the single response funnel, covering pages, routes, actions, and assets

- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
- **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope).
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM.
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM. It also carries two framework DIAGNOSTICS that are not request failures, each with an `err.code` to group or filter on, both under `phase: 'action'`: `WEBJS_FORM_SUBMITTED_AS_GET` (a page GET carrying the reserved `__webjs_action` field in its query string, which only a bound submitter inside an UNBOUND form produces, #1307) and `WEBJS_FORM_ACTION_MISSING` (a PARSEABLE form body carrying no identity, the 405; an `enctype="text/plain"` submission is answered before its body is read, so it stays a bare 405). Both are detect-only, so the 200 and the 405 are unchanged; both carry `method`, `pathname`, and for the second the submitted field NAMES, never the values; and both are deduplicated per process on the code, the method, and the matched ROUTE (not the request pathname, so crafted urls on a dynamic route cannot exhaust the 256-entry cap and silence the diagnostics), since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink.

```ts
const app = await createRequestHandler({
Expand Down
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
25 changes: 24 additions & 1 deletion .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ 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 inside a bound form | **no, it BINDS** | the second supported shape (#1207). The identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, so no `formaction` url is emitted and the server takes the LAST `__webjs_action` entry |
| `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
| `formaction=${fn}` inside an UNBOUND `<form>` **the renderer can see** | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
| `formaction=${fn}` inside an unbound form the renderer CANNOT see (the button is in a component, the form is in the page) | **no, it BINDS** | a component renders its own template in a separate pass with no view of the host page, so boundness is a cannot-tell there and cannot-tell has to bind. `webjs check`'s `submitter-needs-bound-form` is what catches this one, and it is silent at runtime otherwise: see the section below |
| `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 form other than the bound one it sits in, so the boundness just checked was about the wrong element |
Expand All @@ -120,6 +121,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
Loading
Loading