Skip to content
2 changes: 1 addition & 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}>`. A quoted `action="${fn}"`, a `formaction=${fn}`, `action=${fn}` off a `<form>`, a bound form with `method="get"`, 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 submitter `<button formaction=${importedAction}>` / `<input type="submit" formaction=${importedAction}>` inside a bound form. Quoted bindings, non-submit controls, submitter `name` / `value` / `form` / static `formaction` attributes, `action=${fn}` off a `<form>`, a bound form with `method="get"`, and a non-action function all throw. A page has no `action` export, so a bare `<form method="post">` is a 405.

## Export Map

Expand Down
7 changes: 4 additions & 3 deletions .agents/skills/webjs/references/muscle-memory-gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server
html`<form action=${submitFeedback}><input name="email"></form>`;
```

That is the whole wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits the identity field. Writing `method="get"` on a bound form throws, because a GET form sends no body and the action could never run.
That is the whole wiring. The renderer omits the `action` attribute (so the form posts to the page's own url), supplies `method="post"` and an enctype, and emits the identity field. Writing `method="get"` on a bound form throws, because a GET form sends no body and the action could never run. A submitter binding is limited to `<button>` submitters and `<input type="submit">` controls inside that bound form.

**Only the bare, unquoted `action=${fn}` on a `<form>` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.

Expand All @@ -70,7 +70,7 @@ Do not go looking for the rule that decides when it folds. Export status, read c

So treat everything reachable from the action as exposed. That is the assumption the refusal is built on, it is the only one that holds across runtimes, and it is the only one that stays true when the transpiler changes.

The refusal covers the shape, not one spelling of it. A quoted `action="${fn}"` and the mixed `action="/x/${fn}"` are refused, because quoting turns a binding hole back into a plain attribute; so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. `formaction=` is refused everywhere. Attribute names fold case, so `ACTION=${fn}` on a `<form>` BINDS like the lowercase spelling, while `formAction=${fn}` on a button is refused like `formaction=`.
The refusal covers the shape, not one spelling of it. A quoted `action="${fn}"` and the mixed `action="/x/${fn}"` are refused, because quoting turns a binding hole back into a plain attribute; so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. Unsupported `formaction=` shapes are refused, including non-submit controls, duplicate holes, and submitters carrying `name`, `value`, `form`, or static `formaction` attributes. Attribute names fold case, so `ACTION=${fn}` on a `<form>` BINDS like the lowercase spelling, while quoted or otherwise unsupported `formAction=${fn}` shapes are refused.

**Commenting the form out does not disable the hole.** A comment is HTML, the interpolation is JavaScript, and the renderer emits a comment's holes raw, so a hole inside `<!-- ... -->` never reaches the binding branch and is stringified instead: `<!-- <form action=${createTodo}> -->` ships the whole action body with no throw and no log. Commenting out a WORKING binding is therefore not a way to disable it, it is a way to turn it into a leak. Delete the form or move it out of the template. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing.

Expand All @@ -83,7 +83,8 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
| `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
| `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=` anywhere | yes | not supported YET rather than impossible (tracked in #1207). Today, write one form per action, or bind one action and dispatch on a submit button's `name="intent"` |
| `formaction=${fn}` unquoted, on a submitter inside a bound form | **no, it BINDS** | per-submitter server action binding (#1207): emits the reserved identity on a `<button>` or `<input type="submit">` and server dispatches to last submitter in DOM order. The submitter cannot carry `name`, `value`, `form`, or static `formaction` attributes |
| `formaction=${fn}` on an UNBOUND form | yes | submitter action requires enclosing form to be bound (`<form action=${formAction}>`) so POST method and multipart encoding are set at form start |
| `.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 |
| `.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 |
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/optimistic-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ render() {

`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.

When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`). Each interactive control is its own tiny form, which is currently required rather than merely tidy: `formaction=${fn}` on a submit button is not supported yet (tracked in #1207). A form that genuinely needs several buttons binds ONE action and dispatches on a submit button's `name="intent"` inside it.
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons inside a bound form (#1207). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.

## Seed the list from the server for SSR plus optimistic

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/webjs/references/routing-and-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ Three responses that are not the happy path:

The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.

Refusals worth knowing: `formaction=${fn}` on a submit button is refused (write one form per action), a bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
Refusals worth knowing: `formaction=${fn}` is supported only on a submit button or `<input type="submit">` inside a bound form, and the submitter may not carry `name`, `value`, `form`, or a static `formaction` attribute. A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.

## Error, loading, and 404 boundaries

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, **

### `html` expression prefixes

`<div>${x}</div>` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `<form>` binds a server action (#1155: the ONE function-valued attribute hole, the attribute is omitted so the form posts to the page's own url, `method="post"` + an enctype are supplied, and a hidden `__webjs_action` identity field is emitted; a quoted `action="${fn}"`, a `formaction=${fn}`, or `action=${fn}` on any other tag is a render error, since stringifying a function would leak a server action's source); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event` and `.prop` on native elements.
`<div>${x}</div>` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `<form>` or `formaction=${importedAction}` on a submitter inside a bound form binds a server action (#1155, #1207: the function-valued attribute holes where the attribute is omitted/rewritten and a `__webjs_action` identity is emitted; submitters must be actual submit controls and cannot carry their own `name`, `value`, `form`, or static `formaction` attributes; a quoted `action="${fn}"` / `formaction="${fn}"` or `action=${fn}` on un-supported tags is a render error, since stringifying a function would leak a server action's source); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event` and `.prop` on native elements.

---

Expand Down Expand Up @@ -462,7 +462,7 @@ const result = await optimistic(liked, true, () => likePost(postId));

11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).

12. **A form that writes binds its action: `<form action=${importedAction}>`** (#1155). That is the only shape the renderer reads, and every near-miss throws rather than producing a form that posts nowhere: a quoted `action="${fn}"`, a `formaction=${fn}` on a submit button (write one form per action, #1207), `action=${fn}` on any tag other than `<form>`, a bound form declaring `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding on a bound form (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole on one form, a plain `action="/url"` attribute alongside the bound hole (SSR keeps it and the client drops it, so the no-JS submission would post somewhere else), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. WebJs supplies `method` and `enctype` only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` emits nothing so it is supplied, while `method=${null}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **The near-miss guarantee is form-level and stops at the submitter:** a button's `formmethod` / `formenctype` is seen by neither renderer, so `<form action=${fn}><button formenctype="text/plain">` submits fine with JS and is a bare 405 without it. That gap and per-button `formaction=${fn}` are both #1207.
12. **A form that writes binds its action: `<form action=${importedAction}>`, or a submitter `<button formaction=${importedAction}>` / `<input type="submit" formaction=${importedAction}>` inside a bound form** (#1155, #1207). That is the shape the renderer reads, and every invalid shape throws rather than producing a form that posts nowhere: a quoted `action="${fn}"` / `formaction="${fn}"`, `action=${fn}` on any tag other than `<form>`, a `formaction=${fn}` submitter on an unbound form, a non-submit control, a submitter carrying its own `name`, `value`, `form`, or static `formaction`, or an `<input type="image">`, a bound form or submitter declaring `method="get"` / `formmethod="get"` or an enctype the server cannot parse (`formenctype="text/plain"`), a `.method` / `.enctype` / `.encoding` PROPERTY binding on a bound form, a second action hole on one form or submitter, a plain `action="/url"` attribute alongside the bound hole, and a function that is not a `'use server'` export. WebJs supplies `method` and `enctype` only where your template supplies neither. Without JS, per-button action binding emits `<button name="__webjs_action" value="<hash>/<fn>">` and server `form-dispatch.js` takes the last `__webjs_action` entry in DOM order (submitter precedence). A page has no `action` export, so a bare `<form method="post">` is a `405`.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import { deleteTodo } from './delete-todo.server.ts';
// THIS action, and the submit button's own `name="intent"` says which mutation
// to run, which is how one form serves several buttons.
//
// Why an intent dispatcher rather than binding each button to its own action:
// `formaction=${fn}` on a submit button is not supported yet (tracked in
// #1207). So a form binds ONE action, and a form with several buttons
// dispatches on the intent here.
// Alternatively, per-button submitter server actions can be bound directly via
// `formaction=${action}` on <button> or <input type="submit"> (#1207).
//
// 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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// The interactive surface. Demonstrates: the WebComponent factory + reactive
// prop, the DECLARATIVE optimistic() API (instant update, auto-rollback), and
// progressive enhancement (each mutation is a <form> posting to the page action,
// progressive enhancement (each mutation is a <form action=${submitTodo}> bound to a server action,
// intercepted by JS for the optimistic path). All interactivity lives in a
// component; a page/layout cannot be interactive in its own markup.
//
Expand Down
Loading