@webjsdev/cli 0.10.50 → 0.10.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +3 -1
  2. package/bin/webjs.js +437 -32
  3. package/lib/api-gallery.js +6 -7
  4. package/lib/app-name.js +208 -0
  5. package/lib/create.js +37 -9
  6. package/lib/doctor.js +566 -21
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +26 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
  13. package/templates/.agents/skills/webjs/references/components.md +59 -2
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
  16. package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
  17. package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
  18. package/templates/.agents/skills/webjs/references/runtime.md +5 -1
  19. package/templates/.agents/skills/webjs/references/styling.md +1 -1
  20. package/templates/.agents/skills/webjs/references/testing.md +80 -3
  21. package/templates/.agents/skills/webjs/references/typescript.md +71 -2
  22. package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
  23. package/templates/.github/pull_request_template.md +1 -0
  24. package/templates/.github/workflows/ci.yml +13 -0
  25. package/templates/AGENTS.md +31 -5
  26. package/templates/CONVENTIONS.md +4 -1
  27. package/templates/gallery/app/examples/layout.ts +2 -1
  28. package/templates/gallery/app/examples/todo/page.ts +3 -16
  29. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  30. package/templates/gallery/app/features/auth/signup/page.ts +4 -23
  31. package/templates/gallery/app/features/caching/page.ts +6 -6
  32. package/templates/gallery/app/features/file-storage/page.ts +8 -19
  33. package/templates/gallery/app/features/forms/page.ts +12 -38
  34. package/templates/gallery/app/features/layout.ts +6 -2
  35. package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
  36. package/templates/gallery/app/features/view-transitions/page.ts +1 -1
  37. package/templates/gallery/app/global-error.ts +7 -4
  38. package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
  39. package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
  40. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  41. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  42. package/templates/gallery/modules/gallery/nav.ts +1 -1
  43. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  44. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
  45. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  46. package/templates/gallery/modules/todo/types.ts +15 -10
  47. package/templates/gallery/test/auth/auth.test.ts +31 -16
  48. package/templates/partials/agents-playbook-api.md +5 -0
  49. package/templates/partials/agents-playbook-fullstack.md +5 -0
  50. package/templates/scripts/clear-gallery.mjs +5 -4
  51. package/templates/test/hello/e2e/hello.test.ts +18 -1
@@ -27,7 +27,7 @@ export async function GET() { redirect('/login'); }
27
27
  export async function GET(req: Request) { return Response.redirect(new URL('/login', req.url), 303); }
