@webjsdev/mcp 0.1.18 → 0.1.20

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.
@@ -1,362 +0,0 @@
1
- # Muscle-Memory Gotchas
2
-
3
- ## What This Covers
4
-
5
- - The Next.js patterns that LOOK right in WebJs but break, because WebJs borrows Next's file-based routing shape but not its execution model (no RSC, no `'use client'` split): `redirect()` in a route handler, `fetch()` in a page, `<Link>`, `NEXT_PUBLIC_`, `await params`.
6
- - The Lit patterns that break WebJs SSR, reactivity, or event handling, because WebJs is HTML-first (real HTML first paint, JS opt-in per behaviour) not JS-first: `static properties` / the `@property()` decorator, class-field initializers, browser globals in `render()`, fetching in `connectedCallback`, passing a method straight to an `@event` binding, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
7
- - The WebJs-shaped fix for each, with short code.
8
-
9
- Read this when a pattern feels familiar from Next.js or Lit but you are not sure it transfers. For the component runtime see `components.md`; for the routing surface see `routing-and-pages.md`. The one difference underneath everything: pages and layouts render server-only and never hydrate, and the one client boundary is a `WebComponent` custom element.
10
-
11
- ---
12
-
13
- ## Coming from Next.js
14
-
15
- ### `'use client'` does nothing; `'use server'` is a file boundary, not a component annotation
16
-
17
- There is no RSC render tree and no server/client component split. Interactivity lives in a `WebComponent` island that hydrates per element. A page or layout cannot be interactive in its own markup (an `@click` in a page template is dropped at SSR). `'use server'` is real, but it is the RPC plus source-protection directive at the top of a `*.server.ts` file, not a component annotation. Apply it to an action file, never to a component or page.
18
-
19
- ### `redirect()` throws, and it is illegal in a route handler
20
-
21
- In Next, `redirect()` works in Server Components, Actions, and Route Handlers alike. In WebJs, `redirect()` and `notFound()` throw a control-flow sentinel that the SSR page pipeline and the action pipeline catch. They are valid in page functions, layouts, and server actions. They are NOT valid in a `route.ts` handler, where the throw goes uncaught and returns a 500 (the `no-redirect-in-api-route` check flags this).
22
-
23
- ```ts
24
- // route.ts WRONG: redirect() is uncaught here.
25
- export async function GET() { redirect('/login'); }
26
- // route.ts RIGHT: return a real redirect Response.
27
- export async function GET(req: Request) { return Response.redirect(new URL('/login', req.url), 303); }
28
- ```
29
-
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
-
32
- ### Reads are server actions, not `fetch()` in a Server Component
33
-
34
- Next fetches by calling `fetch()` or an ORM directly inside an async Server Component. WebJs has no Server Components, so fetch server data in the page function (server-only) and pass it down, or fetch in a component via an async `render()` (the resolved data is in the first paint), or a `'use server'` GET action.
35
-
36
- ```ts
37
- // WRONG: hand-written fetch to your own endpoint.
38
- const res = await fetch('/api/users');
39
- // RIGHT: importing a 'use server' action IS the API (the import becomes an RPC stub).
40
- import { getUsers } from '#modules/users/queries/get-users.server.ts';
41
- const users = await getUsers();
42
- ```
43
-
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
-
46
- ### `<form action=${fn}>` binds, in exactly one shape
47
-
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
-
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.
84
-
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.
86
-
87
- Whether an OUTER value goes with it is not something to rely on either way. `Function.prototype.toString` returns source text, so on Node a module-scope `const` the body reads appears as its identifier. Bun transpiles the module before the engine sees it and can fold that literal into the body, so the same action reports the VALUE:
88
-
89
- ```
90
- // const VENDOR_API_KEY = 'sk_live_…'; then used as `Bearer ${VENDOR_API_KEY}`
91
- node 26 Authorization: `Bearer ${VENDOR_API_KEY}` identifier only
92
- bun 1.3 Authorization: "Bearer sk_live_…" the key itself
93
- ```
94
-
95
- Do not go looking for the rule that decides when it folds. Export status, read count, declaration position, and whether the module has an import have each been measured as the deciding factor and each produced a counterexample on the same bun version, so whatever the optimizer keys on is finer than any of them. The two rows above are one measurement on two specific versions, not a per-runtime guarantee: read them as proof that the boundary moves, never as a promise that Node keeps an outer binding private.
96
-
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.
98
-
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.
100
-
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.
102
-
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.
104
-
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:
106
-
107
- | Written as | Refused? | Why |
108
- |---|---|---|
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
-
114
- | `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 |
115
- | `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 |
116
- | `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 |
117
- | `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 |
118
- | `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 |
119
- | `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 |
120
- | 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 |
121
- | `.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 |
122
- | `export const method = 'GET'` on a form-bound action file | yes | form-bound actions strictly enforce `POST`. Binding a GET action to a form produces a 405 runtime refusal and `webjs check` error (`form-action-not-a-get-action`) |
123
- | `method="get"` on a bound `<form action=${fn}>` | yes | WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read |
124
- | `.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 |
125
- | 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 |
126
- | 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 |
127
- | `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 |
128
- | `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 |
129
- | `.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 |
130
- | `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
131
- | `.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 |
132
- | `?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}>` |
133
- | `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
134
- | `@action="${fn}"` quoted | no | an event listener, equivalent to the unquoted spelling |
135
-
136
- Quoted single-expression event, property, and boolean bindings follow Lit. Ordinary quoted action bindings retain the source-leak protections above.
137
-
138
- `.action=${fn}` on a native form is refused during SSR too, even though the property is dropped there and nothing could leak, so a page cannot render clean on the server and then throw on hydration.
139
-
140
- **A bound submitter is self-sufficient and asks nothing of the form around it.** This is the shape people expect to have to wire up, and do not:
141
-
142
- ```ts
143
- // components/publish-button.ts <- the submitter lives here
144
- class PublishButton extends WebComponent({}) {
145
- render() { return html`<button formaction=${publishDraft}>Publish</button>`; }
146
- }
147
- PublishButton.register('publish-button');
148
-
149
- // app/triage/page.ts <- the form lives here
150
- // BOTH work. The button carries its own submission attributes.
151
- html`<form><publish-button></publish-button></form>`;
152
- html`<form action=${saveAll}><publish-button></publish-button></form>`;
153
- ```
154
-
155
- The renderer supplies the submission attributes at the level where the action is BOUND (#1307), so a bound `<button>` gains `formmethod="post"` and `formenctype` ON THE BUTTON, alongside the reserved `__webjs_action` identity riding the button's own `name`/`value` pair. A submitter's `formmethod` overrides the form's `method` per HTML, so the submission is a POST whatever the enclosing form declares, including no `method` at all or `method="get"`, and the identity travels in the body where the dispatcher reads it.
156
-
157
- That is also why the renderer refuses only a SAME-ELEMENT contradiction (a bound submitter's own `formmethod` other than post, an `formenctype` the server cannot parse, `formmethod="dialog"`) and never a cross-element one. A component renders its template in a separate pass with no view of the host page, so the cross-element question is unanswerable at render time, and self-sufficiency leaves nothing for it to answer. One consequence: no `formaction` url is emitted, so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs when `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin nothing runs, which the dev-time client guard reports at submit time.
158
-
159
- **Two runtime signals cover what is left.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. A BOUND submitter carries its own `formmethod="post"`, and a bound form is refused a `method="get"` outright, so what reaches the first one is a PLAIN submitter's `formmethod="get"`, which native precedence lets win and the renderer deliberately honours, or a hand-authored form carrying the reserved field. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values.
160
-
161
- **Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way.
162
-
163
- Two things that "renders it empty" understates, both worth knowing before you go looking:
164
-
165
- - **Anything slotted into the failing component goes with it.** The isolation replaces the element from its opening tag through its matching close, so a shell or layout component whose template holds the bad form takes the page's whole authored body with it. Put `action=${fn}` in a shared header and every page renders a 200 with an empty body, not one missing header.
166
- - **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.
167
-
168
- ```ts
169
- import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
170
- // RIGHT: bind the imported action. method and enctype are supplied.
171
- html`<form action=${submitFeedback}><input name="email"></form>`;
172
- // WRONG: a bare form binds nothing, so the submission is a 405. There is no
173
- // page `action` export to catch it.
174
- html`<form method="post"><input name="email"></form>`;
175
- ```
176
-
177
- A plain attribute hole that resolves to `null` or `undefined` omits the attribute on both renderers (#1573), so `method=${null}` and `?method=${false}` both emit nothing and WebJs supplies `method="post"`. An EMPTY string is different: `method=${''}` renders `method=""`, which cannot submit and is refused.
178
-
179
- A boolean in a plain hole on an HTML boolean attribute renders like `?attr` (core 0.7.64+, #1579): `<option selected=${i === 0}>` and `<input type="radio" checked=${v.attending !== 'no'}>` mark only the true one. On an older core the server served `selected="false"` / `checked="false"`, which HTML reads as PRESENT, so the LAST option or radio won on first paint. `?selected=${...}` / `?checked=${...}` is correct on every version.
180
-
181
- 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.
182
-
183
- ### Read a form action's fields as properties, not only through `get()`
184
-
185
- Next hands a form-bound action a raw `FormData`, so the reflex is `formData.get('title')`. In WebJs that still works (the input is a real `FormData`), but each submitted field is also a property (#1664), the same shape the action receives over RPC, so the idiomatic read is `input.title`, and one `validate` serves every boundary:
186
-
187
- ```ts
188
- 'use server';
189
- export const validate = (raw: unknown) => {
190
- const r = (raw ?? {}) as Record<string, unknown>;
191
- const title = String(r.title ?? '').trim(); // works for a form post AND an RPC call
192
- if (!title) return { success: false, fieldErrors: { title: 'Required' } };
193
- return { success: true, data: { title } };
194
- };
195
- ```
196
-
197
- Three edges where the Next reflex goes wrong:
198
-
199
- - **A checkbox group.** A name submitted more than once keeps only its LAST value on the property, so `input.tags` is one string. Name the controls `tags[]` to get `input.tags` as an array, or read `input.getAll('tags')`. Dev warns once when a plain name repeats.
200
- - **Iteration reports names as submitted.** `Object.fromEntries(input)` and `entries()` show `tags[]`, brackets included, like any `FormData`; read the property or `getAll('tags')` for the group.
201
- - **Values are still strings and `File`s.** `input.count` is `'7'`, not `7`: coerce in `validate`.
202
-
203
- ### `params` and `searchParams` are awaitable AND synchronously readable
204
-
205
- Next 15/16 made `params` / `searchParams` Promises. WebJs supports BOTH, so either muscle memory is correct.
206
-
207
- ```ts
208
- export default async function User({ params, searchParams }: PageProps<'/users/[id]'>) {
209
- const id = params.id; // sync read, works
210
- const { id: id2 } = await params; // Next 15/16 await, also works
211
- const tab = (await searchParams).tab;
212
- }
213
- ```
214
-
215
- The runtime hands a plain object with a non-enumerable `then`, so a spread, `JSON.stringify`, and `Object.keys` see only the data keys. This holds for pages, layouts, and `route.ts` handler context alike.
216
-
217
- ### The page default export returns a template and runs server-only
218
-
219
- A Next page returns JSX and may embed client interactivity directly. A WebJs page default export returns a `TemplateResult` from `html` and runs only on the server. It is never re-invoked in the browser, so a signal read or `@click` in a page body does nothing after load. Put interactivity in a `WebComponent` and render its tag from the page.
220
-
221
- ### Route handlers: named method exports, value returns auto-JSON
222
-
223
- Export `GET` / `POST` / etc. as named async functions `(request, { params }) => Response | value` (a non-Response value is auto-JSON'd). A folder cannot have both `page` and `route`. There is no `NextRequest` / `NextResponse`; use the platform `Request` / `Response`. A WebSocket endpoint is a `WS(ws, req, { params })` export from the same file.
224
-
225
- ### `middleware.ts` is per-segment and chainable, not one matcher config
226
-
227
- The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middleware is in-process, chainable, and per-segment (the Remix / Koa model). There is no `export const config = { matcher }` and no single-file restriction. The default export is `async (req, next) => Response`: return a Response to short-circuit, or call `next()` and post-process. Colocate `app/admin/middleware.ts` next to the admin routes and it runs for that subtree only. An optional root `middleware.ts` runs on every app request, outermost to innermost. Some requests are answered before it and never reach it, and the RULE is what to remember, not the list: anything the listener shell or the framework's pre-analysis stage answers bypasses root middleware, and everything routed with the app reaches it. That covers WebSocket upgrades bound for a `route.ts` exporting `WS`, the dev SSE stream at `/__webjs/events`, and the framework's own `/__webjs/*` runtime assets and probes; in DEV only it also covers `/public/*` plus the `/sw.js` / `/offline.html` root remaps and `/favicon.ico`, so a stylesheet is never queued behind the dev startup analysis. In production those static files go through root middleware normally. **`webjs.redirects` and `webjs.trailingSlash` are the case intuition gets wrong**: you configure them, but the framework resolves them ahead of middleware, so a 308 from a redirect rule is answered without root middleware running (redirect in the middleware instead when it has to observe those requests). Server actions go the other way: they are routed with the app, so middleware DOES run for an action call, which is what lets you gate actions with auth or rate limiting.
228
-
229
- ### No `<Link>`, no `next/navigation`, no `next/*` libraries
230
-
231
- 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` to load it when it is first visible (scrolled near, or its hidden tab panel or dialog opened, and it stays lazy when another component imports it), and a dynamic `import()` where code should load lazily.
232
-
233
- ### No `<ScrollRestoration>`, and no scroll restore of your own
234
-
235
- 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, and the BROWSER is what does it: the router reserves the page's recorded height across the swap so the browser's own per-entry replay lands correctly, and writes no scroll itself. 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.
236
-
237
- **Do not set `history.scrollRestoration = 'manual'` either**, which is the one line most of those recipes start with. The browser only records a per-entry scroll offset under the default `auto`, and WebKit composes the iOS edge back-swipe gesture preview from that recording, so `manual` makes every scrolled page preview BLANK for the whole gesture (#1428). The router itself used to set it, inherited from Turbo Drive, and had the same bug. It no longer does.
238
-
239
- 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.
240
-
241
- **One scroll reflex DOES port, and only one.** Next's `<Link scroll={false}>` has a WebJs spelling: `data-preserve-scroll` on the link, or on any element wrapping a group of them, and `navigate(url, { scroll: false })` programmatically. It suppresses the forward-navigation scroll-to-top, which is a write the ROUTER makes, and that is why it exists while none of the restore recipes above do: the restore is the browser's and the router is not a writer on it. Everything else in this section stands unchanged, including the rule not to hand-roll a restore. The attribute keeps the reader's CURRENT offset on a forward nav; it does not bring back the offset they once had on the destination, which is what a hand-rolled `sessionStorage` restore is usually reaching for.
242
-
243
- ### Server-only code: the `.server.ts` boundary, not a `server-only` package
244
-
245
- 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.
246
-
247
- ### Public env vars use `WEBJS_PUBLIC_`, not `NEXT_PUBLIC_`
248
-
249
- `process.env.X` is server-only. To expose a value to the browser, prefix it `WEBJS_PUBLIC_` (inlined via an inline `<script>`, no build step). `NODE_ENV` is defined both sides. Reading a non-public server env var in a component is flagged by `no-server-env-in-components` (it would leak into SSR'd HTML or read as undefined after hydration).
250
-
251
- ---
252
-
253
- ## Coming from Lit
254
-
255
- The disagreement underneath: Lit is JS-first (hydration is the API), WebJs is HTML-first (first paint is real HTML, JS is opt-in per interactive behaviour). JS is requested by the specific interactive holes you write: a `@click`, a `signal.set(...)`, a `.data=${richObject}` property binding, a `Task`. A plain `<a href>`, a `<form action>`, and a display-only component request no JS. The SSR contract: the pipeline runs the constructor, applies attributes, runs `willUpdate` and controllers' `hostUpdate`, reflects `reflect: true` props, then calls `render()`. Nothing past render fires server-side (not `connectedCallback`, `firstUpdated`, `updated`).
256
-
257
- ### Fetching in `connectedCallback` or `firstUpdated`
258
-
259
- Neither hook runs server-side, so the first paint is empty and content pops in after hydration with a layout shift. Fetch in the page function and pass the data down as props or attributes.
260
-
261
- ```ts
262
- // app/users/[id]/page.ts (correct)
263
- export default async function User({ params }) {
264
- const user = await fetchUser(params.id); // via a *.server.ts query
265
- return html`<user-card .user=${user}></user-card>`;
266
- }
267
- ```
268
-
269
- ### `Task` for initial-paint data
270
-
271
- `Task` deliberately does not auto-run at SSR: it keeps its `INITIAL` state and runs only on hydration, so the client renders the resolved state after a flash. `Task` stays right for client-time async (interaction-triggered mutations, polling, websocket reactions). For initial-paint data, fetch in the page function, or use an async `render()` (which Lit does not have): write `const u = await getUser(this.id)` directly in the component and SSR bakes the resolved data into the first paint. A bare async `render()` blocks SSR and renders real data with no fallback. To STREAM slow data wrap the region in `<webjs-suspense .fallback=${html`...`}>`. `renderFallback()` is the OPTIONAL client re-fetch UI, never a first-paint concern.
272
-
273
- ### Browser globals in the constructor or `render()`
274
-
275
- `window.matchMedia`, `localStorage`, `navigator`, `document.querySelector`, and layout reads crash SSR (the instance has no DOM). The constructor is for pure-JS init. Browser APIs belong in `connectedCallback` or later (client-only by construction). Flagged by `no-browser-globals-in-render`.
276
-
277
- ```ts
278
- // wrong
279
- constructor() { super(); this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches; }
280
- // right
281
- constructor() { super(); this.dark = false; }
282
- connectedCallback() {
283
- super.connectedCallback();
284
- this.dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
285
- }
286
- ```
287
-
288
- The attribute methods (`getAttribute` / `setAttribute` / `hasAttribute`), the event methods, and `attachInternals()` ARE backed by a server shim, so reading an attribute in `render()` is safe. Only the genuinely DOM-backed members (`classList`, `querySelector`, `attachShadow`, `getBoundingClientRect`, `focus`) throw.
289
-
290
- ### Top-level imports of browser-only libraries
291
-
292
- `import Chart from 'chart.js'` or any library that touches `window` at import time crashes SSR, because the page module loads on the server. Use a dynamic `import()` inside `connectedCallback` for client-only behaviour, or wrap server work in a `.server.ts` file.
293
-
294
- ```ts
295
- connectedCallback() {
296
- super.connectedCallback();
297
- import('chart.js').then(({ Chart }) => { this.chart = new Chart(this.canvas, this.config); });
298
- }
299
- ```
300
-
301
- ### Class-field initializers for reactive properties
302
-
303
- A class-field initializer (`student: Student = { ... }`) compiles to an assignment after `super()` that uses `[[Define]]` and overwrites the reactive accessor the base class installed, silently breaking reactivity. Declare the prop in the factory and set its default in the constructor after `super()`. Flagged by `reactive-props-no-class-field`.
304
-
305
- ```ts
306
- class StudentCard extends WebComponent({ student: prop<Student>(Object) }) {
307
- constructor() { super(); this.student = { name: '', email: '' }; }
308
- }
309
- ```
310
-
311
- ### The `@property()` decorator and a `static properties` block
312
-
313
- The `@property()` decorator is banned by the erasable-TS invariant (decorators are non-erasable, they would force a build step). A `static properties = { ... }` block THROWS at runtime (`no-static-properties`). The single replacement for both is the declare-free base-class factory `WebComponent({ ... })`, with the `prop()` helper carrying options.
314
-
315
- ### The `@state()` decorator and `state: true`
316
-
317
- lit's `@state()` decorator is non-erasable, so WebJs spells the same thing as a factory option. It behaves as in lit: no attribute, reassign it to re-render, and it shows up in `changedProperties`.
318
-
319
- ```ts
320
- // lit
321
- @state() private open = false;
322
-
323
- // WebJs
324
- class Menu extends WebComponent({ open: prop(Boolean, { state: true }) }) {
325
- constructor() { super(); this.open = false; }
326
- toggle() { this.open = !this.open; }
327
- }
328
- ```
329
-
330
- Signals work too, as lit supports them through `@lit-labs/signals`. Write a signal with `.set()`, never by assignment: `this.count = 5` on a signal field replaces the signal and the next `.get()` throws (`no-signal-field-assignment` flags it). In an `async render()`, read signals before the first `await`, since reads after it are not tracked.
331
-
332
- ### Event handlers follow Lit's JavaScript API
333
-
334
- `@click=${this.handleClick}` invokes a function with the rendering component as `this`. Arrow functions and explicitly bound functions retain their JavaScript receiver. Objects with `handleEvent()` receive their own object as `this`; `capture`, `once`, and `passive` are supported on listeners. Standalone `render(value, container, { host })` sets an explicit function receiver; without a host it is the bound element.
335
-
336
- ### Expecting shadow DOM and reaching for scoped CSS
337
-
338
- Lit defaults to shadow DOM, so `static styles = css` scopes automatically. WebJs defaults to light DOM. A `static styles` block without `static shadow = true` does nothing useful and any inline `<style>` with bare class names leaks globally. The webjs-shaped fix is Tailwind utilities, which apply directly in light DOM. Reach for `static shadow = true` plus `static styles` only when scoped CSS genuinely belongs in a shadow root, or prefix every selector with the tag name if authoring vanilla light-DOM CSS.
339
-
340
- ### Reading `assignedNodes()` in `firstUpdated` of a light-DOM component
341
-
342
- In shadow DOM the browser projects slotted content natively before `firstUpdated`, so Lit muscle memory says `this.shadowRoot.querySelector('slot').assignedNodes()` is populated there. In light DOM the first projection lands one microtask AFTER the first render, so `firstUpdated` sees the `<slot>` element with an EMPTY `assignedNodes()`. The webjs-shaped fix: read assigned content from a `slotchange` listener (fires once projection lands, and on every later change), or wait a microtask. Every later read and every mutation-driven update behaves identically in both modes; only the first-render read differs.
343
-
344
- ### `:host { display: block }` on a light-DOM component
345
-
346
- A custom element is `display: inline` by default, so a block container collapses. In Lit you fix this with `:host { display: block }`, which works because Lit is shadow-DOM-first. A light-DOM WebJs component has no shadow root, so there is no `:host` to write. There is nothing to do: the framework already defaults every light-DOM host to `display: block` via a low-priority `@layer webjs-host` rule, overridable by any Tailwind utility (`class="flex"` wins). A shadow-DOM component (`static shadow = true`) still sets `:host { display: block }` in `static styles` itself, exactly like Lit.
347
-
348
- ### Interpolating into a `<style>` or `<script>` inside a component
349
-
350
- In Lit a binding inside `<style>` works. In a WebJs component it fails silently after hydration: the server emits the interpolated content (first paint looks right), but the client drops the raw-text hole and rebuilds the element EMPTY, so the styles vanish. Use `static styles` (shadow) or Tailwind (light DOM). A fully static `<style>` with no `${}` is fine. Flagged by `no-interpolation-in-raw-text-element`. Note the exception: pages and layouts never hydrate, so a page's `<style>${STYLES}</style>` is a legitimate pattern.
351
-
352
- ### Reordering a `.map()` list needs a keyed `repeat()`
353
-
354
- A plain `.map()` list reconciles in place and preserves node identity on item-level updates (drag-and-drop, focus, caret, and input state all survive), so it is fine for append-only or update-in-place lists. What it does NOT do is keyed reordering: reconciliation is positional, so on a middle insert or a reorder the nodes stay put and their contents are rewritten. When a list reorders or splices in the middle and each item owns DOM state that must move with it, use `repeat(items, (i) => i.id, template)` from `@webjsdev/core/directives`, exactly as in Lit.
355
-
356
- ### `ContextProvider` for server-known data
357
-
358
- Context providers publish on connect via `hostConnected`, which does not run at SSR, so descendants read the default (or undefined) during SSR and re-render on hydration with a content shift. For server-known data (session, user, theme, locale, feature flags), pass it through props from the page function. Reserve `ContextProvider` for client-time concerns (interaction state, focus management, transient UI).
359
-
360
- ### Vanilla DOM instead of Lit idioms
361
-
362
- WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `state: true` prop or a `signal` for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
@@ -1,179 +0,0 @@
1
- # Optimistic UI
2
-
3
- ## What This Covers
4
-
5
- - The declarative `optimistic(host, { source, update })` API (preferred) with `.add(payload, promise)` auto-release
6
- - The imperative `optimistic(signal, value, action)` API for simple boolean flips
7
- - When optimistic UI is appropriate, and when to skip it
8
- - Why you never hand-roll try-catch, cache-and-restore, or temp-ID reconciliation
9
-
10
- Read this when a mutation should feel instant, when the client can predict the result of a create/update/delete/like/toggle/reorder before the server confirms it. Sibling refs: `data-and-actions.md` (the server actions and the `ActionResult` envelope these calls invoke), `components.md` (the `WebComponent` host, reactive props, and signals these APIs attach to).
11
-
12
- ## The Idea
13
-
14
- `optimistic()` from `@webjsdev/core` shows a mutation's expected result IMMEDIATELY (the UI feels instant), runs the real server action, and ROLLS BACK automatically on failure. It is the default for every user-facing mutation where the client can construct the expected result from the input. **Never write manual try-catch, cache-and-restore, or temp-ID reconciliation** when `optimistic()` covers the pattern.
15
-
16
- ## Declarative API (preferred)
17
-
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
-
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
-
22
- ```ts
23
- import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
24
- import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
25
-
26
- class TodoList extends WebComponent({
27
- todos: prop<Todo[]>(Array),
28
- }) {
29
- private optimisticTodos = optimistic(this, {
30
- source: () => this.todos,
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 }) => [
39
- ...state,
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 },
43
- ],
44
- });
45
-
46
- async handleSubmit(e: SubmitEvent) {
47
- e.preventDefault();
48
- const title = new FormData(e.target as HTMLFormElement).get('title') as string;
49
- if (!title) return;
50
- (e.target as HTMLFormElement).reset();
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();
57
- const promise = createTodo({ title });
58
- this.optimisticTodos.add({ tempId, title }, promise);
59
-
60
- const result = await promise;
61
- if (result.success && result.data) {
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.)
68
- this.todos = [...this.todos, result.data];
69
- }
70
- }
71
-
72
- render() {
73
- return html`<ul>${this.optimisticTodos.value.map(todo => html`
74
- <li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
75
- `)}</ul>`;
76
- }
77
- }
78
- TodoList.register('todo-list');
79
- ```
80
-
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.
82
-
83
- - Multiple `.add()` calls stack independently. Each carries its own release by ID, so overlapping in-flight mutations do not clobber one another.
84
- - When `update` is omitted, the payload REPLACES the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
85
-
86
- ### Author the optimistic mutation as a degrade-first form
87
-
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. Note the arrow wrapper on the listener: WebJs does not bind an `@event` handler to your component, so passing the method directly would leave `this` pointing at a framework-internal object (see `muscle-memory-gotchas.md`).
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.
91
-
92
- ```ts
93
- import { createTodo } from '#modules/todo/actions/create-todo.server.ts';
94
-
95
- render() {
96
- return html`
97
- <form action=${createTodo} @submit=${(e: SubmitEvent) => this.handleSubmit(e)}>
98
- <input name="title" required>
99
- <button>Add</button>
100
- </form>
101
- <ul>${this.optimisticTodos.value.map(t => html`<li class=${t.pending ? 'opacity-50' : ''}>${t.title}</li>`)}</ul>`;
102
- }
103
- ```
104
-
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"`.
108
-
109
- ## Seed the list from the server for SSR plus optimistic
110
-
111
- For a page that server-renders a list AND lets the user add to it optimistically, let ONE component own both the list and the form, and seed it from the page through a `.prop` hole (a DOM property that round-trips through SSR on custom elements). The list is then fully server-rendered on first paint (readable with JS off) and re-renders optimistically on each add. A separate static list in the page would not update on an optimistic add.
112
-
113
- ```ts
114
- // app/notes/page.ts (runs server-only; awaits the data so it is in the first paint)
115
- import { html } from '@webjsdev/core';
116
- import '#modules/notes/components/note-composer.ts'; // registers <note-composer>
117
- import { listNotes } from '#modules/notes/queries/list-notes.server.ts';
118
-
119
- export default async function NotesPage() {
120
- const notes = await listNotes();
121
- // .notes=${notes} seeds the component; the list SSRs through the component.
122
- return html`<note-composer .notes=${notes}></note-composer>`;
123
- }
124
- ```
125
-
126
- The component reads that seeded prop as its `optimistic()` `source`, so `source: () => this.notes` is both the SSR list and the base for optimistic additions.
127
-
128
- ## Imperative API (simple boolean flips)
129
-
130
- For a boolean toggle where the value itself is the mutation (like, follow, pin), `optimistic(signal, value, action)` is a thin wrapper over the signal primitive.
131
-
132
- ```ts
133
- import { WebComponent, prop, signal, optimistic, html } from '@webjsdev/core';
134
- import { likePost } from '#modules/posts/actions/like-post.server.ts';
135
-
136
- class LikeButton extends WebComponent({ postId: prop(String) }) {
137
- // INSTANCE scope: one signal per element. A module-scope `signal()` is SHARED
138
- // across every instance (invariant 5), so a feed of these would all flip
139
- // together on one click. Scope per-item state to the instance.
140
- readonly #liked = signal(false);
141
-
142
- private async toggle() {
143
- const next = !this.#liked.get();
144
- // Returns the action's ActionResult, so a { success: false } is readable
145
- // by the caller. The rollback has already happened by then.
146
- return optimistic(this.#liked, next, () => likePost(this.postId));
147
- }
148
-
149
- render() {
150
- return html`<button @click=${() => this.toggle()}>${this.#liked.get() ? 'Liked' : 'Like'}</button>`;
151
- }
152
- }
153
- LikeButton.register('like-button');
154
- ```
155
-
156
- Reserve MODULE scope for state that genuinely is app-wide (a theme, a cart count, a sidebar-open flag), which is the case invariant 5's "module-scope signals share state across components" exists to serve. Per-item state goes on the instance, as above. `#liked` is a private field holding a signal, not a reactive property declared in the factory, so the class-field ban does not reach it and `reactive-props-no-class-field` does not flag it. It is `readonly`: a signal field is written through `.set()`, never assigned. `gallery/modules/optimistic-ui/components/like-button.ts` is the same shape in shipped code.
157
-
158
- It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. It is client-only (it mutates a signal), so a component importing it is never elided as a display-only component.
159
-
160
- ## When Optimistic UI Is Appropriate
161
-
162
- - Todo items, comments, posts, likes, follows, toggles, reorders, renames, status changes.
163
- - Any mutation where the client can construct the expected result from the input.
164
- - CRUD operations where the server returns the same shape the client already has.
165
-
166
- ## When To Skip It
167
-
168
- - The result is unpredictable (AI-generated content, server-computed values the client cannot guess).
169
- - The mutation has side effects the user must wait for (payment processing, email sending, OAuth).
170
- - The action validates against data that may have changed server-side (unique constraints, race conditions).
171
- - The mutation is destructive and irreversible with no undo (confirm-first UX is better).
172
-
173
- ## Rules
174
-
175
- 1. Default to `optimistic()` for every predictable user-facing mutation. Instant UI, automatic rollback.
176
- 2. Prefer the declarative `.add(payload, promise)` form for list mutations. Pass the promise so release is automatic.
177
- 3. Use the imperative `optimistic(signal, value, action)` form only for a boolean flip whose value is the mutation.
178
- 4. Never hand-roll try-catch, cache-and-restore, or temp-ID reconciliation when one of these APIs covers the pattern.
179
- 5. Reconcile the authoritative result from the returned `ActionResult` after the promise settles when you need the server's canonical row.