@webjsdev/mcp 0.1.5 → 0.1.7

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.
@@ -59,7 +59,7 @@ class Dialog extends WebComponent({
59
59
  }
60
60
  ```
61
61
 
62
- Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field initializer (e.g., `count = 0` or `student: Student = { ... }`): the framework installs reactive getter/setter on `this` inside the constructor via `Object.defineProperty`, and a class-field initializer compiles to an assignment after `super()` that uses `[[Define]]` to overwrite the accessor, silently breaking reactivity. The `reactive-props-no-class-field` rule catches this.
62
+ Set defaults in the constructor after `super()`. A declarative `default` option also exists (lit-parity, a function default runs per instance for a fresh object / array), but the constructor is the recommended way. **Never** use a class-field declaration OR initializer (e.g., `count = 0`, `student: Student = { ... }`, or `todos!: Todo[]`): even a type-only declaration like `todos!: Todo[]` compiles under modern TS configurations (with `useDefineForClassFields: true`) to define a property on the instance after `super()`, which shadows and clobbers the prototype's reactive accessor, silently breaking reactivity. Only declare properties in the factory, and read/write them directly off `this`. The `reactive-props-no-class-field` rule catches this.
63
63
 
64
64
  ```ts
65
65
  // set defaults in the constructor after super()
@@ -96,7 +96,7 @@ The SSR pipeline runs the **pre-render value-deriving hooks** before `render()`:
96
96
 
97
97
  For component-local state, create an instance signal in the constructor and call `signal.set(...)` to mutate. The built-in `SignalWatcher` re-runs `render()` on the next microtask; the same lifecycle hooks fire as for reactive-property changes.
98
98
 
99
- See [`/docs/lifecycle`](https://docs.webjs.com/docs/lifecycle) for per-hook usage examples.
99
+ See [`/docs/lifecycle`](https://docs.webjs.dev/docs/lifecycle) for per-hook usage examples.
100
100
 
101
101
  ## Async render: bare-await data fetch (#469)
102
102
 
@@ -244,10 +244,23 @@ patterns, treat the component as interactive.
244
244
 
245
245
  The detection lists live in `packages/server/src/component-elision.js`
246
246
  and are the single source of truth. They are kept in lockstep with the
247
- lifecycle table above by `packages/server/test/elision/lifecycle-coverage.test.js`,
248
- which fails if a new `WebComponent` hook is added without teaching the
249
- analyser about it. If you add an interactivity feature to the framework,
250
- update that file.
247
+ framework's interactivity surface by two drift guards, which fail the build
248
+ if a new interactivity feature ships without teaching the analyser about it
249
+ (a silent over-elision otherwise). `packages/server/test/elision/lifecycle-coverage.test.js`
250
+ introspects the live `WebComponent` prototype and covers hooks and methods.
251
+ `packages/server/test/elision/sigil-coverage.test.js` covers the two surfaces
252
+ that are not prototype members: template binding sigils and interactivity
253
+ static fields. The renderers' sigil set is single-sourced in core's
254
+ `BINDING_PREFIXES` (`packages/core/src/binding-prefixes.js`); the analyser
255
+ classifies each sigil as a client-behaviour ship signal (`SSR_DROPPED_PREFIXES`,
256
+ `@event`, drops at SSR) or an SSR-safe round-trip (`ROUND_TRIP_PREFIXES`,
257
+ `.prop` / `?bool`, survives into the served HTML). The guard asserts that
258
+ classification partitions `BINDING_PREFIXES` exactly, so a new sigil cannot be
259
+ added without a deliberate ship-or-round-trip decision. Interactivity static
260
+ fields live in the `INTERACTIVITY_STATIC_FIELDS` registry (`shadow`, `refresh`).
261
+ If you add an interactivity feature to the framework, update the matching list
262
+ in `component-elision.js` (and add a new static convention to both the
263
+ `INTERACTIVITY_STATIC_FIELDS` registry and this lifecycle table).
251
264
 
252
265
  ## Pages and layouts: keep them carriers, out of the network tab
253
266
 
@@ -325,19 +338,28 @@ suggests is the three legitimate shapes: gate the route in
325
338
  `'use server'` ACTION (its browser stub is a working RPC, so it is
326
339
  exempt), or register the component in a `layout.{ts,js}` so the page
327
340
  elides again. Server-to-server imports (`.server.ts` importing
328
- `.server.ts`) and `middleware.ts` / `route.ts` are never flagged.
341
+ `.server.ts`) and `middleware.ts` / `route.ts` are never flagged. A
342
+ TYPE-ONLY `import type { Row } from './x.server.ts'` is exempt too,
343
+ because the stripper erases it before it reaches the browser, so sharing
344
+ a derived row type from a `.server.ts` is safe (a mixed
345
+ `import { type Row, value }` still ships the runtime `value` binding, so
346
+ it is still flagged).
329
347
 
330
348
  The rule covers every module the build ships, not just pages: a shipping
331
349
  component, and `error.{ts,js}` / `loading.{ts,js}` / `not-found.{ts,js}`
332
350
  modules, are checked too. Those three boundaries always ship and are
333
351
  never elided (only an elidable component import is ever stripped), so a
334
352
  personalized 404 that does `await auth()` is the same throw-at-load crash
335
- and is flagged. One known gap: a DYNAMIC `import('./x.server.ts')` is not
336
- caught, because the framework's import scanner tracks only static
337
- `import` / `export … from`, not the `import(` call form. That is
338
- consistent with the rest of the framework (a dynamic import is also not
339
- elided framework-wide, and its crash is deferred to call time, not module
340
- load), so the rule leaves it to the runtime.
353
+ and is flagged. Scope note for dynamic imports: a string-literal
354
+ `import('./widget.ts')` IS tracked by the authorization gate (#751), so a
355
+ lazily-imported app module is servable instead of 404ing, but it is kept
356
+ out of elision and the modulepreload set (a dynamic import is lazy by
357
+ author intent, fetched at call time). The `no-server-import-in-browser-module`
358
+ rule still operates on STATIC edges only, so a dynamic
359
+ `import('./x.server.ts')` of a no-`'use server'` utility is not flagged at
360
+ check time, its throw-at-load is deferred to call time; and a computed
361
+ `import(expr)` cannot be resolved statically, so `webjs check` warns on it
362
+ in a shipping module (it would 404 if it targets an app module).
341
363
 
342
364
  ### Turning elision off
343
365
 
@@ -213,7 +213,7 @@ export async function GET() {
213
213
 
214
214
  ### `onError` hook (APM / Sentry integration point)
215
215
 
216
- Register an error sink via `createRequestHandler({ onError })` (and `startServer({ onError })`). It is called with `(error, { request, requestId, phase })` whenever the request pipeline catches an unhandled error: the 500 path (a thrown route handler / middleware / page render, labeled phase `handle` / `middleware` / `ssr` / `metadata`) or a server action that throws unexpectedly (phase `action`). **The contract is best-effort:** a throwing `onError` is caught and ignored so it can never crash the response, and the hook is purely additive (webjs's existing sanitized 500, with only `error.message` in prod and never the stack, is unchanged). The hook fires BEFORE the sanitized response is sent, so the sink sees the original error. The `requestId` ties the report to the access-log line.
216
+ Register an error sink via `createRequestHandler({ onError })` (and `startServer({ onError })`). It is called with `(error, { request, requestId, phase })` whenever the request pipeline catches an unhandled error: the 500 path (a thrown route handler / middleware / page render, labeled phase `handle` / `middleware` / `ssr` / `metadata`) or a server action that throws unexpectedly (phase `action`). **The contract is best-effort:** a throwing `onError` is caught and ignored so it can never crash the response, and the hook is purely additive (webjs's existing sanitized response is unchanged: a thrown route handler / page render returns a generic 500, and a thrown server action returns a generic message + a correlation `digest` (#749), never the raw message or the stack in prod). The hook fires BEFORE the sanitized response is sent, so the sink sees the original error. The `requestId` ties the report to the access-log line.
217
217
 
218
218
  ```ts
219
219
  const app = await createRequestHandler({
@@ -28,6 +28,12 @@ This repo uses git worktrees (the review subagents spawn throwaway ones under `.
28
28
 
29
29
  Because the pin lives in the main worktree's `config.worktree`, `git worktree add` copies it into each linked worktree, so a commit made inside a throwaway review worktree also runs the framework `.hooks/pre-commit`. That is harmless (the hook only blocks main and auto-generates a changelog on a version bump), and review subagents are read-only so they do not commit; the inheritance is noted here only so the behavior is not surprising.
30
30
 
31
+ ### Merged worktrees are auto-removed (`cleanup-merged-worktree.sh`)
32
+
33
+ Per-task worktrees pile up when a session merges its PR but never runs `git worktree remove` (a skipped step, or a crash mid-task). The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook (matcher `Bash`, wired in `.claude/settings.json`) closes that gap: after any `gh pr merge`, it sweeps every linked worktree and removes the ones that are safe to drop, so cleanup is deterministic rather than a thing an agent has to remember.
34
+
35
+ It is conservative. A worktree is removed ONLY when it is a linked (non-primary) checkout, on a non-`main`/`master` branch, whose branch is MERGED (an ancestor of `origin/main`, OR a merged GitHub PR for that head branch, which is how squash-merges are detected via `gh`) AND whose working tree is clean apart from untracked `node_modules` / `.webjs`. It KEEPS anything dirty, unmerged, the primary checkout, or the worktree the merge was run from (you cannot remove your current directory; `cd` out and remove it manually), reporting each kept/removed worktree back to the model via `hookSpecificOutput`. It never blocks the tool (always exits 0). Escape hatch: `WEBJS_NO_WORKTREE_CLEANUP=1`. Regression test: `test/hooks/cleanup-merged-worktree.test.mjs`.
36
+
31
37
  The fix only repairs the LOCAL checkout. Commits and branches are always safe on GitHub regardless.
32
38
 
33
39
  ---
@@ -56,6 +62,8 @@ npm runs first; if it fails (auth, network, transient registry error), the GitHu
56
62
 
57
63
  The workflow uses `NPM_TOKEN` (repo secret) and the auto-provisioned `GITHUB_TOKEN`. Free for public repos.
58
64
 
65
+ **Update the global CLI after the publish lands.** The maintainer scaffolds and dogfoods with the globally installed `webjs` CLI, which lags a release until refreshed. So once `release.yml` has published (verify `npm view @webjsdev/cli version` matches the released version), run BOTH `npm update -g webjsdev` and `bun add -g webjsdev`. Run them AFTER the publish, never at merge time (they pull the LATEST PUBLISHED version). This is reminded automatically by the `.claude/hooks/release-global-update.sh` PostToolUse hook, which fires when a `chore/release-*` PR merges (escape hatch `WEBJS_NO_RELEASE_GLOBAL_UPDATE=1`, regression test `test/hooks/release-global-update.test.mjs`).
66
+
59
67
  ---
60
68
 
61
69
  ## Dev error overlay: rich, pushed live over SSE (dev-only) (#264)
@@ -0,0 +1,198 @@
1
+ # ORM: the Drizzle query surface (rc.3)
2
+
3
+ The scaffold pins **`drizzle-orm@^1.0.0-rc.3`** (`db/columns.server.ts` header
4
+ records this, research #562). rc.3 is the 1.0 release candidate with the new
5
+ Relational Query Builder v2 (RQBv2) wired up in `db/connection.server.ts` via
6
+ `drizzle({ client, relations })`. Several method overloads that older Drizzle
7
+ tutorials rely on do NOT exist in rc.3, so training-data patterns from
8
+ Drizzle 0.29 through 0.36 trip real TypeScript errors here. This page codifies
9
+ the shapes that compile against the installed version.
10
+
11
+ Ground rules for this version.
12
+
13
+ 1. **Reads go through the relational query API** (`db.query.<table>.findFirst`
14
+ / `.findMany`), not `db.select().from()`. Relations are pre-registered in
15
+ `db/connection.server.ts`, so `with` joins work with zero boilerplate.
16
+ 2. **`db.select()` takes no argument in rc.3.** The projection overload
17
+ (`db.select({ ... })`) was removed. Passing a projection object is a
18
+ `TS2554` compile error.
19
+ 3. **`.returning()` takes no argument in rc.3.** It always returns the full
20
+ inserted / updated / deleted rows (columns only, no relations). Passing a
21
+ field object is a `TS2554` compile error.
22
+
23
+ All examples assume the scaffold imports.
24
+
25
+ ```ts
26
+ import { eq, and, inArray, desc } from 'drizzle-orm';
27
+ import { db } from '#db/connection.server.ts';
28
+ import { posts, users } from '#db/schema.server.ts';
29
+ ```
30
+
31
+ These belong in a `*.server.ts` query or action (never a page or component,
32
+ invariant 1). Reads live in `modules/<feature>/queries/*.server.ts`, mutations
33
+ in `modules/<feature>/actions/*.server.ts`, one function per file.
34
+
35
+ ---
36
+
37
+ ## Reads: use the relational query API
38
+
39
+ `db.query.<table>` reads a whole row by default, filters with a **plain object**
40
+ `where` (the RQBv2 shape, not a `sql` expression), orders with an `orderBy`
41
+ object, and pulls relations with `with`. This is the shape every scaffold query
42
+ uses.
43
+
44
+ ```ts
45
+ // RIGHT: single row by a unique column
46
+ const post = await db.query.posts.findFirst({
47
+ where: { slug: input.slug },
48
+ with: { author: { columns: { name: true, email: true } } },
49
+ });
50
+
51
+ // RIGHT: a list, newest first, with a projected column set
52
+ const rows = await db.query.posts.findMany({
53
+ orderBy: { createdAt: 'desc' },
54
+ where: { authorId: me.id },
55
+ columns: { id: true, slug: true, title: true },
56
+ with: { author: { columns: { name: true } } },
57
+ });
58
+
59
+ // RIGHT: existence check, pull only the id
60
+ const exists = await db.query.posts.findFirst({
61
+ where: { slug },
62
+ columns: { id: true },
63
+ });
64
+ ```
65
+
66
+ The `where` object supports equality directly (`{ slug }`), nested operators
67
+ (`{ createdAt: { gt: cutoff } }`), and `AND` by listing multiple keys. Reach for
68
+ the imported `eq` / `and` / `inArray` operators only on the `db.select` /
69
+ `db.delete` / `db.update` builders below, where the `.where()` clause is an SQL
70
+ expression rather than the RQBv2 object.
71
+
72
+ ### Pitfall: the `db.select({ ... })` projection overload is gone
73
+
74
+ Older Drizzle let you project columns by passing an object to `select`. rc.3
75
+ removed that overload, so the argument no longer type-checks.
76
+
77
+ ```ts
78
+ // WRONG (rc.3): TS2554 "Expected 0 arguments, but got 1"
79
+ const rows = await db
80
+ .select({ id: posts.id, title: posts.title })
81
+ .from(posts);
82
+ ```
83
+
84
+ If you genuinely need the query-builder (a manual join, a `groupBy`, an
85
+ aggregate the RQB does not express), call `select` with **no arguments** to get
86
+ the full row, then narrow in JS.
87
+
88
+ ```ts
89
+ // RIGHT: full-row select, no projection argument
90
+ const rows = await db.select().from(posts).where(eq(posts.authorId, me.id));
91
+ ```
92
+
93
+ For anything the relational API covers (by-id lookups, ordered lists, relation
94
+ joins, column subsets), prefer `db.query.*`. It is the shorter, typed,
95
+ relation-aware path and it sidesteps the removed overload entirely.
96
+
97
+ ---
98
+
99
+ ## Mutations: `insert` / `update` / `delete` with a no-arg `.returning()`
100
+
101
+ Mutations use the query-builder, filter with the imported SQL operators, and
102
+ read back rows with `.returning()`. In rc.3 `.returning()` has **only the
103
+ no-argument overload**, so it yields the full row set every time.
104
+
105
+ ```ts
106
+ // RIGHT: insert, read back the created row
107
+ const [row] = await db
108
+ .insert(posts)
109
+ .values({ title, body, slug, authorId: me.id })
110
+ .returning();
111
+
112
+ // RIGHT: update by id, read back the updated row
113
+ const [row] = await db
114
+ .update(posts)
115
+ .set({ title, body })
116
+ .where(eq(posts.id, id))
117
+ .returning();
118
+
119
+ // RIGHT: delete by id (no read-back needed)
120
+ await db.delete(posts).where(eq(posts.id, id));
121
+ ```
122
+
123
+ ### Pitfall: `.returning({ ... })` with a field argument
124
+
125
+ The older field-selecting `.returning({ id: posts.id })` overload does not exist
126
+ in rc.3.
127
+
128
+ ```ts
129
+ // WRONG (rc.3): TS2554 "Expected 0 arguments, but got 1"
130
+ const [row] = await db
131
+ .insert(posts)
132
+ .values({ title, body, slug, authorId: me.id })
133
+ .returning({ id: posts.id, slug: posts.slug });
134
+ ```
135
+
136
+ Call `.returning()` bare and destructure or map the fields you need in JS.
137
+
138
+ ```ts
139
+ // RIGHT: full row back, then pick fields
140
+ const [row] = await db.insert(posts).values({ ... }).returning();
141
+ const created = { id: row.id, slug: row.slug };
142
+ ```
143
+
144
+ ### `.returning()` yields columns only, never relations
145
+
146
+ A `.returning()` row is the table's own columns. It does NOT carry `with`
147
+ relations the way a `db.query.*` read does. When the caller expects a joined
148
+ shape (an author name alongside the post), insert then re-read, or splice the
149
+ already-known related value in by hand.
150
+
151
+ ```ts
152
+ // RIGHT: insert returns columns; add the known author locally
153
+ const [row] = await db.insert(posts).values({ title, body, slug, authorId: me.id }).returning();
154
+ return { success: true, data: { ...formatPost(row), authorName: me.name } };
155
+ ```
156
+
157
+ ---
158
+
159
+ ## Column helpers live in `db/columns.server.ts` (the one dialect seam)
160
+
161
+ The schema is written against helpers (`table`, `pk`, `uuidPk`, `uuid`, `bool`,
162
+ `timestamp`, `createdAt`, `updatedAt`, `index`) rather than raw drizzle
163
+ builders, so the same `db/schema.server.ts` runs on SQLite and Postgres. Only
164
+ `db/columns.server.ts` differs per dialect. Two of those helpers exist because
165
+ rc.3 has not yet exposed a stable no-arg overload.
166
+
167
+ - **`table`** wraps `sqliteTableCreator((name) => name, 'snake_case')`. In rc.3
168
+ the snake_case casing lives on the table factory, so column keys map to
169
+ snake_case SQL names automatically. Do not restate a `snake_case` casing
170
+ option on `drizzle()`.
171
+ - **`index(...cols)`** wraps rc.3's `index(name)`, which still requires a name
172
+ argument the runtime auto-fills. The helper synthesizes drizzle-kit's own
173
+ collision-free name, so schema authors call `index(t.createdAt)` with no name.
174
+ Replace it with a bare `index()` once 1.0 stable ships the no-arg overload.
175
+
176
+ When you add a table, use the helpers (`pk()` for an integer autoincrement id,
177
+ `uuidPk()` for an app-generated uuid string id, `createdAt()` for a
178
+ default-to-now timestamp) rather than reaching for raw `integer(...).primaryKey(...)`
179
+ call sites, so the Postgres swap stays a one-file change.
180
+
181
+ ---
182
+
183
+ ## Where each query shape belongs
184
+
185
+ | Task | API | File |
186
+ |---|---|---|
187
+ | Row by unique column | `db.query.t.findFirst({ where })` | `queries/*.server.ts` |
188
+ | Ordered / filtered list | `db.query.t.findMany({ orderBy, where, with })` | `queries/*.server.ts` |
189
+ | Manual join / aggregate | `db.select().from(t)` (no projection arg) | `queries/*.server.ts` |
190
+ | Create | `db.insert(t).values(...).returning()` | `actions/*.server.ts` |
191
+ | Update | `db.update(t).set(...).where(eq(...)).returning()` | `actions/*.server.ts` |
192
+ | Delete | `db.delete(t).where(eq(...))` | `actions/*.server.ts` |
193
+
194
+ Cross-references. Derived row TYPES and the `ActionResult<T>` envelope belong in
195
+ a browser-safe `modules/<feature>/types.ts` (see `agent-docs/types-and-mutations.md`).
196
+ Full page / action / query recipes live in `agent-docs/recipes.md`. The
197
+ server-only boundary that keeps `db` off the client is in the root `AGENTS.md`
198
+ (invariant 1).
@@ -407,6 +407,142 @@ for `fetch` + a JS submit handler would break the no-JS baseline. Use a
407
407
  See `agent-docs/advanced.md` for the client-router side (how the enhanced
408
408
  303/422 swap works) and the rest of the form-submission behavior.
409
409
 
410
+ ## Snappy UX: Form mutation with Optimistic UI updates
411
+
412
+ When building interactive applications (like a Todo app, chat app, or likes toggle), using Optimistic UI creates a Snappy UX by immediately updating the UI state in the browser before the server confirms the operation. **Default to `optimistic()` from `@webjsdev/core` for every mutation where the client can predict the result.** Only skip it when the result is unpredictable (AI-generated content), the user must wait for side effects (payment, email), or the mutation is destructive with no undo.
413
+
414
+ To support progressive enhancement, write a baseline `<form>` that works without JS, then enhance the component in the browser using JS.
415
+
416
+ ### Optimistic UI Recipe
417
+
418
+ 1. **The Server Action**:
419
+ Create a standard server action that performs the database write and returns the new database entity.
420
+
421
+ ```ts
422
+ // modules/todos/actions/create-todo.server.ts
423
+ 'use server';
424
+ import { db } from '../../../db/connection.server.ts';
425
+ import { todos } from '../../../db/schema.server.ts';
426
+
427
+ export async function createTodo(input: { title: string }) {
428
+ const title = String(input.title || '').trim();
429
+ if (!title) return { success: false, error: 'Title is required', status: 400 };
430
+
431
+ const [todo] = await db.insert(todos).values({ title, completed: false }).returning();
432
+ return { success: true, data: todo };
433
+ }
434
+ ```
435
+
436
+ 2. **The Component with `optimistic()` (preferred)**:
437
+ Use the declarative `optimistic(host, { source, update })` API. It tracks a queue of pending updates, computes the combined value via a reducer, and auto-releases when the promise settles. No try-catch, no manual rollback, no temp-ID bookkeeping.
438
+
439
+ ```ts
440
+ // components/todo-list.ts
441
+ import { html, WebComponent, prop, optimistic } from '@webjsdev/core';
442
+ import { createTodo } from '../modules/todos/actions/create-todo.server.ts';
443
+ import { Todo } from '../modules/todos/types.ts';
444
+
445
+ export class TodoList extends WebComponent({
446
+ todos: prop<Todo[]>(Array),
447
+ }) {
448
+ private optimisticTodos = optimistic(this, {
449
+ source: () => this.todos,
450
+ update: (state, title: string) => [
451
+ ...state,
452
+ { id: crypto.randomUUID() as any, title, completed: false, pending: true },
453
+ ],
454
+ });
455
+
456
+ async handleSubmit(e: SubmitEvent) {
457
+ e.preventDefault();
458
+ const form = e.target as HTMLFormElement;
459
+ const title = String(new FormData(form).get('title') || '').trim();
460
+ if (!title) return;
461
+
462
+ form.reset();
463
+
464
+ const promise = createTodo({ title });
465
+ this.optimisticTodos.add(title, promise);
466
+
467
+ const result = await promise;
468
+ if (result.success && result.data) {
469
+ this.todos = [
470
+ ...this.todos.filter(t => t.pending),
471
+ result.data,
472
+ ...this.todos.filter(t => !t.pending),
473
+ ];
474
+ }
475
+ }
476
+
477
+ render() {
478
+ return html`
479
+ <form @submit=${this.handleSubmit}>
480
+ <input type="text" name="title" placeholder="New todo..." required />
481
+ <button type="submit">Add</button>
482
+ </form>
483
+
484
+ <ul>
485
+ ${this.optimisticTodos.value.map(
486
+ (todo) => html`
487
+ <li class=${todo.pending ? 'opacity-50 pointer-events-none' : ''}>
488
+ ${todo.title}
489
+ </li>
490
+ `
491
+ )}
492
+ </ul>
493
+ `;
494
+ }
495
+ }
496
+ TodoList.register('todo-list');
497
+ ```
498
+
499
+ For simple boolean flips, the imperative `optimistic(signal, value, action)` is cleaner:
500
+
501
+ ```ts
502
+ import { signal, optimistic } from '@webjsdev/core';
503
+ import { likePost } from '#actions/like-post.server.ts';
504
+
505
+ const liked = signal(false);
506
+ // in an @click handler:
507
+ await optimistic(liked, true, () => likePost(postId));
508
+ ```
509
+
510
+ 3. **Manual optimistic (when you cannot use `optimistic()`)**:
511
+ If you need fine-grained control (custom loading states, partial rollbacks, multi-step reconciliation), write the manual pattern:
512
+
513
+ - Declare a reactive property to hold the list.
514
+ - Generate a temporary ID, construct the optimistic item, update state immediately.
515
+ - Run the server action.
516
+ - Reconcile: replace the optimistic item on success, revert on failure.
517
+
518
+ ```ts
519
+ async handleSubmit(e: SubmitEvent) {
520
+ e.preventDefault();
521
+ const form = e.target as HTMLFormElement;
522
+ const title = String(new FormData(form).get('title') || '').trim();
523
+ if (!title) return;
524
+ form.reset();
525
+
526
+ const tempId = `temp-${crypto.randomUUID()}`;
527
+ const previousTodos = this.todos;
528
+ this.todos = [
529
+ { id: tempId as any, title, completed: false, pending: true },
530
+ ...this.todos,
531
+ ];
532
+
533
+ try {
534
+ const result = await createTodo({ title });
535
+ if (result.success && result.data) {
536
+ this.todos = this.todos.map((t) => (t.id === tempId ? result.data! : t));
537
+ } else {
538
+ this.todos = previousTodos;
539
+ }
540
+ } catch {
541
+ this.todos = previousTodos;
542
+ }
543
+ }
544
+ ```
545
+
410
546
  ## Receive and persist an uploaded file
411
547
 
412
548
  A file upload is just a `<form enctype="multipart/form-data">` posting to a page
@@ -11,9 +11,9 @@ user-facing reference is the docs-site page `/docs/runtime`; keep the two in syn
11
11
 
12
12
  | Area | Node 24+ | Bun |
13
13
  |---|---|---|
14
- | Install | `npm install` (required) | optional (zero-install via Bun auto-install) |
14
+ | Install | `npm install` (required) | `bun install` (required, like Node) |
15
15
  | Run | `npm run dev` / `npm run start` | `bun run dev` / `bun run start` |
16
- | Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the listening path) |
16
+ | Listener | `node:http` shell | native `Bun.serve` (about 1.9x req/s on the LISTENING PATH only, a trivial-handler microbenchmark, NOT end-to-end: for a real SSR page the render dominates and the delta is small, see `scripts/bench-listener.mjs`) |
17
17
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
18
18
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
19
19
  | Hot reload | `node --watch` | `bun --hot` |
@@ -29,52 +29,26 @@ correctness (the modulepreload hints still ship in the document head).
29
29
  `webjs create <name>` defaults to Node. `webjs create <name> --runtime bun` (or
30
30
  `bun create webjs <name>`, auto-detected from the invoking package manager,
31
31
  #541) emits a Bun-flavored app: `bun.lock`, a pure `oven/bun:1` Dockerfile +
32
- bun-install CI, bun-command agent docs, and the zero-install bootstrap below.
33
- `--runtime` is orthogonal to `--template`.
32
+ bun-install CI, and bun-command agent docs. `--runtime` is orthogonal to
33
+ `--template`.
34
34
 
35
- ## Bun zero-install (#675)
35
+ ## Bun
36
36
 
37
- A Bun app's `dev` / `start` / `db` scripts run through a generated app-local
38
- `webjs-bun.mjs` bootstrap under `bun --bun`:
37
+ A Bun app installs with `bun install` (like Node), then its `dev` / `start` /
38
+ `db` scripts force `bun --bun` so the server runs on Bun:
39
39
 
40
- ```js
41
- // webjs-bun.mjs
42
- await import('@webjsdev/cli/bin/webjs.js');
40
+ ```sh
41
+ bun install
42
+ bun run dev # or: bun run start
43
43
  ```
44
44
 
45
- `bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun;
46
- importing the CLI by bare specifier lets Bun auto-install resolve `@webjsdev/*`
47
- and the app's deps on demand, so a fresh app serves with **no `bun install`**.
48
- The CLI's `start` is in-process and `dev` re-execs via `process.execPath` (which
49
- is `bun` here), so the server stays on Bun once the CLI does. `bunx
50
- @webjsdev/cli` is deliberately NOT used (it runs on Node via the shebang AND
51
- eager-installs the whole tree). The `start.before` migrate also routes through
52
- the bootstrap, so the boot-time `webjs db migrate` needs no `webjs` bin in
53
- `node_modules`. `bun install` stays optional: run it for editor type
54
- intelligence (no `node_modules` means no local type files) or a pinned offline
55
- install. The Node-targeted tooling scripts (`test` / `check` / `typecheck`)
56
- stay plain `webjs` on Node and still expect an install.
57
-
58
- **Version pinning (#685).** Bun's auto-install fetches `latest` for a bare
59
- `import 'zod'`, ignoring package.json and `bun.lock` (it consults neither the
60
- manifest, the lockfile, nor an `onResolve` plugin, verified in #684). webjs
61
- closes that gap with an `onLoad` transform: it rewrites a declared dep's bare
62
- specifier to an inline-versioned one (`zod` to `zod@3.22.4`), which Bun's
63
- auto-install DOES honor. The version is the EXACT one from `bun.lock` when
64
- present, else an exact `package.json` pin. Bun resolves an exact inline version
65
- but ENOENTs on a range or dist-tag (`zod@^3`, `zod@latest`), so a dep declared
66
- as a RANGE without a `bun.lock` is left bare and resolves to latest as before:
67
- commit a `bun.lock` (or pin exact versions) to get pinned zero-install. The
68
- rewrite is server-runtime only (it shapes what Bun fetches for SSR and server
69
- actions). The browser is still served bare specifiers resolved via the importmap
70
- / jspm, unchanged. Only declared deps are rewritten (a transitive dep follows
71
- from its pinned parent's manifest), and it is a no-op when `node_modules` exists
72
- (Bun uses the installed copy). Default on. Opt out with `WEBJS_PIN=0` or
73
- `{ "webjs": { "pin": false } }`.
74
-
75
- **Reproducibility:** dev resolves on demand (now at the pinned versions), and the
76
- scaffold's Bun Dockerfile still keeps an explicit `bun install` so a prod image
77
- is immutable and self-contained with no registry fetch at boot.
45
+ `bun --bun` overrides the `webjs` bin's Node shebang so the server runs on Bun
46
+ (selecting the native `Bun.serve` listener and `amaro` type stripping); the app's
47
+ deps resolve from `node_modules`, the same as Node. The `start.before` migrate
48
+ (`webjs db migrate`) runs under Bun too. Commit a `bun.lock` (the Bun analog of
49
+ `package-lock.json`) for reproducible, offline installs; the scaffold's Bun
50
+ Dockerfile runs `bun install` and serves via `CMD ["bun", "--bun", "run",
51
+ "start"]`.
78
52
 
79
53
  ## SQLite busy_timeout (#674)
80
54
 
@@ -39,7 +39,11 @@ and emit `<script nonce="${cspNonce()}">…`.
39
39
  ## What the worker does (scope + strategy)
40
40
 
41
41
  Registered from `/sw.js`, the worker's scope is the site root (`/`), so it sees
42
- every navigation and same-origin asset request.
42
+ every navigation and same-origin asset request. Your worker file lives at
43
+ `public/sw.js`, and although most `public/*` assets serve at `/public/<name>`,
44
+ the framework serves this one (and `public/offline.html`) at the SITE ROOT with a
45
+ `Service-Worker-Allowed: /` header, so `register('/sw.js')` resolves to a 200 and
46
+ the worker controls the whole origin (#830).
43
47
 
44
48
  - **Navigations are network-first.** The worker always tries the network first,
45
49
  so the user sees fresh server-rendered HTML, and caches each successful page
@@ -24,7 +24,7 @@ app work.
24
24
  | Kind | Location | What it does | Runner |
25
25
  |---|---|---|---|
26
26
  | **unit + integration** (node) | `test/<feature>/<name>.test.{js,ts,mjs}` | Imports modules and asserts. No spawned process, no network. Includes both true unit tests and in-process integration. | `node --test` |
27
- | **browser** | `test/<feature>/browser/<name>.test.js` | Runs in real Chromium with Playwright. Real DOM, events, `adoptedStyleSheets`, `IntersectionObserver`, shadow / light DOM. | web-test-runner (`wtr`) |
27
+ | **browser** | `test/<feature>/browser/<name>.test.js` | Runs in real Chromium, Firefox, AND WebKit via Playwright (#774). Real DOM, events, `adoptedStyleSheets`, `IntersectionObserver`, shadow / light DOM. WebKit is the engine behind the iOS-only repaint bugs (the #610 class), so it runs in CI now, not just by hand on-device. | web-test-runner (`wtr`) |
28
28
  | **e2e** | `test/<feature>/e2e/<name>.test.{ts,mjs}` | Boots a real process (dev server, CLI binary) and drives it through its public interface (HTTP, browser, stdout). Opt in with `WEBJS_E2E=1`. | `node --test` (driver) |
29
29
  | **smoke** | `test/<feature>/smoke/<name>.test.{js,ts}` | Fast deploy-time sanity check. A subset of e2e in spirit, kept separate so it runs on every deploy. | `node --test` |
30
30
 
@@ -33,6 +33,35 @@ Some packages will only have one or two of these kinds (e.g.
33
33
  a kind subfolder when there's at least one test of that kind.
34
34
  Empty `e2e/` folders are anti-patterns.
35
35
 
36
+ ### App browser tests import your REAL components (#806)
37
+
38
+ In a scaffolded app, a browser test (`test/<feature>/browser/<name>.test.js`)
39
+ can `import` your real `.ts` components, **including ones that call a
40
+ `'use server'` action**, and they load in a real browser. `webjs test --browser`
41
+ runs each test through the same pipeline as `webjs dev`: the `.ts` is type-
42
+ stripped, a `.server.ts` action import is rewritten to its RPC stub, `#` aliases
43
+ and `@webjsdev/core` resolve via the injected importmap. So you write:
44
+
45
+ ```js
46
+ // test/todos/browser/todo-list.test.js
47
+ import { ssrFixture } from '@webjsdev/core/testing';
48
+ import { html } from '@webjsdev/core';
49
+
50
+ suite('todo-list', () => {
51
+ test('adds a row', async () => {
52
+ await import('../../../components/todo-list.ts'); // imports create-todo.server.ts
53
+ const el = await ssrFixture(html`<todo-list></todo-list>`);
54
+ el.querySelector('button')?.click(); // fires the action RPC
55
+ // assert on the DOM...
56
+ });
57
+ });
58
+ ```
59
+
60
+ The wiring lives in the scaffolded `web-test-runner.config.js` (you don't set it
61
+ up). Keep the browser test file itself `.js`; the components + modules it imports
62
+ are `.ts` and are served transformed. This is why a component that talks to the
63
+ server can be tested in a real browser, not just in a node test.
64
+
36
65
  ---
37
66
 
38
67
  ## Framework layout (this repo)
@@ -99,8 +128,18 @@ package, it goes in that package's `test/`.
99
128
  - `npm run test:browser` → `wtr`: globs
100
129
  `packages/*/test/**/browser/**/*.test.js` and
101
130
  `test/**/browser/**/*.test.js`. The blog browser e2e is
102
- excluded (it needs the blog dev server up first).
103
- - `npm run test:e2e` → `WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs`.
131
+ excluded (it needs the blog dev server up first). Runs on Chromium,
132
+ Firefox, and WebKit concurrently; narrow to one engine for a fast local
133
+ loop with `WEBJS_BROWSERS=chromium npx wtr` (comma-separate for a subset).
134
+ - `npm run test:e2e` → `WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs`. In CI
135
+ this runs as two PARALLEL jobs (#774): the Node-served suite (the required
136
+ merge-gate check) and a Bun-served run (`WEBJS_E2E_RUNTIME=bun`) in a
137
+ separate `e2e-bun` job, so the cross-runtime e2e no longer serializes behind
138
+ the Node run.
139
+ - `npm run test:coverage` → `WEBJS_COVERAGE=1 npm test`: the node suite with
140
+ Node's built-in coverage reporter (#774). Opt-in, kept off the default
141
+ `npm test` so the common path stays fast; excludes test files and the
142
+ `scripts/` harness so the numbers reflect shipped source.
104
143
  - `npm run test:all` runs node + browser (not e2e).
105
144
 
106
145
  The root drivers above ONLY discover the framework packages and the root
@@ -195,7 +234,7 @@ ui-website ship no test suite yet, so they are not in the job.
195
234
 
196
235
  ## Component test helpers (`@webjsdev/core/testing`)
197
236
 
198
- `import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations, click, shadowQuery, shadowQueryAll } from '@webjsdev/core/testing'`. The mount + hydrate + a11y helpers run in the WTR Chromium session (real DOM), thin wrappers over the browser already running.
237
+ `import { fixture, ssrFixture, waitForUpdate, assertNoA11yViolations, click, shadowQuery, shadowQueryAll } from '@webjsdev/core/testing'`. The mount + hydrate + a11y helpers run in the WTR browser session (real DOM, on each of Chromium / Firefox / WebKit), thin wrappers over the browser already running.
199
238
 
200
239
  ### `fixture()` vs `ssrFixture()`
201
240
 
@@ -330,8 +369,9 @@ enforces:
330
369
  - **the wire serializer** (a `Date` / `Map` / `BigInt` arg or return is
331
370
  genuinely encoded + decoded, not passed by reference),
332
371
  - **CSRF** (a missing token is a 403),
333
- - **prod error sanitization** (a thrown error surfaces as a sanitized
334
- message-only payload, never the stack or extra error fields).
372
+ - **prod error sanitization** (#749: a thrown error surfaces as a GENERIC
373
+ message plus a correlation `digest`, never the raw message, stack, or extra
374
+ error fields; the thrown error carries `.digest`).
335
375
 
336
376
  So `invokeActionForTest` catches a serializer / CSRF / error-sanitization
337
377
  regression a direct import cannot see. For the negative cases (assert a 403 on
@@ -406,6 +446,7 @@ a feature, the test is probably testing too many things at once.
406
446
  Kind is a child of feature, not the other way around.
407
447
  - **Don't create empty kind folders.** If `e2e/` has no tests
408
448
  yet, leave it absent.
449
+ - **Don't attempt to run full browser-based tests (WTR/Playwright) inside headless VM sandboxes** if they lack compiler transformation plugins (e.g. for TypeScript components or RPC action stubbing) or have missing native browser OS libraries. Instead, extract components' reconciliation or optimistic UI update logic into pure, browser-safe utilities and cover them using server-side Node.js unit tests.
409
450
  - **Don't put package-only tests under root `test/`.** It hurts
410
451
  the per-package `npm test --workspace=…` workflow.
411
452
  - **Don't import from another package's `test/` directory.**