28
28
  ```
29
29
 
30
- Do NOT throw `redirect()` from a page `action` to bounce a form POST either. The method-preserving 307 default re-POSTs the body and re-runs the mutation. Return an `ActionResult` with a `redirect` field instead (a 303 PRG), or throw only for a real external redirect.
30
+ Do NOT throw `redirect()` from a form-bound action to bounce a form POST either. The method-preserving 307 default re-POSTs the body and re-runs the mutation. Return an `ActionResult` with a `redirect` field instead (a 303 PRG), or throw only for a real external redirect.
31
31
 
32
32
  ### Reads are server actions, not `fetch()` in a Server Component
33
33
 
@@ -43,11 +43,44 @@ const users = await getUsers();
43
43
 
44
44
  There is no React `cache()`, `use()`, or `unstable_cache`. Caching is the `cache()` query helper, `export const revalidate` on a page, or `export const cache` on a GET action.
45
45
 
46
- ### A function in `<form action=${fn}>` is refused, not stringified
46
+ ### `<form action=${fn}>` binds, in exactly one shape
47
47
 
48
- Next binds a Server Action with `<form action={createTodo}>` and React serializes the binding into hidden fields. WebJs does not read that shape. A function interpolated into `action=` is a hard render error.
48
+ Next binds a Server Action with `<form action={createTodo}>`, and WebJs reads the same shape, so this muscle memory transfers. The mechanism underneath differs, and the difference is what the rest of this section is about: React serializes the binding (bound arguments included) into hidden fields, while WebJs emits ONE hidden field carrying the action's `<hash>/<fn>` identity and no arguments. A per-row action therefore takes its row id from a hidden input in the form, never from `action.bind(null, id)`.
49
49
 
50
- 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.
50
+ ```ts
51
+ import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
52
+ html`<form action=${submitFeedback}><input name="email"></form>`;
53
+ ```
54
+
55
+ 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.
56
+
57
+ **A form whose buttons run different actions binds each one on its submitter**, with the same unquoted spelling one level down:
58
+
59
+ ```js
60
+ html`<form action=${saveDraft}>
61
+ <input name="title">
62
+ <button>Save</button>
63
+ <button formaction=${publishPost}>Publish</button>
64
+ </form>`;
65
+ ```
66
+
67
+ The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
68
+
69
+ **A bound submitter is self-sufficient, so the enclosing form does not have to be bound** (#1307). The renderer puts `formmethod="post"` and `formenctype="multipart/form-data"` on the button itself, alongside the identity, exactly as React emits `formMethod` and `formEncType` on a button carrying a function `formAction`. 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:
70
+
71
+ ```js
72
+ // Every button below submits a POST its action can read, with JS on or off.
73
+ html`<form>
74
+ <button formaction=${saveDraft}>Save</button>
75
+ <button formaction=${publishPost}>Publish</button>
76
+ </form>`;
77
+ ```
78
+
79
+ One consequence worth knowing rather than discovering: no `formaction` url is emitted (an empty one is an HTML conformance error), so the submission targets whatever the FORM targets. A form declaring its own `action="/x"` sends its buttons to `/x`, which is native precedence. The action still runs if `/x` is a PAGE route, because the identity travels in the body, but against a `route.ts` or another origin the identity is ignored and nothing runs. In dev the client logs a warning at submit time naming the url. Leaving the form's `action` off, the ordinary shape, keeps the submission on the current page.
80
+
81
+ The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
82
+
83
+ **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.
51
84
 
52
85
  What escapes is the SOURCE the runtime reports, and how much that includes depends on the runtime. The body always goes: your query shapes, your table and column names, your internal paths, and any credential written inline.
53
86
 
@@ -63,22 +96,37 @@ Do not go looking for the rule that decides when it folds. Export status, read c
63
96
 
64
97
  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.
65
98
 
66
- The refusal covers the shape, not one spelling of it. Every hole form is refused (`action=${fn}`, `action="${fn}"`, the mixed `action="/x/${fn}"`), and so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. Casing does not help either: `formAction=` and `ACTION=` fold to the same rule.
99
+ 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.
67
100
 
68
- **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 `<!-- <form action=${createTodo}> -->` still ships the whole action body with no throw and no log. Delete the binding instead of commenting around it. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing.
101
+ **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.
69
102
 
70
- It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`<div>${fn}</div>`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are refused today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies.
103
+ It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`<div>${fn}</div>`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are claimed today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies.
71
104
 
72
- The refused and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak:
105
+ The bound, refused, and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak:
73
106
 
74
107
  | Written as | Refused? | Why |
75
108
  |---|---|---|
76
- | `action=` / `formaction=` | yes | ordinary attribute, stringified into the HTML |
77
- | `.action=` on a native form | yes | the property reflects, so the source lands in the DOM on the client |
78
- | `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects |
109
+ | `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 |
110
+ | `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 |
111
+ | `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
112
+ | `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 |
113
+ | `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 |
114
+ | `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 |
115
+ | `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 |
116
+ | `formmethod` / `formenctype` the BOUND submitter cannot submit with (`get`, `PATCH`, `text/plain`, `dialog`, a padded `" post "`) | yes | a same-element contradiction: you attached an action to THIS button and told THIS button to submit in a way that action could never read. The renderer supplies `formmethod="post"` and the enctype only where you supplied neither, so your own parseable value always wins |
117
+ | `formmethod="get"` / `formenctype="text/plain"` on a PLAIN submitter inside a bound form | **no** | #1307 reversed this. Native HTML says the submitter's override wins, you typed it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time if the submission is carrying an identity it cannot deliver |
118
+ | `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
119
+ | 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 |
120
+ | `.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 |
121
+ | `.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 |
122
+ | 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 |
123
+ | 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 |
124
+ | `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway |
125
+ | `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up |
126
+ | `.formAction=` / `.formMethod=` / `.formEnctype=` on a BOUND submitter | yes | same reason, that is where they reflect: SSR drops the property and the browser applies it, so the button would submit one way with JS and another way without. On a PLAIN button they are ordinary native properties and are left alone |
79
127
  | `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
80
- | `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute; a function is a legitimate value. Holds for a PLAIN prop: one declared `reflect: true` writes `String(value)` to the attribute on a path outside these commit sites, so it still emits the source |
81
- | `?action=` | yes | never leaked, but it is meaningless, so it is refused rather than silently emitting a bare `action=""` |
128
+ | `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
129
+ | `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
82
130
  | `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
83
131
  | `@action="${fn}"` quoted | yes | quoting makes it an ordinary attribute again, so it leaks |
84
132
 
@@ -94,15 +142,17 @@ Two things that "renders it empty" understates, both worth knowing before you go
94
142
  - **On a route with a `loading.{js,ts}`, there is no log line either.** That wraps the page in a `Suspense` boundary, so the page body renders AFTER the 200 and the shell have been flushed, and a boundary that throws there is currently swallowed with no server log, no `onError`, and no error boundary. The visitor gets chrome and an empty body; with JS off the skeleton simply stays. That silence is a known framework gap rather than intended behaviour, so do not read the missing log line as evidence the render succeeded.
95
143
 
96
144
  ```ts
