Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/webjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
- Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
- Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
- Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
- Writing `method="get"` on a bound `<form action=${fn}>`. WebJs supplies `method="post"` and `formenctype` automatically; manually setting `method="get"` triggers a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning.
- Writing `method="get"` on a bound `<form action=${fn}>`. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
- 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()`.
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
Expand Down
4 changes: 2 additions & 2 deletions .agents/skills/webjs/references/data-and-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +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.
- A form whose buttons run DIFFERENT actions binds each on its submitter, `<button formaction=${publishDraft}>`. **The submitter is self-sufficient** (#1307): the renderer gives it `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the identity riding the button's own `name`/`value` pair, and a submitter's `formmethod` overrides the form's `method` per HTML. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and the enclosing form does not need binding for the button's sake. Bind the form when the FORM itself should run an action on a plain submit. In dev the client logs one `console.error` at submit time for a submission holding an identity it cannot deliver, and in production both server-visible fingerprints reach `onError` with a code (`WEBJS_FORM_SUBMITTED_AS_GET` for an identity in the query string, `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 All @@ -169,7 +169,7 @@ A `'use server'` action is a POST by default. Reserved sibling exports, read sta
1. **Form-Bound Actions (`<form action=${fn}>` / `<button formaction=${fn}>`):**
- **MUST be POST.** Leave unannotated (default) or export `export const method = 'POST'`.
- **NEVER export `export const method = 'GET'` for form actions.** The HTML renderer automatically emits `method="post"` and `formenctype` for form actions. Binding a `method = 'GET'` action to a form returns a `405 Method Not Allowed` at runtime and triggers a `webjs check` error (`form-action-not-a-get-action`).
- **NEVER add `method="get"` to a bound `<form action=${fn}>`.** WebJs manages form submission semantics automatically; adding `method="get"` causes a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning.
- **NEVER add `method="get"` to a bound `<form action=${fn}>`.** WebJs manages form submission semantics automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.

2. **Programmatic / RPC Read Actions (Queries):**
- **ALWAYS export `export const method = 'GET'` for read-only queries.**
Expand Down
13 changes: 6 additions & 7 deletions .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
| `export const method = 'GET'` on a form-bound action file | yes | form-bound actions strictly enforce `POST`. Binding a GET action to a form produces a 405 runtime refusal and `webjs check` error (`form-action-not-a-get-action`) |
| `method="get"` on a bound `<form action=${fn}>` | yes | WebJs supplies `method="post"` and `formenctype` automatically; manually setting `method="get"` triggers a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning |
| `method="get"` on a bound `<form action=${fn}>` | yes | WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read |
| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
Expand All @@ -137,7 +137,7 @@ 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:
**A bound submitter is self-sufficient and asks nothing of the form around it.** This is the shape people expect to have to wire up, and do not:

```ts
// components/publish-button.ts <- the submitter lives here
Expand All @@ -147,17 +147,16 @@ class PublishButton extends WebComponent({}) {
PublishButton.register('publish-button');

// app/triage/page.ts <- the form lives here
// WRONG: the form binds nothing, and NOTHING throws.
// BOTH work. The button carries its own submission attributes.
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.
The renderer supplies the submission attributes at the level where the action is BOUND (#1307), so a bound `<button>` gains `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the reserved `__webjs_action` identity riding the button's own `name`/`value` pair. A submitter's `formmethod` overrides the form's `method` per HTML, so the submission is a POST whatever the enclosing form declares, including no `method` at all or `method="get"`, and the identity travels in the body where the dispatcher reads it.

**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.
That is also why the renderer refuses only a SAME-ELEMENT contradiction (a bound submitter's own `formmethod` other than post, an `formenctype` the server cannot parse, `formmethod="dialog"`) and never a cross-element one. A component renders its template in a separate pass with no view of the host page, so the cross-element question is unanswerable at render time, and self-sufficiency leaves nothing for it to answer. One consequence: no `formaction` url is emitted, so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs when `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin nothing runs, which the dev-time client guard reports at submit time.

**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.
**Two runtime signals cover what is left.** 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. A BOUND submitter carries its own `formmethod="post"`, and a bound form is refused a `method="get"` outright, so what reaches the first one is a PLAIN submitter's `formmethod="get"`, which native precedence lets win and the renderer deliberately honours, or a hand-authored form carrying the reserved field. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.

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

Expand Down
17 changes: 6 additions & 11 deletions gallery/modules/todo/actions/submit-todo.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,12 @@ 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.
// Third thing to know: no `formaction` url is emitted, because the identity
// travels in the body instead. So the submission targets whatever the FORM
// targets, and a form declaring `action="/x"` sends its buttons there. The
// action still runs when `/x` is a PAGE route; against a `route.ts` or another
// origin the identity is ignored and nothing runs, which the dev-time client
// guard reports at submit time.
//
// With JS the component intercepts the submit and calls the underlying action
// directly for the optimistic path, so this runs only with JS off.
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/render-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -750,11 +750,10 @@ function buildFormActionRecord(el, onEl, parts) {
* record.
*
* FORMS FIRST, submitters second, regardless of the order the records were
* collected in. A submitter asks whether its enclosing form is bound, and the
* cheapest true answer is the identity field the form's own reconcile just
* inserted. Document order gets this right for a form and a button in the SAME
* template, but not for a `<form>` whose submitter arrives through a nested
* template, so the ordering is made explicit rather than relied upon.
* collected in. Nothing reads an enclosing form's boundness any more (#1307
* made a bound submitter self-sufficient), but the ordering still makes a
* form's RELEASE run before its submitters reconcile, so it is kept rather
* than churned. See the note in the body.
*
* @param {FormActionRecord[] | null} formActions
* @param {BoundPart[]} bound
Expand Down
Loading
Loading