@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.
- package/README.md +3 -1
- package/bin/webjs.js +437 -32
- package/lib/api-gallery.js +6 -7
- package/lib/app-name.js +208 -0
- package/lib/create.js +37 -9
- package/lib/doctor.js +566 -21
- package/package.json +2 -2
- package/templates/.agents/rules/workflow.md +9 -1
- package/templates/.agents/skills/webjs/SKILL.md +26 -11
- package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
- package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
- package/templates/.agents/skills/webjs/references/components.md +59 -2
- package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
- package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
- package/templates/.agents/skills/webjs/references/runtime.md +5 -1
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +80 -3
- package/templates/.agents/skills/webjs/references/typescript.md +71 -2
- package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
- package/templates/.github/pull_request_template.md +1 -0
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/AGENTS.md +31 -5
- package/templates/CONVENTIONS.md +4 -1
- package/templates/gallery/app/examples/layout.ts +2 -1
- package/templates/gallery/app/examples/todo/page.ts +3 -16
- package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
- package/templates/gallery/app/features/auth/signup/page.ts +4 -23
- package/templates/gallery/app/features/caching/page.ts +6 -6
- package/templates/gallery/app/features/file-storage/page.ts +8 -19
- package/templates/gallery/app/features/forms/page.ts +12 -38
- package/templates/gallery/app/features/layout.ts +6 -2
- package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
- package/templates/gallery/app/features/view-transitions/page.ts +1 -1
- package/templates/gallery/app/global-error.ts +7 -4
- package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
- package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
- package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
- package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
- package/templates/gallery/modules/gallery/nav.ts +1 -1
- package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
- package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
- package/templates/gallery/modules/todo/types.ts +15 -10
- package/templates/gallery/test/auth/auth.test.ts +31 -16
- package/templates/partials/agents-playbook-api.md +5 -0
- package/templates/partials/agents-playbook-fullstack.md +5 -0
- package/templates/scripts/clear-gallery.mjs +5 -4
- 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
|
|
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
|
-
###
|
|
46
|
+
### `<form action=${fn}>` binds, in exactly one shape
|
|
47
47
|
|
|
48
|
-
Next binds a Server Action with `<form action={createTodo}
|
|
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
|
-
|
|
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.
|
|
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}> -->`
|
|
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
|
|
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
|
|
77
|
-
|
|
|
78
|
-
|
|
|
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
|
|
81
|
-
| `?action=` | yes | never leaked, but
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
// submission
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
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
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
126
|
+
## The no-JS write path (a form-bound action)
|
|
126
127
|
|
|
127
|
-
A `page.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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>`
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|