97
- // WRONG: throws at render; it would have leaked the action's body.
98
145
  import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
99
- html`<form method="post" action=${submitFeedback}>`;
100
- // RIGHT: omit action entirely to post to the page's own url, and handle the
101
- // submission in that page's `action` export.
102
- html`<form method="post">`;
146
+ // RIGHT: bind the imported action. method and enctype are supplied.
147
+ html`<form action=${submitFeedback}><input name="email"></form>`;
148
+ // WRONG: a bare form binds nothing, so the submission is a 405. There is no
149
+ // page `action` export to catch it.
150
+ html`<form method="post"><input name="email"></form>`;
103
151
  ```
104
152
 
105
- A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged. Other attributes keep their existing stringify behaviour; only `action` and `formaction` are claimed.
153
+ A hole that resolves to `null` is NOT the same as omitting the attribute. `method=${null}` renders `method=""`, which cannot submit and is refused; `?method=${false}` emits nothing at all, so WebJs supplies `method="post"` and the form works. Both leave no attribute in the DOM, which is exactly why the check reads your template rather than the rendered element.
154
+
155
+ A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
106
156
 
107
157
  ### `params` and `searchParams` are awaitable AND synchronously readable
108
158
 
@@ -134,6 +184,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa
134
184
 
135
185
  Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
136
186
 
187
+ ### No `<ScrollRestoration>`, and no scroll restore of your own
188
+
189
+ Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
190
+
191
+ This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.
192
+
137
193
  ### Server-only code: the `.server.ts` boundary, not a `server-only` package
138
194
 
139
195
  Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
@@ -17,6 +17,8 @@ Read this when a mutation should feel instant, when the client can predict the r
17
17
 
18
18
  `optimistic(host, { source, update })` returns an `OptimisticState<State, Action>` with a `.value` getter and an `.add(payload, promise?)` method. The `source` reads the authoritative state (usually a reactive prop). The `update` reducer transforms that state with each payload. Calling `.add()` pushes an update and schedules a re-render, so `.value` reflects the optimistic state on the next paint.
19
19
 
20
+ **The reducer must be pure.** `.value` re-folds the whole queue on every read, so `update` runs again on each render rather than once per `.add()`. Anything it MINTS is therefore minted per render. Mint a temp id in the handler and pass it in the payload. A `crypto.randomUUID()` inside the reducer gives the pending row a different id on every read, which breaks a keyed list and anything else treating that id as stable.
21
+
20
22
  ```ts
21
23
  import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
22
24
  import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
