@webjsdev/mcp 0.1.6 → 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.
- package/package.json +1 -1
- package/resources/AGENTS.md +94 -13
- package/resources/agent-docs/advanced.md +97 -14
- package/resources/agent-docs/built-ins.md +17 -7
- package/resources/agent-docs/components-shadowing.md +87 -0
- package/resources/agent-docs/components.md +35 -13
- package/resources/agent-docs/configuration.md +1 -1
- package/resources/agent-docs/framework-dev.md +8 -0
- package/resources/agent-docs/orm.md +198 -0
- package/resources/agent-docs/recipes.md +136 -0
- package/resources/agent-docs/runtime.md +1 -1
- package/resources/agent-docs/service-worker.md +5 -1
- package/resources/agent-docs/testing.md +47 -6
- package/resources/agent-docs/types-and-mutations.md +171 -0
- package/src/mcp-source.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The webjs Model Context Protocol server: live app introspection (routes / actions / components / check) plus the framework knowledge layer (docs, recipes, source) for AI coding agents",
|
|
6
6
|
"bin": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -22,6 +22,9 @@ reference there.
|
|
|
22
22
|
| `agent-docs/testing.md` | Unit, browser, convention validation, the `handle()` test harness (`@webjsdev/server/testing`) |
|
|
23
23
|
| `agent-docs/framework-dev.md` | Monorepo dev (only when editing webjs itself): commands, repo-health git config, changelog flow, dev error overlay |
|
|
24
24
|
| `agent-docs/recipes.md` | Page / route / action / component recipes |
|
|
25
|
+
| `agent-docs/orm.md` | Drizzle rc.3 query surface: `db.query` reads, the removed `db.select({...})` projection overload, no-arg `.returning()` (#804) |
|
|
26
|
+
| `agent-docs/types-and-mutations.md` | Where server-derived types live so a shipping module does not pin `db/*.server.ts` (`import type` vs value import, the carrier rule) (#804) |
|
|
27
|
+
| `agent-docs/components-shadowing.md` | Inherited `WebComponent` members app code must not shadow (`title`, `remove`, ...) and the `TS2415`/`TS2416` each produces (#804) |
|
|
25
28
|
| `agent-docs/lit-muscle-memory-gotchas.md` | **READ FIRST** when writing components. Lit patterns that break webjs SSR or reactivity, with the webjs-shaped fix for each |
|
|
26
29
|
|
|
27
30
|
---
|
|
@@ -50,6 +53,8 @@ cd ../<repo>-<slug> # do ALL work for the task here
|
|
|
50
53
|
|
|
51
54
|
Git enforces one-branch-per-worktree, so separate worktrees make the collision impossible. Before any commit in a shared checkout, confirm `git branch --show-current` is still the branch you created; if it moved, you are colliding, switch to a worktree. A lone agent in a clean checkout may still use a plain branch. The repo's `.hooks/pre-commit` additionally BLOCKS a published-library (`core`/`server`/`cli`/`mcp`/`ui`/`intellisense`) version bump on any non-`chore/release-*` branch, the canonical wrong-branch-release symptom.
|
|
52
55
|
|
|
56
|
+
**Cleanup is automatic after a merge.** The `.claude/hooks/cleanup-merged-worktree.sh` PostToolUse hook fires after any `gh pr merge` and removes each linked worktree whose branch is merged AND whose tree is clean, so a merged branch's worktree never leaks (accumulated stale worktrees are exactly what it prevents). It is conservative: it KEEPS anything with uncommitted changes, an unmerged branch, or the worktree you ran the merge from (you cannot remove your current directory, so `cd` out and `git worktree remove` it yourself), and never touches the primary checkout. Disable with `WEBJS_NO_WORKTREE_CLEANUP=1`. Test: `test/hooks/cleanup-merged-worktree.test.mjs`.
|
|
57
|
+
|
|
53
58
|
### Skills are routed deterministically, never skipped
|
|
54
59
|
|
|
55
60
|
A Skill is model-invoked, so it fires only when the model judges a match. The `.claude/hooks/route-skills.sh` `UserPromptSubmit` hook makes routing deterministic: it keyword-matches each prompt against every skill's documented triggers and injects a directive to invoke the matched skill before other work. Check the available skills and invoke a matching one before starting. The skills themselves are committed under `.claude/skills/` (alongside the hooks), so a fresh clone has both the router and the skills it routes to (no machine-local dependency). Tests in `test/hooks/route-skills.test.mjs`, which also asserts every skill the hook references is committed in-repo.
|
|
@@ -64,7 +69,8 @@ Every code change MUST include, automatically:
|
|
|
64
69
|
|
|
65
70
|
1. **Tests, every applicable layer (not just unit).** Ship the tests that prove the change across EVERY layer it touches: **unit** (`packages/*/test/**`, `test/**`, including the counterfactual that fails when reverted), **browser** (`*/test/**/browser/*` via `npm run test:browser`, for hydration / DOM / slots / client router / custom-element upgrade), **e2e** (`test/e2e/*.test.mjs` via `WEBJS_E2E=1`, including network probes / navigation / streaming), and **smoke** (`test/examples/*/smoke/*`). A unit test is NECESSARY BUT NOT SUFFICIENT for any client-router / component / browser-facing change (the headline behaviour is a browser/e2e assertion). **Bun parity is part of the task, not an afterthought:** webjs runs on Node 24+ AND Bun (#508), so a change to a runtime-sensitive surface (the serializer, the node:http vs `Bun.serve` listener + request path, SSR / action / CSRF dispatch, streams, `node:crypto`, the TS stripper, auth / session / cors) MUST be proven on Bun (`node scripts/run-bun-tests.js` + the touched `test/bun/*.mjs` under `bun`) AND ship an added/updated `test/bun/<feature>.mjs` cross-runtime assertion. `npm test` does NOT run browser, e2e, or Bun; run them yourself and report the result. Never report work done with failing or missing tests. See `agent-docs/testing.md`. Enforced by `.claude/hooks/require-tests-with-src.sh` (the scaffold variant WARNS unless `WEBJS_TEST_GATE=block`) and `.claude/hooks/require-bun-parity-with-runtime-src.sh` (BLOCKS a commit that stages runtime-sensitive source with no `test/bun/**` test; escape hatch `WEBJS_BUN_VERIFIED=1`).
|
|
66
71
|
2. **Documentation, part of the definition of done (not optional).** A task is NOT done until EVERY doc surface its change touches is in sync: `AGENTS.md` + `agent-docs/*.md` for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`docs/app/docs/<topic>`), the marketing `website/`, the scaffold templates (`packages/cli/templates/` per-agent rule files), and `README.md` for a headline capability. Updating `AGENTS.md` alone reproduces the #488 gap (docs site left stale). Invoke the `webjs-doc-sync` skill to sync every applicable surface. Enforced by `.claude/hooks/require-docs-with-src.sh`, which BLOCKS a commit that stages public `packages/*/src` source with no doc surface alongside it (a genuinely internal refactor / CI / release / perf change with no behaviour change bypasses with `WEBJS_NO_DOC_GATE=1`).
|
|
67
|
-
3. **
|
|
72
|
+
3. **Scaffold sync, part of the definition of done (not optional).** The scaffold is webjs's PRIMARY teaching surface for AI agents: they learn the framework by reading the generated gallery/showcase and its comments, then build the real app by adapting them. So when a webjs FEATURE is added or changed, the scaffold that teaches it must move too, the same way the docs must. Ask on EVERY feature change: does a UI feature-gallery demo (`packages/cli/templates/gallery/`, shipped in full-stack + saas), an api backend-showcase endpoint (`packages/cli/lib/api-gallery.js`), a generator surface (`packages/cli/lib/{create,saas-template}.js` for the home/theme/schema/wiring), a scaffold convention (the per-agent rule files in lockstep), or a scaffold test (`test/scaffolds/**`) need to change? Invoke the `webjs-scaffold-sync` skill to walk every surface AND run its mandatory generate + boot + `webjs check` verification (the generators emit strings, so an escaping bug only shows in a freshly generated app). Enforced by `.claude/hooks/require-scaffold-with-src.sh`, which BLOCKS a commit that stages `packages/(core|server|cli)/src` feature source with no scaffold surface (`packages/cli/templates` or `packages/cli/lib`) alongside it (a bug fix, an internal refactor, or a tweak to an already-demoed feature that needs no scaffold change bypasses with `WEBJS_NO_SCAFFOLD_GATE=1`).
|
|
73
|
+
4. **Convention validation.** Run `webjs check` and fix violations.
|
|
68
74
|
|
|
69
75
|
### Git workflow (mandatory)
|
|
70
76
|
|
|
@@ -95,7 +101,7 @@ An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rai
|
|
|
95
101
|
- **Progressive enhancement is the default architecture.** Pages and components are SSR'd; with JS off, content reads, `<a>` navigates, `<form>` server actions submit, display-only elements render. JS is opt-in *per interactive behaviour* (`@click`, a reactive property assignment, a signal mutation). Never write a first paint that depends on hydration; never use `fetch` + JS where a `<form>` + server action would do.
|
|
96
102
|
- **Display-only components are elided from the browser.** A component with no interactivity signal renders identical HTML with or without its JS, so the framework strips its import (and any vendor reachable only through it, importmap entry included) from the served source. Automatic, conservative, verified differentially. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `agent-docs/components.md`.
|
|
97
103
|
- **Server actions with rich types.** A `*.server.{js,ts}` file with `'use server'` exports functions importable from the client (the import is rewritten to a typed RPC stub); the wire round-trips `Date` / `Map` / `Set` / `BigInt` / `Error` / typed arrays / `Blob` / `File` / `FormData` / Symbols / cycles. The source is never served to the browser (invariant 1).
|
|
98
|
-
- **Only files reachable from a browser-bound entry are servable.** The dev server walks the
|
|
104
|
+
- **Only files reachable from a browser-bound entry are servable.** The dev server walks the import graph from every page / layout / error / loading / not-found / component file (lazily on first request, re-derived after each `fs.watch` rebuild); that Set is the authorisation gate, so anything no client code imports returns 404. The walk follows static `import` / `export … from` AND string-literal dynamic `import('./x.ts')` edges (#751), so a lazily-imported app module is servable (it is NOT eagerly preloaded, since a dynamic import is lazy by intent). A computed `import(expr)` cannot be resolved statically and 404s with a dev hint.
|
|
99
105
|
- **Sensible defaults, overridable.** Memory store in dev, Redis when configured. Built-in auth, sessions, caching, rate limiting, file storage (all pluggable). Tailwind is the default styling. See `agent-docs/built-ins.md`.
|
|
100
106
|
|
|
101
107
|
---
|
|
@@ -111,9 +117,7 @@ webjs has **no server/client component split.** There is no RSC render tree, no
|
|
|
111
117
|
|
|
112
118
|
A page/layout module still **loads** in the browser for its top-level side effects: registering imported components (so their tags upgrade). The client router is automatic and needs no import: it auto-enables when `@webjsdev/core` loads in the browser (the bundle every component pulls), so any page that ships a component gets client navigation for free (#620). That load is also how its imports reach the client (`import dayjs` at the top of a page fetches dayjs when the module loads, not via hydration). An inert page/layout is dead weight, which is exactly when elision drops it. A page/layout that is non-inert ONLY because it imports interactive components is **import-only** (#605): since it never hydrates, the boot emits its component modules directly and drops the page/layout module, so the browser fetches just the interactive leaves (it still ships whole when it has its own client side effect: a `client-router` import, a module-scope call, or a self-registering bare import).
|
|
113
119
|
|
|
114
|
-
|
|
115
|
-
- module-scope client work: a top-level call, a `new SomethingNonData()`, a browser-global (`window` / `document` / `customElements`) access, or a bare side-effect import of a non-component or `@webjsdev/core/client-router` (routing is automatic). A page-template `@event` / inline-`<script>` is fine (it is SSR output, never module client work, #623 / #634).
|
|
116
|
-
- importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer.
|
|
120
|
+
- importing a client-effecting NON-component util. A helper that touches a client global or self-executes drags the whole page in. Put client-only behaviour inside a component; put server-only work in `.server.{js,ts}` (it never reaches the client closure); and if a util MIXES a pure helper with client-global code (the `cn.ts` shape, #619), split the client part into its own module so the pure helper does not pin every importer. Note: if a page imports a client-effecting utility (like `cn.ts` which accesses client globals), the page module is marked as client-effecting and will be browser-shipped. When this happens, importing any bare server-only utility (like `*.server.ts` queries) inside that page will trigger a `no-server-import-in-browser-module` check violation. To fix this, convert the query to a `'use server'` action or fetch it through a route handler.
|
|
117
121
|
|
|
118
122
|
Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the boot `<script type="module">`; if one does, something in its closure does client work and is not a component.
|
|
119
123
|
|
|
@@ -152,7 +156,7 @@ sitemap.js robots.js manifest.js icon.js opengraph-image.js twitter-image.js app
|
|
|
152
156
|
lib/ app-wide code (lib/*.server.js infra, lib/utils/ browser-safe helpers)
|
|
153
157
|
modules/<feature>/ feature-scoped: actions/ (mutations), queries/ (reads), components/, utils/, types.js
|
|
154
158
|
components/*.js SHARED presentational primitives
|
|
155
|
-
public/* static assets, served at /<name>
|
|
159
|
+
public/* static assets, served at /public/<name> (favicon, sw.js, offline.html serve at root)
|
|
156
160
|
db/*.server.{js,ts} data layer (Drizzle: schema, columns, connection)
|
|
157
161
|
```
|
|
158
162
|
|
|
@@ -195,12 +199,12 @@ The bare `@webjsdev/core` specifier resolves to a BROWSER bundle dropping server
|
|
|
195
199
|
| `register(tag, C)` | Tag binding. Auto-called by `Class.register('tag')`. |
|
|
196
200
|
| `render(v, el)` | Client-side render into a DOM element. |
|
|
197
201
|
| `renderToString` | Server-side async render to HTML with DSD (from `/server`). |
|
|
198
|
-
| `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. |
|
|
202
|
+
| `notFound()` / `redirect(url[, status])` | Throw to return 404, or a redirect. No-status default is convention-picked at the catching site: 302 for a GET page-render gate, 307 (method-preserving) for a server-action redirect. Override with `redirect(url, 308)` or `redirect(url, { status })`. **NEVER** throw `redirect()` inside API route handlers (`route.ts`), as they must return a standard `Response.redirect(url, 303)` response object instead. |
|
|
199
203
|
| `Suspense({fallback, children})` | Page/region-level streaming boundary (a value in a hole). `repeat` keyed-list directive is also re-exported. |
|
|
200
204
|
| `<webjs-suspense .fallback=${html\`…\`}>` | Component-level streaming boundary element (#471): wraps one or more components, flushes `.fallback` on the first byte, streams the resolved content in (concurrently across boundaries, progressively on soft nav). The renderer-recognized opt-in for SLOW async-render data. |
|
|
201
205
|
| `connectWS(url, handlers)` / `richFetch<T>` | Client WebSocket (auto-reconnect, queued sends); content-negotiated rich-type fetch. |
|
|
202
206
|
| `navigate(url, opts?)` / `revalidate(url?)` | Programmatic client-router nav; evict the BROWSER snapshot cache. |
|
|
203
|
-
| `optimistic(signal, value, action)` |
|
|
207
|
+
| `optimistic(signal, value, action)` / `optimistic(host, { source, update })` | Imperative: set `signal` immediately, run `action`, roll back on error. Declarative (preferred): queue optimistic updates with auto-release via `.add(payload, promise?)`. See `agent-docs/advanced.md`. |
|
|
204
208
|
| `renderStream(payload)` / `WebjsFrame` | `<webjs-stream>` element-level updates (#248); `<webjs-frame>` partial-swap regions (#253). See `agent-docs/advanced.md`. |
|
|
205
209
|
| `Metadata` / `PageProps<R>` / `LayoutProps<R>` / `RouteHandlerContext<R>` / `WebjsConfig` (type-only) | Types for metadata, page/layout/route args (`R` narrows `params` against the `webjs types` route union), and the `webjs` config block. See `agent-docs/metadata.md` + `agent-docs/configuration.md`. |
|
|
206
210
|
|
|
@@ -237,7 +241,7 @@ MyThing.register('my-thing');
|
|
|
237
241
|
|
|
238
242
|
**Signals are the default state primitive.** Import `signal` / `computed` from `@webjsdev/core`, read with `signal.get()` inside `render()`, and the built-in `SignalWatcher` re-renders on change. Module-scope signals share state across components and survive navigations; instance signals (constructor) are component-local.
|
|
239
243
|
|
|
240
|
-
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running per instance for a fresh object / array, but the constructor is the recommended way). NEVER use a class-field initializer (e.g., `count = 0`), which clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
|
|
244
|
+
**Reactive properties ride HTML attributes, reflect to them, or arrive via SSR hydration.** They are declared ONLY by passing the property shape into the base-class factory `WebComponent({ ... })`. There is no `static properties` block and no `declare` line. The bare form takes a type constructor (`count: Number`, `label: String`, `open: Boolean`); the `prop()` helper carries options (`count: prop(Number, { reflect: true })`, `name: prop(String, { attribute: 'show-close-button' })`, internal state `prop({ state: true })` with no type), and narrows the TS type (`student: prop<Student>(Object)`, `mode: prop<'a' | 'b'>(String, { reflect: true })`). The types flow automatically to `this.<prop>`. Set defaults in the constructor after `super()` (a declarative `default` option also exists for lit-parity, a function default running 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` or `todos!: Todo[]`), which compiles to define an own property on the instance and clobbers the reactive accessor (caught by `reactive-props-no-class-field`). Declaring your own `static properties` THROWS at runtime and is flagged by `no-static-properties`. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the prop's shape and `array-prop-uses-array-type` flags the `Object` form. Property options: `type` (default `String`), `reflect`, `state`, `hasChanged`, `converter`, `default`, `attribute`.
|
|
241
245
|
|
|
242
246
|
**Lifecycle (lit-aligned), in order:** `shouldUpdate`, `willUpdate`, controllers' `hostUpdate()`, `update` (calls `render()` + commits), controllers' `hostUpdated()`, `firstUpdated`, `updated`, `updateComplete`, each receiving a `changedProperties` Map. **SSR runs only the constructor, attribute application, the pre-render hooks (`willUpdate` / `hostUpdate`), `reflect: true` reflection, and `render()`; it does NOT call `connectedCallback`, `firstUpdated`, `updated`, or any browser-only hook.** So defaults for first paint go in the constructor; browser-only data (localStorage, viewport, `navigator.*`) goes in `connectedCallback` writing a signal; server-known data arrives via the page function. Never ship a placeholder first paint that fetches in `connectedCallback`. A browser-only global in the constructor/`render()` throws at SSR (flagged by `no-browser-globals-in-render`; attribute methods and `closest()` are shimmed).
|
|
243
247
|
|
|
@@ -286,7 +290,7 @@ Optional app-root file default-exporting a **schema object** (env-var names to a
|
|
|
286
290
|
| Plain `.ts` | yes | **Lint violation** (`use-server-needs-extension`). Rename to add `.server.`. |
|
|
287
291
|
| Plain `.ts` | no | Browser-safe; standard. |
|
|
288
292
|
|
|
289
|
-
The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module.
|
|
293
|
+
The server-only-utility row (`.server.ts`, no `'use server'`) is a runtime trap: its browser stub throws at module load, so a page / layout / component that ends up SHIPPING to the browser and transitively imports one crashes the moment the module loads, while `webjs typecheck` and the rest of `webjs check` pass. `webjs check`'s `no-server-import-in-browser-module` rule catches this statically by reusing the build's elision verdict (it only flags modules that genuinely ship; a display-only page the framework elides is fine, because the framework strips its server import). A `'use server'` action is exempt: its browser stub is a working RPC, which is the intended way to call the server from a shipping module. A TYPE-ONLY `import type { Row } from './x.server.ts'` is exempt too, because the TypeScript stripper erases it before it can reach the browser, so sharing a derived row type from a `.server.ts` into a shipping component is safe and is not flagged (#805).
|
|
290
294
|
|
|
291
295
|
Server actions export named async functions whose args + returns round-trip through the serializer. **Importing from a client component IS the API** (rewritten to an RPC stub; never hand-write `fetch()`). **REST over HTTP is a `route.ts`** that imports and calls the action (optionally via the `route(action, opts?)` adapter from `@webjsdev/server`, which merges query + route params + JSON body into one input object and JSON-responds). **Input validation (#245)** is declared via the `export const validate` config export (read on the RPC boundary); a `route()` endpoint passes the same validator as its `{ validate }` option. The framework only CALLS the validator (ships no validation library) and reads its return (`{ success: true, data? }` runs the action, `{ success: false, fieldErrors }` returns a `422` without running the body, a THROW is a sanitized error, any other value is transformed input). The validator stays server-side and receives the action's first argument. Full reference in `agent-docs/recipes.md`.
|
|
292
296
|
|
|
@@ -294,7 +298,7 @@ Server actions export named async functions whose args + returns round-trip thro
|
|
|
294
298
|
|
|
295
299
|
### RPC + REST endpoint security
|
|
296
300
|
|
|
297
|
-
Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. Prod errors are sanitized to `message`
|
|
301
|
+
Action RPC CSRF is an **Origin / `Sec-Fetch-Site` check** (the Remix 3 / Go 1.25 model), NOT a token cookie: a state-changing verb passes only when `Sec-Fetch-Site` is `same-origin` / `none`, or (older browsers, no `Sec-Fetch-Site`) the `Origin` host matches the request host, or the source is in `webjs.allowedOrigins`; otherwise 403. **No `Set-Cookie` rides the SSR HTML**, which is why a page that opts into a public `Cache-Control` (via `metadata.cacheControl`, e.g. on a root layout for a whole visitor-identical app) is CDN-edge-cacheable. A safe GET action is CSRF-exempt. **Prod action errors are sanitized to a GENERIC message plus a short `digest`** (#749): a thrown action never returns its raw message to the client (a DB-driver message, an internal IP, an fs path are not author-controlled), the full error is logged server-side keyed by that digest, and the streaming error frame is sanitized the same way; `redirect()` / `notFound()` control-flow throws pass through, and a user-facing message belongs on the `ActionResult` `{ success: false, error }` envelope, not on a throw. A `route.ts` REST endpoint (hand-written or via the `route()` adapter) is NOT covered by this check: authenticate every mutating endpoint, use `validate`, log without secrets, rate-limit. For CORS use the `cors()` middleware from `@webjsdev/server`; **`credentials: true` REQUIRES an explicit origin allowlist, never `'*'`.** See `agent-docs/advanced.md`.
|
|
298
302
|
|
|
299
303
|
### Components (`components/*.{js,ts}`)
|
|
300
304
|
|
|
@@ -336,9 +340,86 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
336
340
|
|
|
337
341
|
---
|
|
338
342
|
|
|
343
|
+
## Mutations: default to optimistic UI
|
|
344
|
+
|
|
345
|
+
**Use `optimistic()` from `@webjsdev/core` for every user-facing mutation** (create, update, delete, like, toggle, reorder) where the client can predict the result. The UI should feel instant; roll back automatically on failure.
|
|
346
|
+
|
|
347
|
+
### Preferred: declarative API
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
351
|
+
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
|
|
352
|
+
|
|
353
|
+
class TodoList extends WebComponent({
|
|
354
|
+
todos: prop<Todo[]>(Array),
|
|
355
|
+
}) {
|
|
356
|
+
private optimisticTodos = optimistic(this, {
|
|
357
|
+
source: () => this.todos,
|
|
358
|
+
update: (state, title: string) => [
|
|
359
|
+
...state,
|
|
360
|
+
{ id: crypto.randomUUID() as any, title, completed: false, pending: true },
|
|
361
|
+
],
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
async handleSubmit(e: SubmitEvent) {
|
|
365
|
+
e.preventDefault();
|
|
366
|
+
const title = new FormData(e.target as HTMLFormElement).get('title') as string;
|
|
367
|
+
if (!title) return;
|
|
368
|
+
(e.target as HTMLFormElement).reset();
|
|
369
|
+
|
|
370
|
+
const promise = createTodo({ title });
|
|
371
|
+
this.optimisticTodos.add(title, promise);
|
|
372
|
+
|
|
373
|
+
const result = await promise;
|
|
374
|
+
if (result.success && result.data) {
|
|
375
|
+
this.todos = [...this.todos.filter(t => t.pending), result.data, ...this.todos.filter(t => !t.pending)];
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
render() {
|
|
380
|
+
return html`<ul>${this.optimisticTodos.value.map(todo => html`
|
|
381
|
+
<li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
|
|
382
|
+
`)}</ul>`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
TodoList.register('todo-list');
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
`.add(payload, promise)` auto-releases when the promise settles (resolve or reject). No try-catch, no manual rollback, no temp-ID bookkeeping.
|
|
389
|
+
|
|
390
|
+
### Simple flips: imperative API
|
|
391
|
+
|
|
392
|
+
For boolean toggles where the value itself is the mutation:
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
import { signal, optimistic } from '@webjsdev/core';
|
|
396
|
+
import { likePost } from '#actions/like-post.server.ts';
|
|
397
|
+
|
|
398
|
+
const liked = signal(false);
|
|
399
|
+
// in an @click handler:
|
|
400
|
+
const result = await optimistic(liked, true, () => likePost(postId));
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### When to skip optimistic UI
|
|
404
|
+
|
|
405
|
+
- The result is unpredictable (AI-generated content, server-computed values the client cannot guess).
|
|
406
|
+
- The mutation has side effects the user must wait for (payment processing, email sending, OAuth).
|
|
407
|
+
- The action validates against data that may have changed server-side (unique constraints, race conditions).
|
|
408
|
+
- The mutation is destructive and irreversible with no undo (confirm-first UX is better).
|
|
409
|
+
|
|
410
|
+
### When optimistic UI is appropriate
|
|
411
|
+
|
|
412
|
+
- Todo items, comments, posts, likes, follows, toggles, reorders, renames, status changes.
|
|
413
|
+
- Any mutation where the client can construct the expected result from the input.
|
|
414
|
+
- CRUD operations where the server returns the same shape the client already has.
|
|
415
|
+
|
|
416
|
+
**Never write manual try-catch / cache-and-restore / temp-ID reconciliation** when `optimistic()` covers the pattern. See `agent-docs/advanced.md` for the full reference and `agent-docs/recipes.md` for complete examples.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
339
420
|
## Invariants (for both humans and agents)
|
|
340
421
|
|
|
341
|
-
> Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.
|
|
422
|
+
> Hit one of these as a runtime error? The [Troubleshooting page](https://docs.webjs.dev/docs/troubleshooting) is keyed by symptom (the throw-at-load server import, the backtick-in-template 500, the TypeScript strip failure, the SSR browser-global crash, the missing-frame swap) and maps each back to the invariant and the `webjs check` rule below.
|
|
342
423
|
|
|
343
424
|
1. **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** The `.server.{js,ts}` extension is the path-level boundary (the file router refuses to serve the source); a `'use server'` directive additionally makes exports RPC-callable, else the file is a server-only utility whose browser import is a throw-at-load stub. Importing a DB driver (`pg`), `node:*`, or any server-only dep from a component or an `app/**` page / layout / loading / error / not-found file crashes the browser at module load.
|
|
344
425
|
2. **Every `*.server.{js,ts}` file with `'use server'` exports must be `async` functions returning serializer-safe values.** Args and results round-trip via webjs's wire. Files without `'use server'` (server-only utilities) can export anything, including singletons.
|
|
@@ -359,7 +440,7 @@ The advanced client-router surface is in `agent-docs/advanced.md`: **link prefet
|
|
|
359
440
|
|
|
360
441
|
Three scaffolds exist (do not invent template names): `webjs create <name>` (full-stack: layout, page, components, modules, Drizzle+SQLite), `webjs create <name> --template api` (backend-only routes + modules + Drizzle, no SSR), `webjs create <name> --template saas` (auth + login/signup + protected dashboard + User model). The `--db sqlite|postgres` flag (default sqlite) picks the dialect; the schema/queries/actions are identical across dialects (see #563). The `--runtime node|bun` flag (default node, #541) is ORTHOGONAL to the template and re-flavors any of the three for Bun (`bun --bun` dev/start scripts so the SERVER runs on Bun, `bun.lock`, a pure `oven/bun:1` Dockerfile (#595; safe since cli@0.10.20's npx-free `webjs db migrate` (#570) needs no Node in the image) + bun-install CI, bun-command agent docs; the test/db/check tooling stays on Node); `bun create webjs <name>` auto-detects it. Pick from the request: default for any product with UI (todo, blog, dashboard, marketplace, social, e-commerce), `api` for an HTTP/JSON API with no UI, `saas` for accounts/login/signup; default to full-stack when ambiguous.
|
|
361
442
|
|
|
362
|
-
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. Docs at https://docs.webjs.
|
|
443
|
+
Rules: **always scaffold via `webjs create`** (never hand-roll). **Default to a real database (Drizzle + SQLite); NEVER use JSON files, in-memory arrays, or localStorage for persistence.** Update `db/schema.server.ts` to real models FIRST, then `webjs db generate` + `webjs db migrate`, then build pages/actions/queries. **Treat the scaffold as REFERENCE, not the final product:** replace the example page / `User` model / components and adapt `app/layout.ts` (brand, nav, content width; the default `<main class="max-w-[760px]">` reading column needs widening for a full-bleed app). ENFORCED: examples carry a `webjs-scaffold-placeholder` comment and `no-scaffold-placeholder` fails until the content is replaced and the marker deleted. **The scaffold IS the primary teaching surface:** every UI scaffold (full-stack AND saas) ships a densely commented feature gallery (one webjs concept per demo under `app/features/`, logic in `modules/`, plus a whole example app under `app/examples/`), and the `api` template ships a backend-features showcase under `app/api/features/` (the `route()` adapter + validation, rate limiting, streaming, file storage, WebSockets + broadcast) listed in the root `app/route.ts` index; read the demos to learn the idioms, then prune per-feature. Docs at https://docs.webjs.dev.
|
|
363
444
|
|
|
364
445
|
---
|
|
365
446
|
|
|
@@ -41,10 +41,13 @@ On a client-router navigation to a streamed page, the router applies the respons
|
|
|
41
41
|
|
|
42
42
|
Five stacked zero-build optimizations:
|
|
43
43
|
|
|
44
|
-
1. **`<link rel="modulepreload">` per used component + transitive deps.**
|
|
44
|
+
1. **`<link rel="modulepreload">` per used component + transitive deps + reached vendors.**
|
|
45
45
|
The SSR pass knows every custom element in the final HTML. A startup
|
|
46
|
-
module-graph scan adds their transitive import dependencies too
|
|
47
|
-
|
|
46
|
+
module-graph scan adds their transitive import dependencies too, AND the
|
|
47
|
+
npm vendor URLs those shipped modules import (#754, flattening the
|
|
48
|
+
cross-origin CDN waterfall one level; see the no-build model below for the
|
|
49
|
+
shallow-dependency caveat). All preload hints are deduplicated and emitted
|
|
50
|
+
in `<head>`, vendor hints carrying their SRI `integrity` + `crossorigin`.
|
|
48
51
|
2. **HTTP/2 multiplex at the edge.** The production server (`npm run start`) speaks plain
|
|
49
52
|
HTTP/1.1. PaaS edges (Railway, Fly, Render, Vercel, Cloudflare Pages,
|
|
50
53
|
Heroku) and reverse proxies (nginx, Caddy, Traefik) speak
|
|
@@ -132,9 +135,23 @@ production. The Rails 7+ / Hotwire pattern:
|
|
|
132
135
|
resource. The browser walks the import graph and fetches each module
|
|
133
136
|
on demand.
|
|
134
137
|
- **`<link rel="modulepreload">` hints at SSR time**: for every component
|
|
135
|
-
the route uses + its transitive deps from the module graph
|
|
136
|
-
|
|
137
|
-
|
|
138
|
+
the route uses + its transitive deps from the module graph, AND for the
|
|
139
|
+
npm **vendor** URLs those shipped modules reach (#754). The browser
|
|
140
|
+
parallelizes fetches instead of waterfalling through nested imports. This
|
|
141
|
+
is what closes most of the perceived gap vs a bundle. **The honest caveat:**
|
|
142
|
+
a bundle still wins on a DEEP vendor tree. webjs flattens the FIRST level
|
|
143
|
+
(the vendor entries your code imports are hinted up front, with their SRI
|
|
144
|
+
`integrity`, byte-identical to the importmap target so there is no double
|
|
145
|
+
fetch), but a vendor's OWN transitive deps are still discovered by parsing
|
|
146
|
+
each fetched CDN module in turn, level by level, over the cross-origin CDN
|
|
147
|
+
connection. So the complementary mitigation is **shallow-dependency
|
|
148
|
+
discipline**: prefer few, shallow ESM dependencies (a library with a flat
|
|
149
|
+
or one-level graph fully benefits; a deep tree still waterfalls past level
|
|
150
|
+
one). Only REACHED vendors are hinted: a vendor imported solely by an
|
|
151
|
+
elided display-only component, by a page/layout module dropped from the boot
|
|
152
|
+
(an inert or import-only page whose binding vendor import is used only during
|
|
153
|
+
SSR, the canonical SSR-only-dependency case), by a `.server.*` file, or
|
|
154
|
+
pinned-but-unimported, is never preloaded (no over-fetch).
|
|
138
155
|
- **HTTP/2 multiplex** is what makes per-file serving competitive: one
|
|
139
156
|
TCP+TLS handshake, many module fetches in parallel over the same
|
|
140
157
|
connection. The production server (`npm run start`) speaks plain HTTP/1.1.
|
|
@@ -486,10 +503,56 @@ the events + `aria-busy` are a client-only enhancement.
|
|
|
486
503
|
|
|
487
504
|
### Optimistic mutations (`optimistic()`)
|
|
488
505
|
|
|
489
|
-
`optimistic(
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
506
|
+
`optimistic()` from `@webjsdev/core` shows a mutation's expected result IMMEDIATELY (the UI feels instant), runs the real server action, and ROLLS BACK on failure. **Use it for every user-facing mutation where the client can predict the result** (create, update, delete, like, toggle, reorder). See `agent-docs/recipes.md` for complete examples.
|
|
507
|
+
|
|
508
|
+
#### Declarative API (preferred)
|
|
509
|
+
|
|
510
|
+
`optimistic(host, { source, update })` returns an `OptimisticState<State, Action>` with a `.value` getter and `.add(payload, promise?)` method. The reducer transforms the current state with each payload; `.add()` pushes an update and schedules a re-render. When a `promise` is passed, the update auto-releases on settlement (`.finally()` or a `.then()` fallback for thenables lacking `.finally`).
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
import { WebComponent, prop, optimistic, html } from '@webjsdev/core';
|
|
514
|
+
import { createTodo } from '#modules/todos/actions/create-todo.server.ts';
|
|
515
|
+
|
|
516
|
+
class TodoList extends WebComponent({
|
|
517
|
+
todos: prop<Todo[]>(Array),
|
|
518
|
+
}) {
|
|
519
|
+
private optimisticTodos = optimistic(this, {
|
|
520
|
+
source: () => this.todos,
|
|
521
|
+
update: (state, title: string) => [
|
|
522
|
+
...state,
|
|
523
|
+
{ id: crypto.randomUUID() as any, title, completed: false, pending: true },
|
|
524
|
+
],
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
async handleSubmit(e: SubmitEvent) {
|
|
528
|
+
e.preventDefault();
|
|
529
|
+
const title = new FormData(e.target as HTMLFormElement).get('title') as string;
|
|
530
|
+
if (!title) return;
|
|
531
|
+
(e.target as HTMLFormElement).reset();
|
|
532
|
+
|
|
533
|
+
const promise = createTodo({ title });
|
|
534
|
+
this.optimisticTodos.add(title, promise);
|
|
535
|
+
|
|
536
|
+
const result = await promise;
|
|
537
|
+
if (result.success && result.data) {
|
|
538
|
+
this.todos = [...this.todos.filter(t => t.pending), result.data, ...this.todos.filter(t => !t.pending)];
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
render() {
|
|
543
|
+
return html`<ul>${this.optimisticTodos.value.map(todo => html`
|
|
544
|
+
<li class=${todo.pending ? 'opacity-50' : ''}>${todo.title}</li>
|
|
545
|
+
`)}</ul>`;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
TodoList.register('todo-list');
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
Multiple `.add()` calls stack independently; each `release()` removes its own update by ID. When `update` is omitted, the payload replaces the state directly (`Action = State`), matching the simple `useOptimistic(setState)` pattern.
|
|
552
|
+
|
|
553
|
+
#### Imperative API (simple flips)
|
|
554
|
+
|
|
555
|
+
`optimistic(signal, value, action)` is a thin wrapper over the signal primitive for simple boolean flips:
|
|
493
556
|
|
|
494
557
|
```ts
|
|
495
558
|
import { signal, optimistic } from '@webjsdev/core';
|
|
@@ -505,9 +568,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
|
|
|
505
568
|
// to the authoritative value from `result` if you need it.
|
|
506
569
|
```
|
|
507
570
|
|
|
508
|
-
It rolls back on a thrown error OR an `ActionResult` `{ success: false }`
|
|
509
|
-
envelope, and never on success. Client-only (it mutates a signal), so a
|
|
510
|
-
component importing it is never elided as display-only.
|
|
571
|
+
It rolls back on a thrown error OR an `ActionResult` `{ success: false }` envelope, and never on success. Client-only (it mutates a signal), so a component importing it is never elided as display-only.
|
|
511
572
|
|
|
512
573
|
### Wire-byte optimization
|
|
513
574
|
|
|
@@ -642,6 +703,28 @@ document.addEventListener('webjs:prefetch', (e) => {
|
|
|
642
703
|
});
|
|
643
704
|
```
|
|
644
705
|
|
|
706
|
+
### `webjs:before-cache` (strip transient state before a back/forward snapshot)
|
|
707
|
+
|
|
708
|
+
The router keeps a URL-keyed snapshot cache (Turbo's SnapshotCache pattern) so
|
|
709
|
+
Back/Forward restores instantly. Because a snapshot is a raw `outerHTML` clone of
|
|
710
|
+
the live page, anything OPEN at navigate-away time (a hover-card, a dropdown, a
|
|
711
|
+
toast) is captured open and **restored open** on Forward. To prevent that, the
|
|
712
|
+
router dispatches `webjs:before-cache` on `document` **synchronously, on the page
|
|
713
|
+
being cached, immediately before the `outerHTML` read** (detail `{ url }`). A
|
|
714
|
+
handler's DOM mutations are captured in the snapshot; the live edits are
|
|
715
|
+
invisible because the page is being navigated away from (Turbo's
|
|
716
|
+
`turbo:before-cache` contract). Reset transient state here:
|
|
717
|
+
|
|
718
|
+
```ts
|
|
719
|
+
document.addEventListener('webjs:before-cache', () => {
|
|
720
|
+
document.querySelectorAll('[data-transient]').forEach((el) => el.remove());
|
|
721
|
+
// close open menus, clear in-progress toasts, reset a wizard step, ...
|
|
722
|
+
});
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
The kit's overlays (hover-card, tooltip, dropdown-menu, dialog, alert-dialog,
|
|
726
|
+
sonner) already listen and reset their open state, so they come back closed.
|
|
727
|
+
|
|
645
728
|
### View Transitions (opt-in, all three swap paths)
|
|
646
729
|
|
|
647
730
|
The router can wrap a client navigation's DOM mutation in the native
|
|
@@ -1163,7 +1246,7 @@ prod. A truncated stream (a server crash, a dropped connection) also throws
|
|
|
1163
1246
|
rather than completing silently: a healthy stream always ends in an explicit
|
|
1164
1247
|
terminal frame, so a missing one is an error. For a slow region you want behind a fallback on the FIRST paint, use
|
|
1165
1248
|
`<webjs-suspense>` instead; streaming RPC is for an imperative stream consumed
|
|
1166
|
-
after an interaction. Full reference: the [Data fetching](https://docs.webjs.
|
|
1249
|
+
after an interaction. Full reference: the [Data fetching](https://docs.webjs.dev/docs/data-fetching) page.
|
|
1167
1250
|
|
|
1168
1251
|
### Opt out per link
|
|
1169
1252
|
|
|
@@ -59,6 +59,13 @@ export const listPosts = cache(
|
|
|
59
59
|
);
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
Cached values round-trip through the same rich serializer as the RPC wire
|
|
63
|
+
(`Date` / `Map` / `Set` / `BigInt` / typed arrays / cycles), so a warm cache
|
|
64
|
+
hit returns the exact same value shape as a cold miss (a row's `createdAt`
|
|
65
|
+
stays a `Date`, never degrades to a string). Cache keys are rich-serialized
|
|
66
|
+
too, so a `Map` / `Set` argument is a distinct key per value instead of
|
|
67
|
+
colliding. You do not need cached values to be JSON-plain.
|
|
68
|
+
|
|
62
69
|
To invalidate a read from an UNRELATED mutation (without importing the
|
|
63
70
|
wrapper), add `tags`. It is either a static `string[]` or a function
|
|
64
71
|
`(...args) => string[]` so a per-entity read tags with the id:
|
|
@@ -105,13 +112,16 @@ exact id by tag. An untagged `cache()` is untouched by any `revalidateTag`.
|
|
|
105
112
|
evicts cached HTML. Together they are the **server cache invalidation
|
|
106
113
|
surface**, both imported from `@webjsdev/server`.
|
|
107
114
|
|
|
108
|
-
**Multi-instance note.**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
**Multi-instance note.** On the built-in memory and Redis stores the tag
|
|
116
|
+
index is a real set (a native `Set` in memory, a Redis `SADD` set), so
|
|
117
|
+
adding a cache key to a tag is an atomic insert. Two mutations appending to
|
|
118
|
+
the same tag concurrently (across Redis instances, or interleaved in one
|
|
119
|
+
process) no longer lose an entry, so `revalidateTag` reliably evicts every
|
|
120
|
+
tagged key (#752). A custom store that does not implement the optional
|
|
121
|
+
atomic-set methods (`setAdd` / `setMembers`) falls back to the older
|
|
122
|
+
non-atomic JSON path, where a concurrent append can be lost; there, prefer a
|
|
123
|
+
short `ttl` as the cross-instance floor. The index entry carries the cache
|
|
124
|
+
TTL so it self-prunes either way.
|
|
115
125
|
|
|
116
126
|
### Server HTML response cache (`export const revalidate`, ISR for no-build)
|
|
117
127
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Component member shadowing: names a WebComponent must not reuse
|
|
2
|
+
|
|
3
|
+
A `WebComponent` inherits two layers of members. In the browser it extends
|
|
4
|
+
`HTMLElement` (so every DOM property and method is present), and it extends the
|
|
5
|
+
framework base that owns the reactivity lifecycle (`render`, `update`,
|
|
6
|
+
`requestUpdate`, and the lit-aligned hooks). SSR swaps `HTMLElement` for an
|
|
7
|
+
`ElementShim`-style stand-in with the same public surface. When app code
|
|
8
|
+
declares a reactive property (via the `WebComponent({ ... })` factory) or a
|
|
9
|
+
method whose NAME collides with one of those inherited members, one of two
|
|
10
|
+
things happens.
|
|
11
|
+
|
|
12
|
+
- **Type-incompatible collision.** TypeScript refuses the class. A reactive
|
|
13
|
+
property that shadows a native property of a different type raises **`TS2415`**
|
|
14
|
+
on the `class X extends WebComponent({ ... })` line ("Class 'X' incorrectly
|
|
15
|
+
extends base class ...; types of property 'y' are incompatible"). A method or
|
|
16
|
+
field in the class body whose signature differs from the inherited one raises
|
|
17
|
+
**`TS2416`** on that member ("Property 'y' in type 'X' is not assignable to the
|
|
18
|
+
same property in base type").
|
|
19
|
+
- **Type-compatible collision.** It compiles, then silently hijacks the native
|
|
20
|
+
member at runtime (a `title` reactive prop overrides the element's tooltip /
|
|
21
|
+
`title`-attribute reflection, a `hidden` prop overrides native hide behavior).
|
|
22
|
+
No error, wrong behavior.
|
|
23
|
+
|
|
24
|
+
The fix for every row below is the same. **Rename the field or method** to a
|
|
25
|
+
name that is not an inherited member (`postTitle` instead of `title`,
|
|
26
|
+
`removeItem` instead of `remove`). Only override an inherited lifecycle method
|
|
27
|
+
deliberately and with its exact signature (override `render` / `connectedCallback`
|
|
28
|
+
on purpose, never repurpose the name for unrelated app logic).
|
|
29
|
+
|
|
30
|
+
## Members not to shadow
|
|
31
|
+
|
|
32
|
+
| Member | Origin | Base type or signature | Shadow with an app prop / method | Error | Fix (rename to) |
|
|
33
|
+
|---|---|---|---|---|---|
|
|
34
|
+
| `title` | HTMLElement | `string` | `title: prop<Post>(Object)` (a post object) | `TS2415` | `post`, `heading` |
|
|
35
|
+
| `id` | HTMLElement | `string` | `id: prop(Number)` (a numeric row id) | `TS2415` | `postId`, `rowId` |
|
|
36
|
+
| `slot` | HTMLElement | `string` | `slot: prop<Slot>(Object)` | `TS2415` | `bookingSlot` |
|
|
37
|
+
| `role` | HTMLElement | `string \| null` | `role: prop<Role>(Object)` (a user role) | `TS2415` | `userRole` |
|
|
38
|
+
| `hidden` | HTMLElement | `boolean` | `hidden: prop(String)` | `TS2415` | `isHidden` (or keep `boolean`) |
|
|
39
|
+
| `dir` | HTMLElement | `string` | `dir: prop<Direction>(Object)` | `TS2415` | `direction` |
|
|
40
|
+
| `lang` | HTMLElement | `string` | `lang: prop<Lang>(Object)` | `TS2415` | `language` |
|
|
41
|
+
| `translate` | HTMLElement | `boolean` | `translate: prop(String)` | `TS2415` | `translationKey` |
|
|
42
|
+
| `draggable` | HTMLElement | `boolean` | `draggable: prop(String)` | `TS2415` | `isDraggable` |
|
|
43
|
+
| `tabIndex` | HTMLElement | `number` | `tabIndex: prop(String)` | `TS2415` | `tabOrder` |
|
|
44
|
+
| `className` | HTMLElement | `string` | `className: prop<string[]>(Array)` | `TS2415` | `variantClasses` |
|
|
45
|
+
| `dataset` | HTMLElement | `DOMStringMap` | `dataset: prop<Data>(Object)` | `TS2415` | `payload`, `meta` |
|
|
46
|
+
| `remove` | Element | `(): void` | `remove(id: number): Promise<void>` | `TS2416` | `removeItem`, `deleteRow` |
|
|
47
|
+
| `closest` | Element | `(sel: string) => Element \| null` | `closest(n: number)` | `TS2416` | `nearest` |
|
|
48
|
+
| `matches` | Element | `(sel: string) => boolean` | `matches(other: T)` | `TS2416` | `isMatch` |
|
|
49
|
+
| `focus` | HTMLElement | `(opts?) => void` | `focus(field: string)` | `TS2416` | `focusField` |
|
|
50
|
+
| `blur` | HTMLElement | `(): void` | `blur(amount: number)` | `TS2416` | `applyBlur` |
|
|
51
|
+
| `click` | HTMLElement | `(): void` | `click(e: Event)` | `TS2416` | `handleClick` |
|
|
52
|
+
| `append` / `prepend` | Element | `(...nodes) => void` | `append(item: T)` | `TS2416` | `appendItem` |
|
|
53
|
+
| `before` / `after` | Element | `(...nodes) => void` | `after(cb: () => void)` | `TS2416` | `runAfter` |
|
|
54
|
+
| `render` | WebComponent | `() => TemplateResult \| Promise<...>` | `render(data: T)` (arg added) | `TS2416` | override with the real signature |
|
|
55
|
+
| `update` | WebComponent | `(changed: Map) => void` | `update(input: T)` | `TS2416` | `applyUpdate`, `save` |
|
|
56
|
+
| `requestUpdate` | WebComponent | `(name?, old?) => void` | `requestUpdate(payload: T)` | `TS2416` | `queueUpdate` |
|
|
57
|
+
| `updated` / `firstUpdated` | WebComponent | `(changed: Map) => void` | `updated(row: T)` | `TS2416` | `onUpdated` |
|
|
58
|
+
| `willUpdate` / `shouldUpdate` | WebComponent | `(changed: Map) => boolean \| void` | repurposed for app logic | `TS2416` | rename or override correctly |
|
|
59
|
+
| `connectedCallback` | WebComponent | `(): void` | `connectedCallback(user: T)` | `TS2416` | override with no args |
|
|
60
|
+
| `renderError` / `renderFallback` | WebComponent | `(err?) => TemplateResult` | `renderFallback(id: number)` | `TS2416` | override with the real signature |
|
|
61
|
+
| `addController` / `removeController` | WebComponent | `(c: Controller) => void` | app method of the same name | `TS2416` | rename |
|
|
62
|
+
| `updateComplete` | WebComponent | `get(): Promise<boolean>` | `updateComplete: prop(Boolean)` | `TS2415` | `isComplete` |
|
|
63
|
+
|
|
64
|
+
Framework-private fields are all underscore-prefixed (`_renderRoot`,
|
|
65
|
+
`_connected`, `_changedProperties`, `_updatePromise`, `_isUpdating`). Never
|
|
66
|
+
declare a reactive prop or field with a leading underscore that matches one, and
|
|
67
|
+
never write to them from app code. Use a plainly-named reactive prop or a signal
|
|
68
|
+
instead.
|
|
69
|
+
|
|
70
|
+
## Names that are safe (not inherited)
|
|
71
|
+
|
|
72
|
+
Common component prop names that do NOT collide, so they need no rename.
|
|
73
|
+
|
|
74
|
+
`label`, `open`, `count`, `value` (declare it, native `HTMLElement` has none),
|
|
75
|
+
`name`, `items`, `todos`, `active`, `variant`, `size`, `checked`, `selected`,
|
|
76
|
+
`heading`, `caption`, `message`, `status`.
|
|
77
|
+
|
|
78
|
+
Note. `open`, `value`, `checked`, and `selected` ARE native properties on
|
|
79
|
+
specific built-in elements (`<details>`, `<input>`, `<option>`), but a
|
|
80
|
+
`WebComponent` extends the generic `HTMLElement`, which does not define them, so
|
|
81
|
+
they are free on a custom element. When in doubt, grep the base surface in
|
|
82
|
+
`packages/core/src/component.js` (or `node_modules/@webjsdev/core/src/component.js`
|
|
83
|
+
in an app) rather than guessing.
|
|
84
|
+
|
|
85
|
+
Cross-references. Reactive property declaration is in the root `AGENTS.md`
|
|
86
|
+
(`WebComponent` essentials) and `agent-docs/components.md`. The lit patterns that
|
|
87
|
+
break webjs reactivity are in `agent-docs/lit-muscle-memory-gotchas.md`.
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|