@webjsdev/mcp 0.1.10 → 0.1.12
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 +4 -0
- package/package.json +2 -2
- package/resources/AGENTS.md +117 -45
- package/resources/SKILL.md +34 -16
- package/resources/references/auth-and-sessions.md +86 -21
- package/resources/references/built-ins.md +56 -10
- package/resources/references/client-router-and-streaming.md +69 -9
- package/resources/references/components.md +167 -9
- package/resources/references/data-and-actions.md +115 -12
- package/resources/references/muscle-memory-gotchas.md +124 -3
- package/resources/references/optimistic-ui.md +49 -10
- package/resources/references/routing-and-pages.md +62 -18
- package/resources/references/runtime.md +10 -1
- package/resources/references/service-worker.md +5 -3
- package/resources/references/styling.md +116 -2
- package/resources/references/testing.md +86 -9
- package/resources/references/typescript.md +75 -6
- package/resources/references/ui-kit.md +18 -10
- package/src/mcp.js +18 -1
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
- The `modules/<feature>/` architecture (thin `app/` adapters, `actions/` mutations, `queries/` reads, one function per file)
|
|
6
6
|
- `'use server'` RPC actions, the serializer-safe wire, and how a client import becomes a typed stub
|
|
7
7
|
- Input validation at the boundary via `export const validate`
|
|
8
|
-
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`)
|
|
8
|
+
- HTTP-verb config exports (`method`, `cache`, `tags`, `invalidates`, `middleware`), the middleware `ctx` shape, and `actionSignal()` cancellation
|
|
9
9
|
- The `ActionResult<T>` envelope and its robust failure detection
|
|
10
10
|
- The `route()` REST adapter that exposes an action over HTTP
|
|
11
11
|
- Drizzle rc.3 reads (`db.query.*`) and mutations (`.returning()`)
|
|
12
12
|
- Keeping server-only types off the client (`import type` vs a value import)
|
|
13
13
|
|
|
14
|
-
Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (
|
|
14
|
+
Read this when a task touches a server mutation, a data read, input validation, a REST endpoint, or the shape a component consumes. Sibling refs: `routing-and-pages.md` (where a bound form lives on a page, `route.ts` handlers), `auth-and-sessions.md` (protecting an action or endpoint), `optimistic-ui.md` (consuming `ActionResult` on the client), `typescript.md` (erasable syntax, full-stack types).
|
|
15
15
|
|
|
16
16
|
## The Architecture (read this first)
|
|
17
17
|
|
|
@@ -96,28 +96,61 @@ const [updated] = await db.update(posts).set({ title }).where(eq(posts.id, id)).
|
|
|
96
96
|
await db.delete(posts).where(eq(posts.id, id));
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://
|
|
99
|
+
A `.returning()` row is the table's own columns only, never `with` relations. When the caller wants a joined shape, re-read with `db.query.*` or splice the already-known related value in by hand. Full surface at https://webjs.dev/docs.
|
|
100
100
|
|
|
101
101
|
## Input validation at the boundary
|
|
102
102
|
|
|
103
|
-
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on the RPC
|
|
103
|
+
Declare `export const validate` beside the action. It runs SERVER-SIDE before the action body on every BOUNDARY (the RPC endpoint, a `route()` REST endpoint, and a form submission), receiving the action's FIRST argument. On the form boundary that first argument is the `FormData`, and a `{ success: false, fieldErrors }` return becomes a 422 RE-RENDER of the page with the validator's result on `actionData`, rather than a JSON 422. The framework only CALLS the validator (it ships no validation library) and reads its return: `{ success: true, data? }` runs the action (an optional `data` replaces the input), `{ success: false, fieldErrors }` returns a 422 WITHOUT running the body, and a THROW becomes a sanitized error.
|
|
104
104
|
|
|
105
105
|
```ts
|
|
106
106
|
// modules/posts/actions/create-post.server.ts
|
|
107
107
|
'use server';
|
|
108
|
-
export
|
|
108
|
+
export interface CreatePostInput { title: string; body: string }
|
|
109
|
+
|
|
110
|
+
// `unknown` is CORRECT here and nowhere else in this file: the validator IS
|
|
111
|
+
// the narrowing site for an untrusted wire payload. Never `any`, which would
|
|
112
|
+
// un-type the returned `data` and with it the action's own input.
|
|
113
|
+
export const validate = (input: unknown) => {
|
|
114
|
+
const raw = (input ?? {}) as Record<string, unknown>;
|
|
109
115
|
const fieldErrors: Record<string, string> = {};
|
|
110
|
-
const title = String(
|
|
116
|
+
const title = String(raw.title ?? '').trim();
|
|
111
117
|
if (!title) fieldErrors.title = 'Title is required';
|
|
112
|
-
if (String(
|
|
118
|
+
if (String(raw.body ?? '').length < 10) fieldErrors.body = 'Too short';
|
|
113
119
|
if (Object.keys(fieldErrors).length) return { success: false, fieldErrors };
|
|
114
|
-
|
|
120
|
+
// `satisfies` ties the validator's output to the action's input, so the two
|
|
121
|
+
// cannot drift apart.
|
|
122
|
+
return { success: true, data: { title, body: String(raw.body) } satisfies CreatePostInput };
|
|
115
123
|
};
|
|
116
|
-
export async function createPost(input:
|
|
124
|
+
export async function createPost(input: CreatePostInput) { /* runs only when valid */ }
|
|
117
125
|
```
|
|
118
126
|
|
|
119
127
|
A client call resolves with the failure envelope (it does NOT throw), so the component reads `result.fieldErrors`. A zod adapter wraps `safeParse` so its result becomes the envelope; the framework stays zod-free.
|
|
120
128
|
|
|
129
|
+
## Binding an action to a form
|
|
130
|
+
|
|
131
|
+
A `<form action=${importedAction}>` is the one way a form submits to a server action, and it is the whole wiring:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
135
|
+
html`<form action=${createPost}><input name="title"></form>`;
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
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, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
|
|
139
|
+
|
|
140
|
+
**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
|
|
141
|
+
|
|
142
|
+
Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
|
|
143
|
+
|
|
144
|
+
- `validate` runs on the submitted `FormData`.
|
|
145
|
+
- the `middleware` chain runs, with the page's route context (`params`, `searchParams`, `url`) added to `ctx`.
|
|
146
|
+
- `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data.
|
|
147
|
+
- `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag.
|
|
148
|
+
- `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`.
|
|
149
|
+
|
|
150
|
+
The response drives the page: a success is a `303` PRG (to `result.redirect` when it is a same-site local path, else the page's own url), a failure re-renders the SAME page with `status` (default `422`) and the result on `actionData`, a submission carrying no identity is a `405`, and one whose hash no longer resolves is a `422` with a resubmit message (a form held open across a deploy). The submission is Origin-verified like an RPC call, so no token field is needed.
|
|
151
|
+
|
|
152
|
+
A streamed return (#489) is refused from a form-bound action: the RPC stub decodes frames, but a submission is answered with a redirect or a page, and with JS off there is no consumer at all. Stream from a programmatic call instead.
|
|
153
|
+
|
|
121
154
|
## HTTP-verb config exports
|
|
122
155
|
|
|
123
156
|
A `'use server'` action is a POST by default. Reserved sibling exports, read statically (the same way a page reads `export const revalidate`), change its HTTP semantics WITHOUT changing the call site (you still write `await getUser(7)`).
|
|
@@ -139,9 +172,27 @@ export const middleware = [requireAuth]; // async (ctx, next) => resul
|
|
|
139
172
|
export async function updateUser(id: number, patch: Partial<User>) { /* ... */ }
|
|
140
173
|
```
|
|
141
174
|
|
|
142
|
-
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries
|
|
175
|
+
- A **GET** rides args in the URL (POST fallback over a 4KB cap), is CSRF-exempt, and carries a weak `ETag` (304 on `If-None-Match`); with a `cache` export it also carries `Cache-Control` + `X-Webjs-Tags` (without one it is `no-store`, so caching is opted into by `cache`, not by the verb). A **mutation** (POST/PUT/PATCH/DELETE) sends the rich body (DELETE rides the URL), is CSRF-protected, and once it completes without throwing evicts its `invalidates` tags and reports them via `X-Webjs-Invalidate` (a returned `{ success: false }` envelope still evicts, since the action ran). A method mismatch is a `405` + `Allow`.
|
|
176
|
+
- The `cache` object maps onto the `Cache-Control` header. The number shorthand `cache = 60` means `{ maxAge: 60 }`. `maxAge` is the freshness window in seconds (`max-age=<n>`), `swr` is a stale-while-revalidate grace window in seconds (`stale-while-revalidate=<n>`, an expired response is still served instantly while the browser revalidates in the background, usually a bodyless 304 thanks to the ETag), and `public` flips the scope from the default `private`. So `{ maxAge: 60, swr: 300 }` emits `private, max-age=60, stale-while-revalidate=300`.
|
|
143
177
|
- **SAFETY.** `cache` with `public: true` SHARES one response across ALL users, keyed only by URL + args. Use it ONLY for data identical for every visitor (the same rule as a page's `export const revalidate`), never for a session or per-user read.
|
|
144
|
-
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`.
|
|
178
|
+
- Per-action `middleware` short-circuits by returning an `ActionResult` instead of calling `next()`, and accumulates context the action reads via `actionContext()` from `@webjsdev/server`. Each middleware is `async (ctx, next) => result` where `ctx` is `{ request, args, signal, context }`. It writes to the shared bag `ctx.context.<key>` (for example `ctx.context.user = user`), which is exactly what `actionContext().user` reads back in the action. A direct server-to-server call skips the RPC boundary (so its middleware does NOT run), so the action must guard rather than assume a middleware-set value is present.
|
|
179
|
+
|
|
180
|
+
### Cancellation with `actionSignal()`
|
|
181
|
+
|
|
182
|
+
Inside an action, `actionSignal()` from `@webjsdev/server` returns the request's `AbortSignal`. It fires when the client disconnects OR when a newer client render supersedes this one (the RPC stub aborts the previous in-flight fetch). Thread it into the work you start, and re-check it after an await to map an abort to a cancelled envelope:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
'use server';
|
|
186
|
+
import { actionSignal } from '@webjsdev/server';
|
|
187
|
+
export async function search(q: string) {
|
|
188
|
+
const signal = actionSignal();
|
|
189
|
+
const res = await fetch(`https://api/x?q=${q}`, { signal }); // aborts the fetch on disconnect
|
|
190
|
+
if (signal.aborted) return { success: false, error: 'Request cancelled.', status: 499 };
|
|
191
|
+
return { success: true, data: await res.json() };
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A guard placed BEFORE any await can never fire (nothing has yielded yet). Outside an action the signal never aborts, so a server-to-server call stays safe.
|
|
145
196
|
|
|
146
197
|
## The `ActionResult<T>` envelope
|
|
147
198
|
|
|
@@ -184,4 +235,56 @@ import type { Post } from '#db/schema.server.ts';
|
|
|
184
235
|
import { posts } from '#db/schema.server.ts';
|
|
185
236
|
```
|
|
186
237
|
|
|
187
|
-
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://
|
|
238
|
+
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
|
|
239
|
+
|
|
240
|
+
## SSR action seeding, and how to tell it is working
|
|
241
|
+
|
|
242
|
+
When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.
|
|
243
|
+
|
|
244
|
+
**You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.
|
|
245
|
+
|
|
246
|
+
### The correctness boundary
|
|
247
|
+
|
|
248
|
+
A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, so a departed render's value is never served. On an HTML-cached page (`export const revalidate`) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches.
|
|
249
|
+
|
|
250
|
+
There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
[webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.
|
|
257
|
+
|
|
258
|
+
### Reading the dev diagnostics
|
|
259
|
+
|
|
260
|
+
A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.
|
|
261
|
+
|
|
262
|
+
**Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:
|
|
263
|
+
|
|
264
|
+
| Value | What it means |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
|
|
267
|
+
| `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
|
|
268
|
+
| `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
|
|
269
|
+
| `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
|
|
270
|
+
| `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |
|
|
271
|
+
|
|
272
|
+
Check it with `curl -sSI localhost:3000/` or in the network tab.
|
|
273
|
+
|
|
274
|
+
**Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:
|
|
275
|
+
|
|
276
|
+
- *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
|
|
277
|
+
- *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
|
|
278
|
+
- *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.
|
|
279
|
+
|
|
280
|
+
A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.
|
|
281
|
+
|
|
282
|
+
`seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.
|
|
283
|
+
|
|
284
|
+
### The streamed-page exception
|
|
285
|
+
|
|
286
|
+
A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.
|
|
287
|
+
|
|
288
|
+
### Switching it off
|
|
289
|
+
|
|
290
|
+
`"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
## What This Covers
|
|
4
4
|
|
|
5
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 or reactivity, 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`, interpolation into `<style
|
|
6
|
+
- The Lit patterns that break WebJs SSR or reactivity, 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`, interpolation into `<style>`, reading `assignedNodes()` in `firstUpdated` of a light-DOM component.
|
|
7
7
|
- The WebJs-shaped fix for each, with short code.
|
|
8
8
|
|
|
9
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.
|
|
@@ -24,10 +24,10 @@ In Next, `redirect()` works in Server Components, Actions, and Route Handlers al
|
|
|
24
24
|
// route.ts WRONG: redirect() is uncaught here.
|
|
25
25
|
export async function GET() { redirect('/login'); }
|
|
26
26
|
// route.ts RIGHT: return a real redirect Response.
|
|
27
|
-
export async function GET() { return Response.redirect(new URL('/login', req.url), 303); }
|
|
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,6 +43,117 @@ 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
|
+
### `<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
|
+
| `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 |
|
|
127
|
+
| `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
|
|
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}>` |
|
|
130
|
+
| `@action=` unquoted | **no** | an event listener, and a function is exactly what one takes |
|
|
131
|
+
| `@action="${fn}"` quoted | yes | quoting makes it an ordinary attribute again, so it leaks |
|
|
132
|
+
|
|
133
|
+
That last row is the one to remember: quoting a binding hole turns it back into a plain attribute, which is why invariant 4 requires `@`, `.` and `?` holes to be unquoted.
|
|
134
|
+
|
|
135
|
+
`.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.
|
|
136
|
+
|
|
137
|
+
**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.
|
|
138
|
+
|
|
139
|
+
Two things that "renders it empty" understates, both worth knowing before you go looking:
|
|
140
|
+
|
|
141
|
+
- **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.
|
|
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.
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
|
|
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>`;
|
|
151
|
+
```
|
|
152
|
+
|
|
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.
|
|
156
|
+
|
|
46
157
|
### `params` and `searchParams` are awaitable AND synchronously readable
|
|
47
158
|
|
|
48
159
|
Next 15/16 made `params` / `searchParams` Promises. WebJs supports BOTH, so either muscle memory is correct.
|
|
@@ -73,6 +184,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa
|
|
|
73
184
|
|
|
74
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.
|
|
75
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
|
+
|
|
76
193
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
77
194
|
|
|
78
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.
|
|
@@ -149,6 +266,10 @@ The `@property()` decorator is banned by the erasable-TS invariant (decorators a
|
|
|
149
266
|
|
|
150
267
|
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.
|
|
151
268
|
|
|
269
|
+
### Reading `assignedNodes()` in `firstUpdated` of a light-DOM component
|
|
270
|
+
|
|
271
|
+
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.
|
|
272
|
+
|
|
152
273
|
### `:host { display: block }` on a light-DOM component
|
|
153
274
|
|
|
154
275
|
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.
|
|
@@ -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,11 +78,34 @@ 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
|
|
|
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.
|
|
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=${this.handleSubmit}>
|
|
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
|
+
|
|
70
109
|
## Seed the list from the server for SSR plus optimistic
|
|
71
110
|
|
|
72
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.
|