@@ -26,12 +28,18 @@ class TodoList extends WebComponent({
26
28
  }) {
27
29
  private optimisticTodos = optimistic(this, {
28
30
  source: () => this.todos,
29
- update: (state, title: string) => [
31
+ // KEEP THIS REDUCER PURE. `.value` re-folds every queued update on EVERY
32
+ // read, not once per `.add()`, so anything minted in here is minted again
33
+ // on each render. A `crypto.randomUUID()` here would hand the pending row
34
+ // a NEW id per render, and a keyed list (`repeat(todos, t => t.id, ...)`)
35
+ // would tear the row down and rebuild it every update, losing focus, any
36
+ // in-progress transition, and DOM state. Mint the temp id in the handler
37
+ // and carry it in the payload, so it is stable for the life of the row.
38
+ update: (state, add: { tempId: string; title: string }) => [
30
39
  ...state,
31
- // A client-only placeholder id for the pending row; the real id arrives
32
- // from the server on reconcile, so the `as any` cast on this temp row is
33
- // fine (the row is dropped when the promise settles).
34
- { id: crypto.randomUUID() as any, title, completed: false, pending: true },
40
+ // `createdAt` is rebuilt per read too. That is tolerable only because
41
+ // nothing keys on it; put it in the payload as well if anything does.
42
+ { id: add.tempId, title: add.title, completed: false, createdAt: new Date(), pending: true },
35
43
  ],
36
44
  });
37
45
 
@@ -41,14 +49,22 @@ class TodoList extends WebComponent({
41
49
  if (!title) return;
42
50
  (e.target as HTMLFormElement).reset();
43
51
 
52
+ // Minted ONCE here, not in the reducer. `Todo['id']` is a string (a uuid
53
+ // primary key), so no cast is needed. Against an auto-increment integer
54
+ // id there is no honest client-side value, so model the temp row instead
55
+ // (an optional id, or a `tempId` the row keys on) rather than casting.
56
+ const tempId = crypto.randomUUID();
44
57
  const promise = createTodo({ title });
45
- this.optimisticTodos.add(title, promise);
58
+ this.optimisticTodos.add({ tempId, title }, promise);
46
59
 
47
60
  const result = await promise;
48
61
  if (result.success && result.data) {
49
- // Reconcile: the optimistic entry has ALREADY auto-released (the promise
50
- // settled), so `this.todos` holds only confirmed rows here. Append the
51
- // server's canonical row, matching the order the `update` reducer used.
62
+ // Reconcile: the optimistic row is never written to `this.todos`. The
63
+ // overlay holds only the PAYLOAD, and `update` rebuilds the row from it
64
+ // on each `.value` read, so this prop holds only confirmed rows. Append
65
+ // the server's canonical row, matching the order the `update` reducer
66
+ // used. (The overlay entry auto-released when the promise settled, so
67
+ // `.value` does not double-count it on the next paint.)
52
68
  this.todos = [...this.todos, result.data];
53
69
  }
54
70
  }
@@ -62,20 +78,23 @@ class TodoList extends WebComponent({
62
78
  TodoList.register('todo-list');
63
79
  ```
64
80
 
65
- **Auto-release is the whole point.** Pass the action's promise as the second argument to `.add(payload, promise)`, and the update auto-releases the moment that promise settles (resolve OR reject). It uses `.finally()`, with a `.then()` fallback for thenables that lack `.finally`. No try-catch, no manual rollback, no temp-ID bookkeeping. On success you reconcile the authoritative row from `result.data` (as above); on failure the optimistic entry simply drops when the promise rejects.
81
+ **Auto-release is the whole point.** Pass the action's promise as the second argument to `.add(payload, promise)`, and the update auto-releases the moment that promise settles (resolve OR reject). It uses `.finally()`, with a `.then()` fallback for thenables that lack `.finally`. No try-catch, no manual rollback, no reconciling a temp id against the real one. The handler mints a temp id for the pending row, but nothing tracks it afterwards: the overlay drops whole when the promise settles, and the authoritative row arrives from `result.data`. On failure the optimistic entry simply drops when the promise rejects.
66
82
 
67
83
  - Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
68
84
  - When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
69
85
 
70
86
  ### Author the optimistic mutation as a degrade-first form
71
87
 
72
- Wrap the mutation in a REAL `<form method="post" action="">` posting to the page's own URL, then intercept it for the optimistic path. One form then serves both: with JS off the browser submits to the page `action` (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
88
+ Wrap the mutation in a REAL `<form>` bound to the action, then intercept it for the optimistic path. One form serves both: with JS off the browser submits and the server dispatches to that action (the no-JS write path, see `routing-and-pages.md`), and with JS on `@submit` calls `e.preventDefault()` and runs the optimistic path. That is the progressive-enhancement contract, not a fetch-only handler.
89
+
90
+ The SAME imported function is the form binding and the optimistic path's callee, which is what makes a degrade-first form cheap to write: there is no second wiring to keep in step.
73
91
 
74
92
  ```ts
93
+ import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
94
+
75
95
  render() {
76
96
  return html`
77
- <form method="post" action="" @submit=${this.handleSubmit}>
78
- <input type="hidden" name="intent" value="create"> <!-- one page action dispatches on intent -->
97
+ <form action=${createTodo} @submit=${this.handleSubmit}>
79
98
  <input name="title" required>
80
99
  <button>Add</button>
81
100
  </form>
@@ -83,7 +102,9 @@ render() {
83
102
  }
84
103
  ```
85
104
 
86
- When a page owns SEVERAL mutations (create, toggle, delete), give each form a hidden `intent` field and let the single page `action` dispatch on it. Each interactive control (a toggle button, a delete button) is also its own tiny form so it still works with JS off.
105
+ `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.
106
+
107
+ 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 (#1207, #1307: a bound submitter carries its own submission, so the enclosing form need not be bound). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
87
108
 
88
109
  ## Seed the list from the server for SSR plus optimistic
89
110
 
@@ -7,7 +7,7 @@
7
7
  - `route.ts` HTTP handlers and `middleware.ts`, the route-handler toolkit (`json` / `readBody` / `clientIp` / the no-arg accessors), and calling one from the client with `richFetch`
8
8
  - `metadata` and `generateMetadata` (folded in here), including image metadata routes that return a `Response`
9
9
  - Control-flow throws: `notFound()`, `redirect()`, `forbidden()`, `unauthorized()`
10
- - The no-JS page `action` write path
10
+ - The no-JS write path: a `<form>` bound to a `'use server'` action
11
11
  - Boundaries: `error.ts`, `loading.ts`, `not-found.ts`, `forbidden.ts`, `unauthorized.ts`, and the two root-only ones
12
12
 
13
13
  Read this when a task touches the route contract, a URL, a `<head>` tag, a redirect, a 404, or a form POST that a page owns. Sibling refs: `components.md` (anything interactive), `data-and-actions.md` (server actions, queries, validation, the `ActionResult` envelope), `auth-and-sessions.md` (`forbidden()` / `unauthorized()` flows).
@@ -32,7 +32,7 @@ export default function About() {
32
32
 
33
33
  `params` and `searchParams` are awaitable AND synchronously readable (`params.id` and `await params` both work, Next.js 15/16 parity). Throw `notFound()` or `redirect(url)` to short-circuit. Reach data through a `.server.ts` query; never import the DB driver into a page.
34
34
 
35
- Optional named exports: `metadata` / `generateMetadata` (below), `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor), and `export const action` (the write path, below).
35
+ Optional named exports: `metadata` / `generateMetadata` (below) and `export const revalidate` (seconds, opts into the HTML response cache, only for a page identical for every visitor). There is no `action` export; the write path is a form bound to a `'use server'` action (below).
36
36
 
37
37
  ## Layouts (`app/**/layout.ts`)
38
38
 
@@ -41,7 +41,8 @@ The default export receives `{ children, params, searchParams, url }` and must e
41
41
  ```ts
42
42
  // app/layout.ts (root)
43
43
  import { html } from '@webjsdev/core';
44
- export default function RootLayout({ children }: { children: unknown }) {
44
+ import type { LayoutProps } from '@webjsdev/core';
45
+ export default function RootLayout({ children }: LayoutProps) {
45
46
  return html`<html lang="en"><head></head><body>${children}</body></html>`;
46
47
  }
47
48
  ```
@@ -114,24 +115,26 @@ Common fields: `title` (string or `{ template, default, absolute }`), `descripti
114
115
 
115
116
  ## Control-flow throws
116
117
 
117
- From `@webjsdev/core`: throw to short-circuit a page / layout render or a page `action`.
118
+ From `@webjsdev/core`: throw to short-circuit a page / layout render or a form-bound action.
118
119
 
119
120
  - `notFound()` renders the nearest `not-found.ts` (nearest wins from the throwing chain).
120
- - `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a page action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
121
+ - `redirect(url[, status])`. The no-status default is convention-picked at the catch site: `302` for a GET page render, `307` (method-preserving) for a form-bound action. Override with `redirect(url, 308)` or `redirect(url, { status })`.
121
122
  - `forbidden()` renders the nearest `forbidden.ts` (authenticated user lacking permission); `unauthorized()` renders the nearest `unauthorized.ts` (request not authenticated).
122
123
 
123
124
  None of these belong in a `route.ts` (return a `Response` there). Inside a `'use server'` RPC action, return an `ActionResult` for an auth failure rather than throwing (`data-and-actions.md`).
124
125
 
125
- ## The no-JS write path (a page `action`)
126
+ ## The no-JS write path (a form-bound action)
126
127
 
127
- A `page.ts` may export an `action` beside its default render function. A non-GET/HEAD submission to the page's own URL runs it, wrapped in the page's segment middleware. It works with JS off; with JS on the client router applies the response in place.
128
+ A page imports a `'use server'` action and binds it into the form: `<form action=${sendMessage}>`. There is no page `action` export; the binding IS the 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 a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity. A non-GET/HEAD submission carrying that identity runs the action, wrapped in the segment middleware of the URL it was submitted to AND the action's own declared `middleware`. It works with JS off; with JS on the client router posts the same body to the same url and applies the response in place.
128
129
 
129
- ```ts
130
- // app/contact/page.ts
131
- import { html } from '@webjsdev/core';
132
- import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
130
+ **Do not treat the page's segment middleware as the action's authorization gate.** An identity names the action, not the page that rendered it, so the same action is reachable by submitting to any page path (and, being a `'use server'` export, at its RPC endpoint too, where no segment middleware runs at all). That is the same reachability every server action has always had, and the removed page `action` export was the one shape where the URL really did scope the handler. Authorize the action itself: an `export const middleware = [requireAdmin]` on the action, or a check in its body, both of which run on either transport.
133
131
 
134
- export async function action({ formData }: { formData: FormData }) {
132
+ A form-bound action always receives the `FormData` as its first argument, which is where it differs from the same action called over RPC. `validate` is the typing seam: it receives that `FormData`, and its transform-return becomes the action's typed input.
133
+
134
+ ```ts
135
+ // modules/contact/actions/send-message.server.ts
136
+ 'use server';
137
+ export async function sendMessage(formData: FormData) {
135
138
  const email = String(formData.get('email') || '').trim();
136
139
  const body = String(formData.get('body') || '').trim();
137
140
  const values = { email, body };
@@ -139,9 +142,15 @@ export async function action({ formData }: { formData: FormData }) {
139
142
  if (!email.includes('@')) fieldErrors.email = 'Enter a valid email';
140
143
  if (body.length < 10) fieldErrors.body = 'Message is too short';
141
144
  if (Object.keys(fieldErrors).length) return { success: false, fieldErrors, values, status: 422 };
142
- await sendMessage({ email, body });
145
+ await deliver({ email, body });
143
146
  return { success: true, redirect: '/contact/thanks' };
144
147
  }
148
+ ```
149
+
150
+ ```ts
151
+ // app/contact/page.ts
152
+ import { html } from '@webjsdev/core';
153
+ import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
145
154
 
146
155
  export default function Contact({ actionData }: {
147
156
  actionData?: { fieldErrors?: Record<string, string>; values?: Record<string, string> };
@@ -149,7 +158,7 @@ export default function Contact({ actionData }: {
149
158
  const errors = actionData?.fieldErrors || {};
150
159
  const values = actionData?.values || {};
151
160
  return html`
152
- <form method="POST" class="flex flex-col gap-3">
161
+ <form action=${sendMessage} class="flex flex-col gap-3">
153
162
  <input name="email" type="email" value=${values.email || ''} required>
154
163
  ${errors.email ? html`<p class="text-sm text-red-600">${errors.email}</p>` : ''}
155
164
  <textarea name="body" required>${values.body || ''}</textarea>
@@ -160,7 +169,17 @@ export default function Contact({ actionData }: {
160
169
  }
161
170
  ```
162
171
 
163
- How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a `<form>` + page action over `fetch` in a `@click` for any write a form can express.
172
+ How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `<form>` over `fetch` in a `@click` for any write a form can express.
173
+
174
+ Three responses that are not the happy path:
175
+
176
+ - **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `<form method="post">` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url.
177
+ - **An identity whose hash no longer resolves re-renders at `422`** with a resubmit message and the submitted values on `actionData`. That is a form held open across a deploy; a 404 would discard what was typed and a silent no-op would show success for a write that never happened.
178
+ - **A bound action declaring `export const method = 'GET'` is a `405`.** A GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. `webjs check`'s `form-action-not-a-get-action` catches it at edit time.
179
+
180
+ 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.
181
+
182
+ Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). 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.
164
183
 
165
184
  ## Error, loading, and 404 boundaries
166
185
 
@@ -41,7 +41,11 @@ The 103 Early Hints gap costs only a small first-load latency edge where an edge
41
41
 
42
42
  Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site.
43
43
 
44
- Two limits worth knowing. `WEBJS_NO_TRUST_PROXY=1` stops the URL rewrite (and the HSTS scheme check) from trusting the headers when the container is directly exposed, but it is not a global switch: the CSRF host check still reads `X-Forwarded-Host` regardless. And this rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP.
44
+ `WEBJS_NO_TRUST_PROXY=1` is the global switch for "nothing trusted sits in front of this container, stop believing these headers". It is read in ONE place and is ABSOLUTE across every forwarded-header read: the URL rewrite, the HSTS scheme check, the CSRF host resolution, and `clientIp` (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`) alike. So `rateLimit({ trustProxy: true })` is inert while the flag is set, falling back to the framework-stamped peer address (its default with no option) and warning once per process. The two CAN be set in contradiction, and the direction rule is what resolves it: the flag only ever SUBTRACTS trust and the per-call option can only add it, so the flag wins. Setting both is a misconfiguration that buckets every visitor behind the proxy onto one key, which is what the warning is for. It used to miss the CSRF host, which read `X-Forwarded-Host` regardless; that gap closed in #1104. Setting it on a genuinely proxied deploy is a misconfiguration, and it now shows up as one, since the legacy no-`Sec-Fetch-Site` CSRF fallback compares `Origin` against the raw `Host` and rejects a legitimate cross-host request. The primary `Sec-Fetch-Site` path, which nearly every real browser request takes, never consults the host at all and is unaffected either way.
45
+
46
+ One limit worth knowing: the rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP.
47
+
48
+ **Anything SHARED that you derive from `ctx.url` must be keyed by the origin.** Neither Cloudflare nor Railway strips a client-supplied `X-Forwarded-Host`, they forward it, so a hostile value reaches the container through the proxy rather than around it. That is fine for a per-request response (the attacker only poisons their own), and a real problem the moment a response is shared. WebJs handles the cache it owns: the server HTML response cache (`export const revalidate`) folds `url.origin` into every cache key (#1097), so a poisoned body is unreachable from any origin the attacker does not already control. Apply the same rule to anything you cache yourself off `ctx.url`, and note the rule reaches caches WebJs does not own either. A page served `Cache-Control: public` is stored by a CDN under the CDN's OWN key, which does not include `X-Forwarded-Host`, so keying the server cache cannot protect that copy. Derive nothing origin-dependent into a page you make publicly cacheable, or pin the origin from config rather than from `ctx.url`.
45
49
 
46
50
  ## Scaffolding a Bun app
47
51
 
@@ -70,7 +70,7 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc
70
70
 
71
71
  ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
72
72
 
73
- An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
74
 
75
75
  ```ts
76
76
  // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
@@ -24,6 +24,35 @@ Feature folders are primary, and the test kind is a subfolder inside the feature
24
24
 
25
25
  Assert only on what the layer needs. A block that inspects only the HTTP response, the SSR HTML string, headers, or the importmap does NOT need a browser. Keep in the browser suite only blocks that genuinely need a DOM (live state via `page.evaluate`, hydration, client-router nav, slots, view transitions, streaming into the DOM, custom-element upgrade).
26
26
 
27
+ ### A browser test that clicks a real link or submits a real form MUST cancel the default
28
+
29
+ web-test-runner aborts the whole SESSION, not one file, when the page navigates. So a test that clicks a real `<a href>` or submits a real `<form>` is a single point of failure for every browser test file you have: whenever the client router loses the race to intercept, the browser performs the real navigation and the run dies reporting `0 failed` and then exiting non-zero, which reads as an infrastructure blip rather than a test problem. Cancel the default so an interception gap fails ONE test on its own assertion.
30
+
31
+ Register the canceling listener on `window` in the BUBBLE phase. That is the last step of the propagation path, so it runs after the router's own document-level listeners, and `preventDefault()` still cancels the default action because that action runs only once dispatch completes.
32
+
33
+ **Never use the capture phase.** Capture sets `defaultPrevented` before the router ever sees the event, and the router returns immediately on that flag (the same guard that lets a component's `@click` opt out). Every guarded router test then passes while testing nothing. Capture is correct only when suppressing the router is the actual goal, for a test that exercises a menu or a drawer rather than navigation; say so in a comment when you do it, because it looks identical to the mistake. Do not reach for `stopPropagation` either, which hides the click from the router and turns the assertion into a tautology.
34
+
35
+ Cancel a form on its `submit` event, not on the submit control's `click`. The form's default action fires on submit, so canceling the click stops the form from ever submitting and the router never sees it.
36
+
37
+ Resolve the anchor from `e.composedPath()`, not `e.target.closest('a[href]')`. The listener is on `window`, so a click inside a shadow root (a `static shadow = true` component) arrives retargeted to the host, and `closest()` walks only light-tree ancestors and never finds the link, which fails open exactly where the router itself handles the case. A pure-fragment `href="#x"` link needs no guard at all, since it never navigates the page away.
38
+
39
+ There is a SECOND channel a click guard cannot reach: the router assigns `location.href` when it degrades a soft navigation, and `preventDefault` does not cancel a script assignment. Do not try to intercept `location.href` itself, which is non-configurable on Chromium, Firefox, and WebKit alike, so its setter cannot be redefined on any of them. Listen for `webjs:navigation-fallback` on `document` and assert none fired; its `cause` is the diagnosis.
40
+
41
+ It is a few lines, so write it in your suite's setup and detach it in teardown:
42
+
43
+ ```js
44
+ const onClick = (e) => {
45
+ for (const el of e.composedPath()) {
46
+ if (el instanceof HTMLAnchorElement && el.hasAttribute('href')) { e.preventDefault(); return; }
47
+ }
48
+ };
49
+ const onSubmit = (e) => e.preventDefault();
50
+ window.addEventListener('click', onClick); // bubble, never capture
51
+ window.addEventListener('submit', onSubmit);
52
+ ```
53
+
54
+ (The WebJs framework repo keeps its own copy of exactly this in one shared module, `test/browser-nav-guard.js`, whose `installNavGuard()` returns `{ fallbacks, hardNavigations, remove }`. That module is framework-repo infrastructure and is not part of a scaffolded app.)
55
+
27
56
  ## App runners (`webjs test`)
28
57
 
29
58
  ```sh
@@ -48,11 +77,11 @@ test/
48
77
 
49
78
  ## The `handle()` harness (`@webjsdev/server/testing`)
50
79
 
51
- `createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, page actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
80
+ `createRequestHandler({ appDir }).handle(request)` drives the FULL request pipeline (middleware, routing, SSR, form-dispatched actions, server-action RPC, auth, CSRF) and returns a native `Response`. It is the same entry the framework's own suite uses, so the most realistic way to test an app is to fire a `Request` through it and assert on the `Response`, with no spawned process and no network. `@webjsdev/server/testing` ships thin builders over that `handle()`, each a few lines over native `Request` / `Response` that reuse the REAL cookie names, header names, and wire serializer. For a browser test that needs to drive the app in a real DOM, `createBrowserTestHandler()` from `@webjsdev/server/testing` exposes the same `handle()` pipeline to the WTR Chromium session.
52
81
 
53
82
  ```js
54
83
  import { createRequestHandler } from '@webjsdev/server';
55
- import { testRequest, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
84
+ import { testRequest, submitForm, invokeActionForTest, rawActionRequest, loginAndGetCookies, withSessionCookie }
56
85
  from '@webjsdev/server/testing';
57
86
 
58
87
  const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
@@ -77,6 +106,28 @@ const dash = await testRequest(app.handle, '/dashboard', withSessionCookie({}, c
77
106
  assert.equal(dash.status, 200);
78
107
  ```
79
108
 
109
+ ### `submitForm`: the no-JS write path
110
+
111
+ Use it for any page whose form binds an action (`<form action=${myAction}>`). A bound form carries a hidden `__webjs_action` field holding the action's identity, and that field is what tells the dispatcher which action to run, so a hand-written `POST` that omits it is not a form submission at all and is answered **405**. `submitForm` renders the page, reuses the identity the server put there, and posts it back, which is exactly what a browser with JS off does.
112
+
113
+ ```js
114
+ import { submitForm } from '@webjsdev/server/testing';
115
+
116
+ // success is a 303 PRG
117
+ const ok = await submitForm(app.handle, '/signup', { name: 'Ada', email: 'ada@example.com', password: 'hunter2' });
118
+ assert.equal(ok.status, 303);
119
+ assert.equal(ok.headers.get('location'), '/dashboard');
120
+
121
+ // a failure result re-renders the SAME page at 422 with the result on actionData
122
+ const bad = await submitForm(app.handle, '/signup', { email: 'not-an-email' });
123
+ assert.equal(bad.status, 422);
124
+ assert.match(await bad.text(), /Enter a valid email/);
125
+ ```
126
+
127
+ Options: `cookies` (submit as a logged-in user, paired with `loginAndGetCookies`), `match` (a string or RegExp the form's markup must contain, for a page with several forms), `index` (pick by position instead), `submitPath` (render one page, submit to another), and `headers`.
128
+
129
+ Do not hand-roll the identity scrape. When it is wrong the symptom is a 405, an assertion fails, and a surrounding `catch` reports it as a database that was never migrated, so the test goes quiet rather than red.
130
+
80
131
  ### `invokeActionForTest`: round-trip an action through the REAL endpoint
81
132
 
82
133
  ```js
@@ -112,9 +163,35 @@ WebJs runs on Node 24+ or Bun. The Node suite is the source of truth; an additiv
112
163
 
113
164
  If your app targets Bun, Bun parity is part of the definition of done. A change to a runtime-sensitive surface (the serializer, the `node:http` vs `Bun.serve` listener and request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) is NOT done until you also run your suite under the Bun runtime (needs `bun` installed) and add a cross-runtime assertion for the touched surface.
114
165
 
166
+ ### Writing a plain proof script that boots a server
167
+
168
+ A cross-runtime proof is often a plain assert script rather than a test file, so the SAME file runs under `node script.mjs` and `bun script.mjs`. If it boots a real server through `startServer`, know what carries its verdict: the process EXIT CODE, since nothing is collecting results for you. WebJs makes that code trustworthy: a fatal shutdown, which is where a failed top-level assertion lands on both runtimes, exits 1, and so does a drain that rejects or is still hanging at the 10s deadline. The process exits 0 in one case only, an operator-requested stop that drained cleanly with no crash on the way out, so a proof script that finishes green really did finish green. Two habits keep it that way:
169
+
170
+ - **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
171
+ - **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
172
+
173
+ ## Proving display-only elision did not break anything
174
+
175
+ WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
176
+
177
+ ```sh
178
+ webjs elision --verify
179
+ ```
180
+
181
+ renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
182
+
183
+ That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
184
+
185
+ ```sh
186
+ WEBJS_ELIDE=1 npm run test:e2e
187
+ WEBJS_ELIDE=0 npm run test:e2e
188
+ ```
189
+
190
+ A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
191
+
115
192
  ## Convention validation (`webjs check`)
116
193
 
117
- `npm run check` is the correctness validator. Every rule catches code that is wrong to ship (a crash, a security leak, a type-strip failure), plus the `no-scaffold-placeholder` sentinel for unreplaced scaffold content. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
194
+ `npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
118
195
 
119
196
  ## What NOT to do
120
197