@webjsdev/mcp 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/resources/AGENTS.md +11 -10
- package/resources/SKILL.md +3 -2
- package/resources/references/built-ins.md +1 -1
- package/resources/references/client-router-and-streaming.md +10 -0
- package/resources/references/components.md +51 -2
- package/resources/references/data-and-actions.md +53 -1
- package/resources/references/muscle-memory-gotchas.md +27 -7
- package/resources/references/optimistic-ui.md +1 -1
- package/resources/references/routing-and-pages.md +1 -1
- package/resources/references/testing.md +19 -0
- package/src/mcp.js +18 -1
package/README.md
CHANGED
|
@@ -28,6 +28,10 @@ subcommand) delegates to this same server, so both routes run identical code.
|
|
|
28
28
|
`list_actions` (RPC endpoints plus the full data contract: HTTP verb, cache
|
|
29
29
|
config, and boolean flags for tags/invalidates/validate/middleware; reserved
|
|
30
30
|
config exports are excluded from the callable-action list), `list_components`,
|
|
31
|
+
`list_elision` (the display-only elision verdict: which component modules the
|
|
32
|
+
browser never downloads, the evidence behind each one that ships, every
|
|
33
|
+
page/layout as inert / import-only / shipped, and any orphan class that gets
|
|
34
|
+
no verdict at all; identical to `webjs elision --json`),
|
|
31
35
|
`check` (the structured `webjs check` violations). Each projects an existing
|
|
32
36
|
`@webjsdev/server` data function and mutates nothing.
|
|
33
37
|
- **Knowledge layer**: an `init` mental-model primer, a `docs` retrieval tool,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Model Context Protocol server for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Gives AI coding agents live app introspection over routes, actions, components, and convention checks, plus a knowledge layer of docs, recipes, and framework source.",
|
|
6
6
|
"bin": {
|
package/resources/AGENTS.md
CHANGED
|
@@ -85,7 +85,7 @@ Every code change MUST include, automatically:
|
|
|
85
85
|
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 `references/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`).
|
|
86
86
|
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` + the skill at `.agents/skills/webjs/` (SKILL.md + references/) for new API surface, `CONVENTIONS.md` (and per-package `AGENTS.md`) for new conventions, the docs site (`website/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`).
|
|
87
87
|
3. **Scaffold + skill sync (when a feature changes what apps should do).** The scaffold `webjs create` emits is a gallery index home + a root layout + db wiring, a densely-commented feature gallery (`packages/cli/templates/gallery/**`, single-concept demos under `app/features/` plus the `app/examples/todo` app, shipped in every UI template) and the api backend-features showcase (`packages/cli/lib/api-gallery.js`), plus the one cross-agent skill at `packages/cli/templates/.agents/skills/webjs/` (SKILL.md + references). So when a WebJs feature is added or changed, ask: does the generator (`packages/cli/lib/{create,api-gallery}.js`), a gallery demo (`packages/cli/templates/gallery/`), or the agent skill (`.agents/skills/webjs/SKILL.md` + its `references/`) need to move so a freshly scaffolded app and the skill teach the new reality? Verify by generating an app and running `generate + boot + webjs check` (the generators emit strings, so an escaping bug only shows in a freshly generated app). See `framework-dev.md`.
|
|
88
|
-
4. **Convention validation.** Run `webjs check` and fix violations. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website
|
|
88
|
+
4. **Convention validation.** Run `webjs check` and fix violations. Run `webjs doctor` too when you touched an in-repo app (`examples/blog`, `website`): the required `conventions` CI job runs it over both, and it fails on a hard toolchain check or on whatever that app's `webjs.doctor.gate` marks `error` (today `UNMARKED_ASSET_LINKS` in `website` and `examples/blog`), so a clean `webjs check` alone is not enough to predict that job (#1257).
|
|
89
89
|
|
|
90
90
|
### Git workflow (mandatory)
|
|
91
91
|
|
|
@@ -114,7 +114,7 @@ An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rai
|
|
|
114
114
|
- **No build step.** Source files are served as native ES modules. JSDoc `.js` is default; `.ts` / `.mts` is stripped through a pluggable stripper (#508): Node 24+'s built-in `module.stripTypeScriptTypes`, or `amaro` on Bun (byte-identical, position-preserving) (invariant 10 + `references/typescript.md`). **Runs on Node 24+ or Bun** (run a Bun app with `bun --bun run dev` / `start`); the early `assertNodeVersion()` preflight enforces the Node floor and admits Bun. On Bun, `startServer` selects a native `Bun.serve` listener shell instead of the node:http one (skipping the compat bridge for ~1.9x more req/s on the listening path, at near-complete feature parity, the one node-only gap being 103 Early Hints since `Bun.serve` has no informational-response API), via a runtime-neutral seam that also sets up future `Deno.serve` / embedded adapters. Edge runtimes (no filesystem) are a separate, later target. See `references/runtime.md` for the Node vs Bun command + difference reference.
|
|
115
115
|
- **SSR + CSR by default.** Pages are server-rendered HTML; components render light DOM by default, shadow DOM opt-in via `static shadow = true` with DSD SSR.
|
|
116
116
|
- **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 action=${importedAction}>` would do.
|
|
117
|
-
- **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 `references/components.md`.
|
|
117
|
+
- **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. Inspect the verdict with `webjs elision` (per module, with the evidence behind every ship) and prove it for your own app with `webjs elision --verify`. Disable with `"webjs": { "elide": false }` or `WEBJS_ELIDE=0`. See `references/components.md`.
|
|
118
118
|
- **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).
|
|
119
119
|
- **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.
|
|
120
120
|
- **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 `references/built-ins.md`.
|
|
@@ -144,7 +144,7 @@ Self-check: `page.ts` / `layout.ts` should NOT appear in the network tab or the
|
|
|
144
144
|
|
|
145
145
|
## Framework source: where to find it
|
|
146
146
|
|
|
147
|
-
Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source).
|
|
147
|
+
Plain JS with JSDoc lives in `node_modules/@webjsdev/` (`core/`, `server/`, `cli/`, `mcp/`, `intellisense/`, `ui/`); what you read is what runs. Starting points: SSR `@webjsdev/server/src/ssr.js`, client hydration `@webjsdev/core/src/render-client.js`, client router `@webjsdev/core/src/router-client.js`, convention rules `@webjsdev/server/src/check.js`. For UI debugging use the Playwright MCP server; for live introspection the scaffold wires the read-only `@webjsdev/mcp` server (`npx @webjsdev/mcp`, also reachable as `webjs mcp`): `list_routes`, `list_actions`, `list_components`, `list_elision` (what the browser never downloads, and why each shipped module ships), `check`, `ui` (the `@webjsdev/ui` kit inventory + a component's helpers / paste-ready example / a11y header), plus a knowledge layer (docs / recipes / framework source).
|
|
148
148
|
|
|
149
149
|
---
|
|
150
150
|
|
|
@@ -237,7 +237,7 @@ lit-html parity: `repeat` (keyed lists), `unsafeHTML(str)` (trusted raw HTML, **
|
|
|
237
237
|
|
|
238
238
|
### `html` expression prefixes
|
|
239
239
|
|
|
240
|
-
`<div>${x}</div>` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `<form>` or `formaction=${importedAction}` on a submitter
|
|
240
|
+
`<div>${x}</div>` text child; `class=${x}` attribute (escaped); `action=${importedAction}` on a `<form>` or `formaction=${importedAction}` on a submitter binds a server action (#1155, #1207, #1307: the function-valued attribute holes where the attribute is omitted/rewritten and a `__webjs_action` identity is emitted, plus `formmethod` / `formenctype` on a bound submitter so it needs no bound form around it; submitters must be actual submit controls and cannot carry their own `name`, `value`, `form`, or static `formaction` attributes; a quoted `action="${fn}"` / `formaction="${fn}"` or `action=${fn}` on un-supported tags is a render error, since stringifying a function would leak a server action's source); `@click=${fn}` event listener (client-only, drops at SSR); `.value=${v}` DOM property (round-trips through SSR on custom elements via `data-webjs-prop-*`, drops on native elements); `?disabled=${b}` boolean attribute. Event/property/boolean holes **must be unquoted** (invariant 4). Every hole is identical server and client except `@event`, `.prop` on native elements, and a **nullish-or-`false` plain-attribute hole**: the client REMOVES the attribute for `null` / `undefined` / `false`, while the server stringifies, emitting `attr=""` for nullish and `attr="false"` for `false`. So the two renderers disagree, and there is no "omit this attribute" hole value at all. `false` is the subtler half: `aria-expanded=${false}` serves `aria-expanded="false"` and hydrates to no attribute, and for a tri-state ARIA attribute those are NOT the same thing, so `?attr=${bool}` is not a substitute either. To omit an attribute conditionally, BRANCH the template (`cond ? html\`<div a=${v}>\` : html\`<div>\``) rather than passing a nullish value, and use `?attr=${bool}` for a genuine boolean attribute. This matters for ARIA in particular, where an empty `aria-labelledby` is an IDREF list resolving to nothing and an `aria-checked=""` is an attribute the role may not even allow.
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
@@ -268,7 +268,7 @@ MyThing.register('my-thing');
|
|
|
268
268
|
|
|
269
269
|
**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).
|
|
270
270
|
|
|
271
|
-
**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `<slot>` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically
|
|
271
|
+
**Async render (`async render()`), bare-await data fetch (#469).** A component may write `async render() { const u = await getUser(this.id); return html\`<h3>${u.name}</h3>\`; }`. Writing `await` makes the function async by JS rule, and every render path awaits a promise-returning `render()` automatically (no flag). This co-locates the fetch in the leaf component (no prop-drilling). The model is decoupled into three separate concerns. (1) **SSR always blocks**, so the resolved DATA is in the first paint with no fallback markup (PE-safe, JS-off reads it). (2) **The client re-fetch default is stale-while-revalidate**: when a prop / dependency change re-runs `async render()`, the current content stays until the new render resolves (no blank, no flash). (3) **`renderFallback()` is the OPTIONAL re-fetch loading UI**, a prop-aware method shown ONLY during a client re-fetch, NEVER on the first paint, and it does NOT trigger SSR streaming. **Errors are isolated per component by default** (no user code): a thrown `await getData()` renders a component-scoped error state while siblings render, and `renderError()` optionally customizes it (dev surfaces the message, prod stays silent). `getData()` is already isomorphic (a `'use server'` action is the real function during SSR and an RPC stub on the client), so the same line works both sides. Use `async render()` for request-time-known SERVER data that should be in the first paint; keep `Task` / signals for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). A **bare** async-render component (an `async render()` with no other client signal, light DOM) is **elided** like any display-only component (#474): its SSR'd HTML is the complete output, so the framework drops the module AND the redundant on-hydration re-fetch. It SHIPS only when it also carries an independent signal (an `@event`, a non-`state` reactive prop, a signal / reactive import, a lifecycle hook including `renderFallback()`, the dynamic slot READ surface (`slotchange` / `assignedNodes` / `assignedElements` / `assignedSlot`; merely RENDERING a `<slot>` does not ship, since the SSR output carries the placed children), `static shadow = true`, `static interactive = true`, cross-module observation, or a transitively-reachable interactive child). Two carve-outs always ship: `static shadow = true` (Declarative Shadow DOM attaches only during HTML parsing, so a streamed or soft-navigated shadow component needs its module to re-run `attachShadow`) and `static interactive = true` (the explicit author override that forces a ship when the analyser cannot see a component's interactivity statically: an OBSERVER that computes the tag it waits for, a `:defined` rule in an external stylesheet outside the module graph, or a consumer reaching the element through a string selector; a component's OWN registration tag must be a literal per invariant 3, and a computed one is invisible to the scanner, so it gets no verdict and the override cannot rescue it). **For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`…\`}>` to STREAM it** (the fallback flushes on the first byte, the data streams in; multiple boundaries fetch concurrently). This is the only way to show a first-paint fallback, a deliberate choice for slow regions, and it streams progressively on soft navigation too. A throwing component inside a boundary is isolated (renders its error state, siblings stream). **The on-hydration re-fetch is itself eliminated by SSR action seeding (#472):** each `'use server'` action result invoked during a (non-streamed) SSR render is serialized into the page, and the generated RPC stub reads that seed on its first client call, so a shipping async component does NOT re-issue the RPC on hydration (a later refetch / arg-change still goes to the network). Keyed by action-hash + fn + serialized args, consume-once, fail-open (a miss degrades to a normal RPC). A hit returns the value the SSR render that produced THIS page computed for exactly that key, so it cannot disagree with the HTML on screen (a page navigation evicts whatever the outgoing page left unconsumed, in the DOM and in the store, so a departed render's value can never be served); on an HTML-cached page (#241) the seed rides inside the cached bytes and is as fresh as they are. The one shape where a hit can differ from the paint is an action returning a DIFFERENT result for the SAME arguments twice in one render (the seed carries the last, the first component painted the first), which dev warns about once per action function. Captured via a transparent server-side `'use server'` facade (no source transform, no build step; the browser source tab and on-disk files are unchanged), default on, opt out with `"webjs": { "seed": false }` or `WEBJS_SEED=0`. **A miss is otherwise invisible, so dev makes it observable (#1309):** every dev page response carries `X-Webjs-Seed` (`off` / `html-cache` / `collected=<m>, emitted=<n>` / `collected=<m>, emitted=0, streamed`), folded into the access-log line as a `seed` field, and the browser logs ONE warning per page view when a hydration call missed AND the cause is provable (a streamed page, a serializer drop, or seeds present but unmatched), staying silent otherwise, including on a page that emitted no seeds, where a miss is not evidence of a defect because a mutation / `Task` / `connectedCallback` call routes through the same lookup and could never be seeded. `seedStats()` from `@webjsdev/core` exposes the counters. Nothing reaches production: the client's dev gate is a server-stamped `data-webjs-dev` marker on the seed block, never `process.env.NODE_ENV`, which esbuild folds to a constant in the built core bundle. See `references/data-and-actions.md`.
|
|
272
272
|
|
|
273
273
|
**Light DOM (default) vs Shadow DOM.** Light DOM applies global CSS and Tailwind directly (default; for Tailwind/global CSS + simple composition). Shadow DOM (`static shadow = true`) is for `static styles` scoped CSS and third-party isolation; `<slot>` works in either. **Light-DOM slots ARE the native DOM slot API (#1021, full shadow parity):** `<slot>` works identically in light and shadow DOM, so post-mount native writes are LIVE (`appendChild`, `insertBefore`, `removeChild`, `el.remove()`, `innerHTML`, `el.slot=` flips, `HTMLSlotElement.assign()`) and the reads (`assignedNodes` / `assignedElements` / `{flatten}` / `assignedSlot` / `slotchange`, with native async-coalesced timing) match. Flip `static shadow` and nothing else changes; there is NO WebJs-specific slot API. The one write that does NOT flip is `assign()`: the light-DOM version is an extension (element-bound overlay alongside name matching), while native shadow `assign()` needs `slotAssignment: 'manual'`, which WebJs does not set, so avoid `assign()` in a component meant to flip modes. A FORWARDED slot (a template forwarding `<slot>` into a nested component) projects its content on the client and through hydration (#1023): the renderer stamps each slot with its template owner (`SLOT_OWNER`, carried across SSR as `data-wj-slot-owner`) so it routes to the OUTER host that rendered it, and a layout's `${children}` inside a slotted shell keeps its named slots in sync across a soft-nav swap (#1024, the swap resyncs every own slot of the enclosing host). Four inherent gaps, all a consequence of light DOM having no shadow boundary: structural host reads (`host.children` / the `innerHTML` getter show the rendered template, not the authored children, so read slotted content with `assignedNodes()`), `assignedChild.parentNode` is the `<slot>`, `::slotted()` CSS (style slotted content with normal selectors / Tailwind), and initial-projection lifecycle timing (`firstUpdated` sees the `<slot>` element with EMPTY `assignedNodes()`, the projection lands one microtask later; read assigned content from `slotchange` or after a microtask). Live writes need the component's JS on the page, so a display-only slotted wrapper elides (its writes are inert like anything elided; force a ship with `static interactive = true` for an imperative consumer the analyser cannot see). A light-DOM component authoring custom CSS MUST prefix every class selector with its tag name (invariant 7); prefer Tailwind. **Light-DOM component hosts default to `display: block`**: a custom element is `display: inline` in plain CSS, so the framework marks every LIGHT-DOM host `data-wj-host` and injects one head rule in a low-priority cascade layer, `@layer webjs-host { :where([data-wj-host]) { display: block } }`, so a container component does not collapse; the layer keeps it overridable by any author style INCLUDING Tailwind utilities (`class="flex"`/`grid`/`hidden` win, because their layer is ordered after `webjs-host`), a `[hidden]` carve-out keeps `?hidden` working, and an inline light component opts out with `my-tag { display: inline }`. **Shadow-DOM hosts are NOT marked** (a document rule would override the shadow tree's `:host`), so a shadow component sets its host display via `:host { display: block }` in `static styles` (respected because the host is unmarked; set it for a shadow block container). See the even-grid / no-reflow layout recipes in `references/styling.md`. **Never interpolate into a component's `<style>` or `<script>` body** (`html\`<style>${css}</style>\``): the server emits it but the client drops the raw-text hole, so it paints at SSR then wipes to empty on hydrate. Use `static styles` or Tailwind instead (flagged by `no-interpolation-in-raw-text-element`). A page/layout, which never hydrates, may interpolate a `css` result into `<style>`. Install the `webjs` VSCode extension (`packages/editors/vscode`, VS Marketplace + Open VSX; also covers Cursor / Antigravity / Windsurf) or `webjs.nvim` (`packages/editors/nvim`, via lazy.nvim) for template highlighting + editor intelligence with no Lit plugin, or add the standalone `@webjsdev/intellisense` to `tsconfig.json` `plugins` manually (JetBrains). Full deep-dive in `references/components.md` + `references/muscle-memory-gotchas.md`.
|
|
274
274
|
|
|
@@ -367,7 +367,7 @@ Derive the type at every boundary: a DB row from the schema (`typeof todos.$infe
|
|
|
367
367
|
|
|
368
368
|
## Client navigation: automatic, nothing to opt into
|
|
369
369
|
|
|
370
|
-
The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
370
|
+
The router auto-enables when `@webjsdev/core` loads (any page with a component), so there is nothing to opt INTO. An app that wants plain full-page (MPA) navigation can opt OUT app-wide with `{ "webjs": { "clientRouter": false } }` (#629), or per-moment at runtime with `disableClientRouter()`. SSR auto-emits KEYED boundary comment pairs around each layout's children AND the page itself (open `<!--wj:children:<segment>:<route-key>-->`, close `<!--/wj:children:<segment>-->`; the route-key is the resolved path with param values percent-encoded, #1015). The router strictly scans both DOMs (any truncated, mispaired, or duplicated boundary poisons the scan) and applies a two-tier swap with Next.js remount parity: a changed route-key REPLACES (remounts) at the PARENT of the shallowest changed boundary (the range that contains the changed layout's own markup, exact Next remount scope), an unchanged one MORPHS the deepest shared boundary in place (a searchParams-only nav preserves hydrated component state). A poisoned or disjoint scan degrades to a full page load, never a guessed recovery, so silent DOM corruption is structurally impossible; outer-layout DOM identity is preserved on every soft path. **Every degradation dispatches `webjs:navigation-fallback` on `document` in ALL environments** (detail `{ cause, href, willReload }`, not cancelable), so a full page load on a click is observable in production rather than silent (#1114). Form submissions ride the same pipeline (`data-no-router` opts out). Wire bytes are minimized via the `X-Webjs-Have` header (`segment:route-key` entries, so a dynamic layout held for OTHER params is re-rendered rather than short-circuited; the server returns only the divergent fragment, served `private` so a shared cache can never store the reduced body and serve it to a full-page navigation, and additionally marked `Vary: X-Webjs-Have` for caches that honour it (#1140; `Vary` alone was not enough, since Cloudflare honours only `Accept-Encoding`)); scroll is restored on back/forward. **The link-prefetch cache is ANCHOR-VALIDATED** (#1114): a reduced fragment begins at the boundary the server short-circuited on, and on consume the router checks that boundary is still live with the same route-key. A root-anchored fragment therefore survives an unrelated navigation (still a cache hit), while one anchored deeper is discarded once that layout is gone, because applying it would share no boundary with the live DOM and force a full page load. The router also never prefetches the page it is already on (#1106): that request can never serve a later navigation and only occupies a capped cache slot. A non-GET `<form>` that BINDS a server action (`action=${fn}`) is the no-JS write-path (with JS the router posts the same body to the same url and applies the response in place: a `422` swaps without reload, a `303` is followed via fetch). **The router ENCODES that body per the declared `enctype`** (#1307), resolved with native precedence (a submitter's `formenctype` over the form's): `multipart/form-data` sends `FormData`, and `application/x-www-form-urlencoded`, the HTML default and therefore what a plain `<form method="post">` means, sends `URLSearchParams` (a `File` serializes as its name, as the platform does). It previously built `FormData` for everything, so an ordinary POST form sent a urlencoded body with JS off and a multipart body with JS on. A `text/plain` POST is the one encoding the server cannot parse, so the router declines it and lets the browser submit natively, which keeps both paths doing the same thing. A failed navigation recovers in place (a cancelable `webjs:navigation-error` event, else a minimal in-place alert), never a destructive full reload.
|
|
371
371
|
|
|
372
372
|
The advanced client-router surface is in `references/client-router-and-streaming.md`: **link prefetch** (on by default, device-adaptive default: `intent` on a hover pointer, `viewport` (dwell-gated, cancel-on-scroll-out) on touch, per-link `data-prefetch` override), **`<webjs-frame>`** partial-swap regions, **View Transitions** (opt-in via `<meta name="view-transition" content="same-origin">`, where the router checks that exact `content` value and a bare tag enables nothing, plus `data-webjs-permanent` to persist a live element), **stream actions** (`<webjs-stream>` element-level updates, #248), and the **opt-in nav-loading indicator** (`<html data-webjs-nav-progress>` exposes a `data-navigating` attribute during a nav so you can style a CSS-only progress affordance, off by default because toggling a root attribute re-resolves `oklch()` tokens to a one-frame repaint flash on iOS WebKit, #610). Production benefits from HTTP/2 at the edge; `npm run start` speaks plain HTTP/1.1 (put a reverse proxy in front for TLS + HTTP/2).
|
|
373
373
|
|
|
@@ -483,7 +483,7 @@ const result = await optimistic(liked, true, () => likePost(postId));
|
|
|
483
483
|
|
|
484
484
|
11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a `<my-tag>`, an `[expr]` subscript, or a `<code>foo()</code>` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs <subcommand>` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing).
|
|
485
485
|
|
|
486
|
-
12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}
|
|
486
|
+
12. **A form that writes binds its action: `<form action=${importedAction}>`, and a form whose buttons run different actions binds each on its submitter, `<button formaction=${importedAction}>`** (#1155, #1207, #1307). Those are the two shapes the renderer reads, and every near-miss throws rather than producing a form that posts nowhere. The submitter identity rides the pressed button's own `name`/`value` pair (the one channel a browser submits for that button alone), so no `formaction` url is emitted, both entries reach the server, and the dispatcher takes the LAST `__webjs_action` in DOM order, which is the submitter's whenever one was pressed. **Refused on a form:** a quoted `action="${fn}"`, `action=${fn}` on any tag other than `<form>`, `method="get"` or an enctype the server cannot parse, a `.method` / `.enctype` / `.encoding` PROPERTY binding (a `.prop` drops at SSR and applies in the browser, so the form would submit differently with JS than without), a second `action` hole, a plain `action="/url"` alongside the bound hole (SSR keeps it and the client drops it), a whitespace-padded `method=" post "` (an enumerated attribute is matched against exact keywords, so a padded value falls to the invalid-value default and submits as a GET), and a function that is not a `'use server'` export. **Refused on a submitter:** a control that is not a submit control, an `<input type="image">` (it submits `name.x` / `name.y` coordinates, so the identity never arrives), an `<input type="submit">` (the identity has to occupy its `value`, which on that control is also its visible label, so it would render captioned with the action id and could never be labelled; a `<button>` has no such conflict), a submitter carrying its own `name`, `value`, `form`, or static `formaction`, a `.prop` spelling of any of those (`.name` / `.value` / `.formAction` / `.formMethod` / `.formEnctype` all REFLECT on a submitter, so the write is dropped at SSR and lands in the attribute in the browser), and a second `formaction` hole. **Refused on a BOUND submitter, as a same-element contradiction:** its own `formmethod` other than post, an unparseable `formenctype` such as `text/plain`, and `formmethod="dialog"` (which dismisses a `<dialog>` instead of submitting, so the bound action could never run). A PLAIN submitter's own `formmethod` / `formenctype` is NOT refused: native HTML says the submitter's override wins, the author typed it deliberately, and the form's action simply does not run, so the renderer honours it and the dev-time client guard reports at submit time when a submission holds an identity it cannot deliver. A plain `formaction="/url"` likewise retargets away from the bound action and is the author's business. WebJs supplies `method` and `enctype` (and, on a bound submitter, `formmethod` and `formenctype`) only where your template supplies neither, judged from the TEMPLATE rather than the rendered element: `?method=${false}` emits nothing so it is supplied, while `method=${null}` emits `method=""` and is refused. An `encoding=` attribute is inert in HTML (only the `.encoding` PROPERTY aliases `enctype`), so both renderers ignore it. A page has no `action` export, so a bare `<form method="post">` is a `405`. **A bound submitter is SELF-SUFFICIENT and asks nothing of the form around it** (#1307). The renderer supplies submission attributes at the level where the action is BOUND, and never overrides what you wrote at that same level: a bound `<form>` gains `method="post"` plus `enctype`, and a bound `<button>` gains `formmethod="post"` plus `formenctype` ON THE BUTTON, which is what React does for a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all. That is why the refusal list above contains no rule about a submitter's NEIGHBOURS: the renderer refuses only a SAME-ELEMENT contradiction, which has no correct fallback, and never a cross-element rule, which always has one (whatever native HTML would do). It also removed a question neither renderer could answer honestly, since a COMPONENT renders its own template in a separate pass with no view of the host page and the client may reconcile a submitter whose form is not in the tree yet. One consequence: no `formaction` url is emitted (an empty one is a conformance error), so the submission targets whatever the FORM targets, and a form declaring `action="/x"` sends its buttons there. The action still runs if `/x` is a PAGE route, since the identity travels in the body; against a `route.ts` or another origin the identity is ignored and nothing runs, which the dev-time client guard reports at submit time.
|
|
487
487
|
|
|
488
488
|
---
|
|
489
489
|
|
|
@@ -505,8 +505,9 @@ webjs start [--port N] # prod server; source IS the runtime, plain H
|
|
|
505
505
|
webjs test [--server] [--browser] [--watch]
|
|
506
506
|
webjs check [--rules] [--json] # correctness validator (report-only, no autofix); --json for an agent loop
|
|
507
507
|
webjs routes [--json] [--table] [--no-headers] # print the route table (path / owner file / methods, #975). Default tree; --json is byte-identical to the MCP list_routes tool; --no-headers drops the --table header for piping
|
|
508
|
-
webjs
|
|
509
|
-
webjs
|
|
508
|
+
webjs elision [--json] [--verify] [--routes <paths>] # the elision verdict (#1308): every component module as elided or shipped (a shipped one naming the EVIDENCE that forced it and the module that did the forcing), every page/layout as inert / import-only / ships-whole, and every orphan class that gets no verdict at all (either no registration call, or a computed tag; the scanner matches only a literal one). --json is byte-identical to the MCP list_elision tool. --verify renders every static page route with elision on and off and diffs the observable SSR bytes (the framework's own differential guard, pointed at your app): exit 0 on parity, non-zero on a divergence OR on a corpus where nothing could be compared. It proves elision did not change the bytes you SERVE, NOT post-hydration behaviour (a wrongly dropped module is a dead click, not different bytes), so run your browser/e2e suite twice under WEBJS_ELIDE=1 / WEBJS_ELIDE=0 for that half. Dynamic routes are skipped by name; --routes adds real paths
|
|
509
|
+
webjs mcp # read-only MCP: routes, actions (RPC hashes), components, elision (what the browser drops, and why each shipped module ships), check, ui kit
|
|
510
|
+
webjs doctor [--json] [--strict] # project-health checklist (incl. a framework-resolve check that warns when @webjsdev/core can't be resolved from the app dir, the fresh-worktree-without-node_modules trap #954; a page/layout elision advisory PLUS the component-elision verdict, which warns only on an orphan (#1308); a warning when a route module writes a `<link rel="stylesheet">` without `asset()`, #1095); non-zero exit on a hard fail OR on a check the app gated `error`. --json emits `{ results, summary }` (results is the DoctorResult[], each carrying a stable code + its effective severity; summary counts pass/warn/fail/off), plus a third `configErrors` key on the one path where a rejected `webjs.doctor` config stops any check running; --strict additionally fails on every REMAINING warning (#975). Per-check severity is CONFIG, not a flag: `webjs.doctor.gate` maps a code to `off` / `warn` / `error` so CI gates a chosen subset (#1257)
|
|
510
511
|
webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params, #258)
|
|
511
512
|
webjs version # print the installed @webjsdev/cli version (also: webjs --version / -v, #975)
|
|
512
513
|
webjs help [command] # full usage banner, or per-command usage + Options + Examples (e.g. webjs help routes, #975). Flag forms: webjs --help / -h (banner), webjs <command> --help / -h (that command). typecheck/db/ui --help forward to their wrapped tool; an unknown topic exits 1
|
|
@@ -517,7 +518,7 @@ webjs ui init | add <names...> | list | view <name>
|
|
|
517
518
|
webjs vendor pin|unpin|list|audit|outdated|update [--from PROVIDER] # importmap pinning, .webjs/vendor/importmap.json
|
|
518
519
|
```
|
|
519
520
|
|
|
520
|
-
`--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, which serves the docs and the UI gallery,
|
|
521
|
+
`--from PROVIDER` accepts `jspm` (default), `jsdelivr`, `unpkg`, `skypack` and is persisted in the pin file. `PORT` is honoured when `--port` is absent; `webjs dev` emits `routes.d.ts` automatically. Running this repo's own apps (`website/`, which serves the docs and the UI gallery, and `examples/blog/`): `cd` in and use **its** `npm run dev` / `npm start`; as of #550 a bare `webjs dev` / `webjs start` is equivalent (the per-app orchestration moved into the `webjs.dev` / `webjs.start` tasks config, which the primitive runs), so the npm scripts are now just thin aliases.
|
|
521
522
|
|
|
522
523
|
---
|
|
523
524
|
|
package/resources/SKILL.md
CHANGED
|
@@ -39,6 +39,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
39
39
|
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
40
40
|
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
|
|
41
41
|
| Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
42
|
+
| Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
|
|
42
43
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
43
44
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
44
45
|
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
|
|
@@ -105,7 +106,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection
|
|
|
105
106
|
9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s).
|
|
106
107
|
10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators.
|
|
107
108
|
11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor).
|
|
108
|
-
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}
|
|
109
|
+
12. A form that writes binds its action: `<form action=${importedAction}>`, or a per-button `<button formaction=${importedAction}>`. A bound submitter is SELF-SUFFICIENT (#1307): the renderer puts `formmethod="post"` and `formenctype` on the button itself, so it needs no bound form around it and works inside any form or none. Quoted bindings, non-submit controls, `<input type="submit">` (the identity needs its `value`, which is also its label, so use a `<button>`), submitter `name` / `value` / `form` / static `formaction` attributes, a `.prop` spelling of any of those, `action=${fn}` off a `<form>`, a bound form with `method="get"`, a BOUND submitter's own non-post `formmethod` or unparseable `formenctype`, and a non-action function all throw. A PLAIN button's own `formmethod` / `formenctype` is a legal native override and is left alone. A page has no `action` export, so a bare `<form method="post">` is a 405.
|
|
109
110
|
|
|
110
111
|
## Export Map
|
|
111
112
|
|
|
@@ -236,7 +237,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
236
237
|
- Writing `fetch()` to call your own server instead of importing the action.
|
|
237
238
|
- Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
|
|
238
239
|
- Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
|
|
239
|
-
- Writing `formmethod="get"` or `formenctype="text/plain"` on
|
|
240
|
+
- Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
|
|
240
241
|
- Binding an action whose file declares `export const method = 'GET'`. That is a 405 at runtime and a `webjs check` error.
|
|
241
242
|
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
|
|
242
243
|
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
|
|
@@ -233,7 +233,7 @@ Wired at the single response funnel, covering pages, routes, actions, and assets
|
|
|
233
233
|
|
|
234
234
|
- **Access log.** One structured `info` line per handled request (`method`, `path`, `status`, `durationMs`, `requestId`). Never logs bodies or secrets; framework `/__webjs/*` traffic is suppressed.
|
|
235
235
|
- **Request id.** Each request gets a `crypto.randomUUID()` correlation id, set as `X-Request-Id` (honoring a trusted inbound one) and readable server-side with `requestId()` from `@webjsdev/server` (returns `null` outside a request scope).
|
|
236
|
-
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM.
|
|
236
|
+
- **`onError` hook.** Register via `createRequestHandler({ onError })` or `startServer({ onError })`. Called with `(error, { request, requestId, phase })` on any caught pipeline error, before the sanitized response is sent. Best-effort (a throwing hook is ignored), purely additive (the sanitized 500 / action digest is unchanged). Point it at Sentry or an APM. It also carries two framework DIAGNOSTICS that are not request failures, each with an `err.code` to group or filter on, both under `phase: 'action'`: `WEBJS_FORM_SUBMITTED_AS_GET` (a page GET carrying the reserved `__webjs_action` field in its query string, so a submission holding a bound action's identity went out as a GET and the action never ran, #1307; a bound submitter carries its own `formmethod="post"`, so what reaches this is an explicit `formmethod="get"` / `method="get"` the author wrote and the renderer honours rather than refuses) and `WEBJS_FORM_ACTION_MISSING` (a PARSEABLE form body carrying no identity, the 405; an `enctype="text/plain"` submission is answered before its body is read, so it stays a bare 405). Both are detect-only, so the 200 and the 405 are unchanged; both carry `method`, `pathname`, and for the second the submitted field NAMES, never the values; and both are deduplicated per process on the code, the method, and the matched ROUTE (not the request pathname, so crafted urls on a dynamic route cannot exhaust the 256-entry cap and silence the diagnostics), since either is reachable by an unauthenticated request and an uncapped report would be a free amplifier into a paid sink.
|
|
237
237
|
|
|
238
238
|
```ts
|
|
239
239
|
const app = await createRequestHandler({
|
|
@@ -56,6 +56,16 @@ revalidate(); // clear the entire snapshot cache
|
|
|
56
56
|
|
|
57
57
|
The router keeps a URL-keyed snapshot cache (LRU, cap 16) so Back/Forward restores instantly, then refetches in the background. Call `revalidate(path)` after a server action mutates data a cached page depends on. Wire bytes are minimized by an `X-Webjs-Have` header, so the server returns only the divergent layout fragment. Concurrent navigations abort the prior in-flight fetch, and scroll is restored on Back/Forward.
|
|
58
58
|
|
|
59
|
+
**Back/Forward scroll restore vs late layout growth.** The router SUPPRESSES the browser's scroll anchoring (`overflow-anchor`) for the duration of a Back/Forward restore, then puts it back. The saved offset was recorded against the page at its SETTLED height, while the DOM the restore swaps in is still shorter until its components upgrade and render. Without the suppression the browser treats that late growth as content appearing above a reader and adds it to the offset the router just replayed, so the reader lands BELOW where they left (the reported case was 763px, exactly the height a page gained after its swap). What follows for an app:
|
|
60
|
+
|
|
61
|
+
- **Do not write your own scroll restore.** A `popstate` listener that calls `scrollTo`, a saved offset in `sessionStorage`, a `scrollIntoView` on a remembered element: all of them fight the router, which already set `history.scrollRestoration = 'manual'` and is the sole authority on scroll during a navigation. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
|
|
62
|
+
- **An app that sets `overflow-anchor` on `<html>` itself sees it overridden during a restore and restored afterwards**, including a value set inline by your own script. Setting it in a stylesheet is unaffected between restores. Nothing else on the page is touched, and the router never sets `overflow-anchor` anywhere but the root element.
|
|
63
|
+
- **A new PAGE navigation ends an open window.** The window outlives its own restore on purpose (a floor, then a ceiling), so a page navigation or a page-level form submission starting inside that span closes it first, and reopens only if it earns one. Otherwise a second Back, or a click, would inherit suppressed anchoring on a page it was never meant for. A FRAME-TARGETED navigation or submission is the exception, on exactly the rule that decides frame targeting everywhere else (the enclosing frame, an explicit `data-webjs-frame="<id>"` from anywhere, or the frame's own `src`; `_top` and an unresolvable id are page navigations and do close the window). It swaps one region and leaves the page, and so the restored offset, intact, so it leaves the restore running. Closing there would hand anchoring back mid-restore and bring the double count straight back, and it needs no user input to happen, since a component upgrading in the just-restored page can drive a frame on its own.
|
|
64
|
+
- **Suppression is conditional on the offset being reachable, and follows the chase onto it.** A page that has not grown yet can be too short to scroll that far, so the browser clamps to its current maximum. There the shortfall IS the growth still to come, and anchoring adding it is what carries the reader back down, so the router leaves anchoring alone. Suppressing in that case would freeze the clamp and strand the reader a full page-growth above where they left, which is this same defect pointing the other way. That case is not left to anchoring alone, though, because anchoring adds the FULL growth however far short the clamp fell, so by itself it only lands a reader who left at the very bottom. The router also CHASES the recorded offset there, re-asserting it the moment the page is tall enough to hold it, and then stopping. That is the one place the router writes scroll after the initial restore, it is scoped to the clamped path, and it stops on the same inputs that close a suppression window. It is also time-boxed, and more tightly than the window a landed restore gets: a few hundred milliseconds from the RESTORE, not the 2s ceiling, and the suppression it installs on landing shares that same deadline rather than starting a fresh one. That bound is what keeps it from moving a reader who has landed and started reading, since such a reader generates no input to cancel it and the chase cannot tell the restore settling apart from any other growth. Anchoring is left on only WHILE the offset is out of reach, which is the part that heals the clamp. The moment the chase lands on the offset it suppresses anchoring too, because the growth that made the offset reachable is rarely all of it and every later stage would otherwise be added on top of what was just written. Both halves end together on the bound. After it, the router writes no more scroll and anchoring is back on, so a component that reaches its final height later than the bound (a chart, an embed measured from its content) has its growth added and the reader drifts BELOW the offset, the same way they would without this fix at all, rather than sitting at the clamp.
|
|
65
|
+
- **The window closes on the first real input** (`wheel`, `touchmove`, `keydown`, `pointerdown`), so a reader who starts scrolling mid-restore immediately gets normal browser anchoring back. Absent that it closes once the restore is over, which is the LATER of the restore's own background revalidation settling and a short floor, and at the latest on a 2s ceiling. The floor is load-bearing: waiting on the revalidation alone ties the window's length to network latency rather than to the growth it guards, so a server answering faster than the page renders would close it early and the reader would land low again. Suppression only ever WITHHOLDS a browser correction, it never moves the viewport, so it cannot yank someone who has taken over.
|
|
66
|
+
|
|
67
|
+
Components that reach their final size only after they render (a chart, a media embed with no intrinsic dimensions, anything sized from measured content) are exactly the shape that triggers this, and they need no special handling: give them a placeholder height where you can, and let the router own the restore.
|
|
68
|
+
|
|
59
69
|
**Error recovery.** A 2xx/3xx swap applies in place, and an HTML error body of any status (a 422 re-rendered form, a 5xx error page) is ALSO applied in place with no reload. For a non-HTML error or a transport failure the router dispatches a cancelable `webjs:navigation-error` on `document` (detail `{ url, status, error }`). Call `preventDefault()` to own recovery, otherwise the router renders a minimal in-place alert into the layout slot.
|
|
60
70
|
|
|
61
71
|
```ts
|
|
@@ -183,7 +183,7 @@ The boundary also covers `watch(signal)` (its notify microtask) and `until()` (i
|
|
|
183
183
|
|
|
184
184
|
**A commit that throws leaves the directive's own state consistent, so the NEXT valid render is correct.** This matters because the corruption is otherwise silent: the renders that expose it are fully valid and log nothing after the first throw. The hole whose commit threw is marked so the next render re-applies it rather than skipping it as unchanged (its recorded value is never advanced past a throw, and would otherwise match exactly what the recovering render supplies, leaving a child region blank for good). Both list reconcilers additionally repair their own bookkeeping so it describes the DOM again, and the next render is an ordinary reconcile rather than a rebuild of the region, which would discard the node identity the reconcilers exist to preserve. `repeat()` re-unites its key map and repositions every row (the failure was a permanently duplicated row). A plain `.map()` array splices the part of its slot list the failed pass never reached back on, which matters whenever a slot is REPLACED rather than updated in place (its template shape changed, its kind changed between text, template and empty, or the array grew past its old length), since that is the branch that inserts the replacement before removing what it replaced (the failure was a stranded row that outlived even a render of an empty array). `guard()` records its new deps only once the commit succeeds, so a later render with those same deps re-renders the region instead of short-circuiting past a region the throw had blanked; `until()` advances its resolved priority only after the commit succeeds, so a failed high-priority resolution does not refuse the lower-priority one behind it.
|
|
185
185
|
|
|
186
|
-
**Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it.
|
|
186
|
+
**Teardown is total as well.** Removing a row is not a commit and has no retry, so a throw while tearing one down cannot be allowed to abandon the rest. Unbinding a `ref` during teardown can never abort the removal of the remaining rows, and `repeat()` drops each leftover key from its map before touching that row, so the map never describes a row that has already been removed (which used to leave the row the app DELETED on screen, reorder the survivors, and let a later render that re-added that key reinsert the disposed instance). To make that hold, a `ref` whose object `value` setter throws is now SWALLOWED on teardown, matching the ref CALLBACK, which was already swallowed everywhere. That is a deliberate divergence from lit, which guards neither and propagates from both. It applies to teardown only: on the COMMIT path a throwing object-ref setter still reaches `renderError()`, because there the boundary can report it and the next render can repair it. Total also means a removal takes the row's own boundary markers with it, so a list that grows and shrinks all day is net zero on the nodes the renderer added, rather than accruing one invisible comment per removed row for the life of the region.
|
|
187
187
|
|
|
188
188
|
Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `<webjs-suspense .fallback=${html\`Loading...\`}>` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself.
|
|
189
189
|
|
|
@@ -264,7 +264,56 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
|
|
|
264
264
|
- the dynamic slot READ surface (`slotchange`, `assignedNodes` / `assignedElements` / `assignedSlot`); merely RENDERING a `<slot>` does not ship (the SSR output carries the placed children, so a display-only slotted wrapper is byte-identical without its JS; native-write liveness is consumer-driven and the consumer's tag reference forces the ship)
|
|
265
265
|
- being rendered by a component that itself ships
|
|
266
266
|
|
|
267
|
-
A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis
|
|
267
|
+
A bare `async render()` (no other signal, light DOM) is elided too: the SSR'd data is the complete first paint. Force shipping with `static interactive = true` when interactivity is invisible to static analysis. `static shadow = true` always ships (Declarative Shadow DOM re-attaches only during parsing). Turn elision off app-wide with `{ "webjs": { "elide": false } }` or `WEBJS_ELIDE=0`.
|
|
268
|
+
|
|
269
|
+
### What `static interactive = true` does and does not rescue
|
|
270
|
+
|
|
271
|
+
The analyser reads source lexically, so a few real shapes escape it. The override covers them:
|
|
272
|
+
|
|
273
|
+
- **An OBSERVER that computes the tag it waits for.** `customElements.whenDefined(TAG)` where `TAG` is a variable does not name a tag the analyser can resolve, so the observed component is elided, its `register` never runs, and the `await` never settles. Put `static interactive = true` on the OBSERVED component.
|
|
274
|
+
- **A `:defined` rule in an external stylesheet.** `public/app.css` is not in the module graph, so a `my-badge:defined { … }` rule is invisible. Same fix, on the component the rule names.
|
|
275
|
+
- **A consumer that reaches the element through a string selector.** The analyser matches `whenDefined` / `:defined` / `instanceof`, so a `document.querySelector('my-wrapper')` consumer escapes all three. Same fix, on the component being reached.
|
|
276
|
+
|
|
277
|
+
**It does NOT rescue a component whose OWN registration tag is computed.** `Badge.register(TAG)` is not a registration the scanner recognises (invariant 3 requires a literal tag), so that component is never in the component set at all: it gets no verdict, nothing consults the analyser for it, and the override has nothing to attach to. The registration still runs if the module reaches the browser, so what you ALWAYS lose is the verdict, the tag-to-module registry entry, and the preload hint. Whether the element upgrades depends on one thing: the importing module has to ship WHOLE. An inert, import-only, or elided importer is dropped from the boot and takes the import with it, and then the element never registers at all. A page rendering a real component alongside the orphan is import-only unless it ALSO does its own client work, so shipping whole is the narrower case: assume the element does not upgrade. Always pass a literal: `Badge.register('my-badge')`.
|
|
278
|
+
|
|
279
|
+
`webjs dev` warns, and `webjs elision` / `webjs doctor` report it, as an **orphan**. That name covers TWO shapes and they fail differently, so read the warning carefully: a computed tag is the case above, while a class with NO registration call anywhere in the app is the plainer one (someone forgot to register it), and that element never upgrades. The check is app-wide, so registering the class from a sibling module is fine and is not reported. Both lose the verdict, the registry entry, and the preload hint.
|
|
280
|
+
|
|
281
|
+
### Inspecting and proving the verdict
|
|
282
|
+
|
|
283
|
+
Elision is the one thing WebJs decides about your code that you did not write down, so it is inspectable rather than something to reason about from the rules above.
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
webjs elision # per-module verdict, and the evidence behind every ship
|
|
287
|
+
webjs elision --json # the same object, for a tool or an agent
|
|
288
|
+
webjs elision --verify # prove elision changed nothing your app serves
|
|
289
|
+
webjs elision --verify --routes /,/blog/hello # add paths (the only way to cover a dynamic route)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**Reading the report.** Every component is `elided` or `shipped`. A shipped one carries the `evidence` that forced it, first match wins:
|
|
293
|
+
|
|
294
|
+
| `evidence` | Means | `by` |
|
|
295
|
+
|---|---|---|
|
|
296
|
+
| `own` | its own source carries a signal; `reason` is the exact one | null |
|
|
297
|
+
| `observed` | another module observes its registration (`whenDefined` / `:defined` / `instanceof`) | the observer |
|
|
298
|
+
| `closure` | something it imports does client work | the import |
|
|
299
|
+
| `render` | a shipping component can render its tag | that component |
|
|
300
|
+
| `import` | a shipping component imports it | that component |
|
|
301
|
+
| `unreadable` | its source could not be read, so it ships conservatively | null |
|
|
302
|
+
|
|
303
|
+
An elided row carries no reason on purpose: elision is the ABSENCE of every signal, so there is no positive fact to report.
|
|
304
|
+
|
|
305
|
+
**What to do with each verdict.** `elided` on a component you believe is interactive is the one result worth acting on: find the signal it is missing (the list above), and if the interactivity is genuinely invisible to static analysis, add `static interactive = true`. `shipped` with an `evidence` you did not expect is usually a `closure` row, and the fix is to move the client-effecting import out of that component's path. An `orphans` row is always a bug, and the fix depends on which shape it is: give the class a literal registration tag if its tag is computed, or add the missing `Class.register('my-tag')` call if there is none at all (delete the class instead if nothing uses it).
|
|
306
|
+
|
|
307
|
+
**What `--verify` proves.** It renders every static page route with elision on and off and diffs the bytes with the JS-loaded set masked out, which is the framework's own guard pointed at your app. So it proves elision did not change what your app SERVES. It does not prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Cover that half by running your own browser or e2e suite twice:
|
|
308
|
+
|
|
309
|
+
```sh
|
|
310
|
+
WEBJS_ELIDE=1 npm run test:e2e
|
|
311
|
+
WEBJS_ELIDE=0 npm run test:e2e
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
It exits non-zero on a divergence AND on a corpus where nothing could be compared, so it is safe to put in CI. The ON side is forced on rather than read from your config, so the comparison is a real one even in an app that has elision switched off, and the run reports how many modules elision actually dropped so a trivially-true pass is visible. Dynamic routes are skipped by name (rendering one would mean inventing param values); pass real ones with `--routes`. A route whose two same-side renders already differ is reported as nondeterministic and excluded, since a differential over live data proves nothing.
|
|
315
|
+
|
|
316
|
+
`webjs doctor` carries the same verdict as a one-line inventory, and warns only on an orphan.
|
|
268
317
|
|
|
269
318
|
## Members app code must not shadow
|
|
270
319
|
|
|
@@ -135,7 +135,7 @@ import { createPost } from '#modules/posts/actions/create-post.server.ts';
|
|
|
135
135
|
html`<form action=${createPost}><input name="title"></form>`;
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, so the two paths are identical by construction.
|
|
138
|
+
The renderer omits the `action` attribute so the form posts to the page's own url, supplies `method="post"` and an enctype, and emits a hidden `__webjs_action` field carrying the action's `<hash>/<fn>` identity, the same identity the RPC endpoint resolves. Nothing about the action's source reaches the browser. With JS off this is an ordinary HTML submission; with JS the client router posts the same body to the same url, encoded per the declared `enctype` (#1307: multipart stays `FormData`, urlencoded, which is the HTML default, is sent as `URLSearchParams`), so the two paths are identical by construction.
|
|
139
139
|
|
|
140
140
|
**A form-bound action always receives the `FormData`**, which is where it differs from the same function called over RPC (rich arguments) or server-to-server. `validate` is the typing seam: it takes the `FormData` and its transform-return becomes the action's typed input.
|
|
141
141
|
|
|
@@ -236,3 +236,55 @@ import { posts } from '#db/schema.server.ts';
|
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
Keep the wire shape in a browser-safe `modules/<feature>/types.ts` with NO runtime import from a `.server.ts` file or from `db/`. Define a hand-written DTO, or a type-only derivation (`import type { Post } ...; export type PostFormatted = Omit<Post, 'createdAt'> & { createdAt: string }`). Never `export *` or a value re-export from a `.server.ts` in `types.ts`; that carries the runtime table bindings and breaks any component importing the types. Full reference at https://webjs.dev/docs.
|
|
239
|
+
|
|
240
|
+
## SSR action seeding, and how to tell it is working
|
|
241
|
+
|
|
242
|
+
When a shipping component's `async render()` awaits an action during SSR, WebJs serializes that result into the page and the generated RPC stub reads it on its FIRST client call. So `const u = await getUser(this.id)` runs once, on the server, and hydration reuses the result with no network round-trip.
|
|
243
|
+
|
|
244
|
+
**You write nothing for this.** It is automatic, on by default, and there is no API to call. The only thing you can do is break it, so the section below is about noticing when you have.
|
|
245
|
+
|
|
246
|
+
### The correctness boundary
|
|
247
|
+
|
|
248
|
+
A seed hit returns the value the SSR render that produced this page computed for exactly this action, function, and argument list, so a hit cannot show the user something different from the HTML they are already looking at. A page navigation evicts whatever the outgoing page left unconsumed, both the block still in the DOM and anything already ingested from it, so a departed render's value is never served. On an HTML-cached page (`export const revalidate`) the seed rides inside the cached bytes, so it is exactly as fresh as the HTML it came with. A miss simply re-fetches.
|
|
249
|
+
|
|
250
|
+
There is one shape where a hit can differ from the paint, and WebJs warns about it in dev: **an action that returns a DIFFERENT result for the SAME arguments twice in one render.** The seed carries the last result while the first component painted the first one. So keep an action deterministic for a given argument list. A counter, a `Math.random()`, a `new Date()` in the return value, or a read of mutable module state all break that rule, and dev prints:
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
[webjs] SSR action seeding: "getUser" returned two DIFFERENT results for the SAME arguments during one render. ...
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The fix is to make the action deterministic, or to move the varying part into an argument so the two calls get different keys.
|
|
257
|
+
|
|
258
|
+
### Reading the dev diagnostics
|
|
259
|
+
|
|
260
|
+
A miss is invisible from the outside: the page still renders correctly, it just pays a round-trip per async component on every first load. Two channels make it visible in dev, and neither exists in production.
|
|
261
|
+
|
|
262
|
+
**Server side, per request.** The `X-Webjs-Seed` response header, also folded into the dev access-log line as a `seed` field:
|
|
263
|
+
|
|
264
|
+
| Value | What it means |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `off` | Seeding is switched off (`"webjs": { "seed": false }` or `WEBJS_SEED=0`). Not a defect. |
|
|
267
|
+
| `html-cache` | The #241 HTML response cache answered. The seeds rode inside the cached bytes. |
|
|
268
|
+
| `collected=3, emitted=3` | Healthy. Three action results were captured and all three reached the page. |
|
|
269
|
+
| `collected=3, emitted=0` | The serializer threw and dropped the whole block. Something in a returned value is not serializer-safe. |
|
|
270
|
+
| `collected=3, emitted=0, streamed` | The page streams, so nothing could be emitted (see below). |
|
|
271
|
+
|
|
272
|
+
Check it with `curl -sSI localhost:3000/` or in the network tab.
|
|
273
|
+
|
|
274
|
+
**Browser side, per page view.** One `console.warn` at the first idle after hydration, and only when a call missed AND the client can be certain why. It stays silent otherwise, including on a page that emitted no seeds at all: every action call routes through the seed lookup, including ones that were never SSR-invoked and never could have been seeded (a mutation, a `Task` autorun, a `connectedCallback` read), so a miss there is not evidence of a defect. That case is the server header's job, where `collected=0` is unambiguous. The line names one of these:
|
|
275
|
+
|
|
276
|
+
- *"This page streams"*, so no seeds could be emitted. Expected, not a bug (see below).
|
|
277
|
+
- *"The page's seeds could not be serialized."* Something an action returned is not serializer-safe, so the whole block was dropped. The response header shows `collected` above `emitted` for the same reason.
|
|
278
|
+
- *"The page seeded these actions under DIFFERENT arguments."* The key is `hash(action file) / function name / serialized arguments`, so the client asked with an argument the SSR render never used. Common cause: the component computes its argument from browser-only state (a `localStorage` read, a `connectedCallback` assignment), which the server render could not have known. A miss on an action the page never seeded at all is NOT reported, because a mutation or a client-only read routes through the same lookup and could never have been seeded.
|
|
279
|
+
|
|
280
|
+
A miss AFTER hydration is correct and is not reported: the seed is consume-once, so a deliberate refetch or an argument change is supposed to go to the network.
|
|
281
|
+
|
|
282
|
+
`seedStats()` from `@webjsdev/core` returns `{ ingested, replaced, hits, misses, keyMisses, pending }` (`keyMisses` being the provable subset of `misses`, a call for an action the page seeded under other arguments) if you want to assert this in a browser test or read it from the console. A non-zero `pending` at rest usually means the seeding component ELIDED, so its module never shipped and nothing on the client was ever going to consume the seed. `pending` covers the page you are on: a page navigation evicts whatever the outgoing page left unconsumed, both the block still sitting in the DOM and anything already ingested from it, since those values belong to a render no longer on screen.
|
|
283
|
+
|
|
284
|
+
### The streamed-page exception
|
|
285
|
+
|
|
286
|
+
A page carrying a `Suspense` or `<webjs-suspense>` boundary emits NO seed block at all, not just none for the streamed region: a streamed render's deferred boundaries resolve after the first flush, so their results cannot ride the block. Every action call on that page goes to the network on hydration. That is a real trade, so make it deliberately: reach for a streaming boundary when a slow region would otherwise block the first byte, and leave a fast page buffered so it seeds.
|
|
287
|
+
|
|
288
|
+
### Switching it off
|
|
289
|
+
|
|
290
|
+
`"webjs": { "seed": false }` in `package.json`, or `WEBJS_SEED=0`. The client then re-fetches on hydration exactly as it did before the feature, and stale-while-revalidate hides the flicker. Turn it off only to isolate a problem; there is no reason to ship with it off.
|
|
@@ -64,7 +64,21 @@ html`<form action=${saveDraft}>
|
|
|
64
64
|
</form>`;
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
|
|
67
|
+
The identity rides the pressed button's own `name`/`value` pair, which a browser submits for that button alone, so this works with JS off exactly as it does with JS on. Both entries reach the server and the LAST wins, which is always the submitter's when one was pressed.
|
|
68
|
+
|
|
69
|
+
**A bound submitter is self-sufficient, so the enclosing form does not have to be bound** (#1307). The renderer puts `formmethod="post"` and `formenctype="multipart/form-data"` on the button itself, alongside the identity, exactly as React emits `formMethod` and `formEncType` on a button carrying a function `formAction`. So a per-button action works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// Every button below submits a POST its action can read, with JS on or off.
|
|
73
|
+
html`<form>
|
|
74
|
+
<button formaction=${saveDraft}>Save</button>
|
|
75
|
+
<button formaction=${publishPost}>Publish</button>
|
|
76
|
+
</form>`;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
One consequence worth knowing rather than discovering: no `formaction` url is emitted (an empty one is an HTML conformance error), so the submission targets whatever the FORM targets. A form declaring its own `action="/x"` sends its buttons to `/x`, which is native precedence. The action still runs if `/x` is a PAGE route, because the identity travels in the body, but against a `route.ts` or another origin the identity is ignored and nothing runs. In dev the client logs a warning at submit time naming the url. Leaving the form's `action` off, the ordinary shape, keeps the submission on the current page.
|
|
80
|
+
|
|
81
|
+
The submitter must be a `<button>` and cannot carry its own `name`, `value`, or `form` attribute, because the identity already occupies that pair. `<input type="submit">` is refused for the binding: the identity has to occupy its `value`, which on that control is also the visible caption, so the button would render captioned with the action id and could never be labelled. A `<button>` has no such conflict, since its label is its children.
|
|
68
82
|
|
|
69
83
|
**Only the bare, unquoted `action=${fn}` on a `<form>` binds.** Every near-miss is a hard render error rather than a silently-inert form, and the reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike.
|
|
70
84
|
|
|
@@ -95,21 +109,21 @@ The bound, refused, and allowed shapes in full. Every "no" row is a binding that
|
|
|
95
109
|
| `action=${fn}` unquoted, on a `<form>` | **no, it BINDS** | the one supported shape: the identity is resolved and emitted as a hidden field, nothing is stringified |
|
|
96
110
|
| `action=${fn}` on any other tag | yes | `action` submits nothing off a `<form>`, so it is an ordinary attribute and the function would be stringified |
|
|
97
111
|
| `action="${fn}"`, or a mixed `action="/x/${fn}"` | yes | quoting turns a binding hole back into a plain attribute |
|
|
98
|
-
| `formaction=${fn}` unquoted, on a submitter
|
|
99
|
-
| `formaction=${fn}` inside an UNBOUND `<form>` | yes | `method="post"` and the enctype are forced on the FORM's start tag, which SSR has already emitted by the time it reaches the button, so a per-button action cannot retrofit them |
|
|
112
|
+
| `formaction=${fn}` unquoted, on a submitter, ANYWHERE | **no, it BINDS** | the second supported shape (#1207, #1307). A bound submitter carries its WHOLE submission: the identity rides the button's own `name`/`value` pair, the one channel a browser submits for the pressed button alone, and the renderer adds `formmethod="post"` and `formenctype="multipart/form-data"` to the button itself. So it works inside a bound form, an unbound form, a `method="get"` form, or a form with no method at all, and it asks NOTHING of the element around it. No `formaction` url is emitted, and the server takes the LAST `__webjs_action` entry |
|
|
100
113
|
| `formaction=${fn}` on a submitter carrying its own `name` or `value` | yes | the identity IS that name/value pair, so both halves are already spoken for. Bind one action on the form and dispatch on `name="intent"` if you need the button's own value |
|
|
101
114
|
| `formaction=${fn}` on a non-submit control, or `<input type="image">` | yes | `formaction` is inert on anything that does not submit, and an image submitter sends `name.x` / `name.y` coordinates instead of `name=value`, so the identity would never arrive |
|
|
102
|
-
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a form
|
|
103
|
-
| `formmethod
|
|
115
|
+
| `formaction=${fn}` on a submitter with `form="other"` | yes | it re-points the submitter at a different form owner, which may not be where the identity field it needs lives |
|
|
116
|
+
| `formmethod` / `formenctype` the BOUND submitter cannot submit with (`get`, `PATCH`, `text/plain`, `dialog`, a padded `" post "`) | yes | a same-element contradiction: you attached an action to THIS button and told THIS button to submit in a way that action could never read. The renderer supplies `formmethod="post"` and the enctype only where you supplied neither, so your own parseable value always wins |
|
|
117
|
+
| `formmethod="get"` / `formenctype="text/plain"` on a PLAIN submitter inside a bound form | **no** | #1307 reversed this. Native HTML says the submitter's override wins, you typed it deliberately, and the form's action simply does not run, exactly as the same markup behaves anywhere else. In dev the client logs a console error at submit time if the submission is carrying an identity it cannot deliver |
|
|
104
118
|
| `formmethod="dialog"` on a submitter that binds nothing | **no** | a native `<dialog>` dismissal, never a submission, so there is no body for the action to miss. It IS refused on a button that also binds an action, which is a straight contradiction |
|
|
105
|
-
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so
|
|
119
|
+
| a plain `formaction="/url"` on a submitter inside a bound form | **no** | it retargets the submission away from the page's bound action entirely, so where it goes and how is your business |
|
|
106
120
|
| `.action=` on a native form | yes | the supported binding is the plain attribute, and a `.prop` on a native element drops at SSR, so accepting it would mean a form that submits under JS and does nothing without it |
|
|
107
121
|
| `.method=` / `.enctype=` / `.encoding=` on a BOUND form | yes | the same reason one level over. All three are reflected IDL attributes, so SSR drops the binding and emits `method="post"` while a browser ends at what you assigned. Write them as plain attributes |
|
|
108
122
|
| a second `action=${fn}` on one form | yes | SSR emits the second as a plain url next to the identity field, the client takes the last. Bind exactly one, in either position |
|
|
109
123
|
| a plain `action="/url"` beside the bound hole | yes | the hole drops only its OWN attribute, so SSR keeps the static one while the client removes it: without JS the browser posts to `/url`, with JS to the page |
|
|
110
124
|
| `method=" post "` / `enctype=" multipart/form-data "` | yes | `method` and `enctype` are enumerated attributes matched against exact keywords with no whitespace stripping, so a padded value falls to the invalid-value default and the form submits as a GET with no body. Trimming it for you would emit the padded value anyway |
|
|
111
125
|
| `encoding="..."` as an ATTRIBUTE on a bound form | **no** | inert in HTML (`form.encoding` reads back `enctype`), so both renderers ignore it and still supply `enctype`. Only the `.encoding` PROPERTY aliases enctype, and that spelling IS refused, one row up |
|
|
112
|
-
| `.formAction=` on a
|
|
126
|
+
| `.formAction=` / `.formMethod=` / `.formEnctype=` on a BOUND submitter | yes | same reason, that is where they reflect: SSR drops the property and the browser applies it, so the button would submit one way with JS and another way without. On a PLAIN button they are ordinary native properties and are left alone |
|
|
113
127
|
| `.action=` on any other native tag | **no** | a plain expando (`<div .action=${fn}>`, `<button .action=${fn}>`), reflecting nothing, so nothing reaches the markup |
|
|
114
128
|
| `.action=` on a custom element | **no** | an author-defined property, not a reflected IDL attribute, so a function is a legitimate value. One declared `reflect: true` reflects on a path outside these commit sites, which used to write `String(value)` and emit the source. It now removes the attribute and warns instead, for a bare function and for an array carrying one, unless the prop supplies its own `converter.toAttribute`, which runs first and stays the author's call |
|
|
115
129
|
| `?action=` | yes | a function never leaked through a boolean hole, but the binding is meaningless, so it is refused rather than emitting the bare `action=""` that ANY truthy value produces there. Two separate facts worth carrying: `action=""` is a conformance error (the spec wants a valid non-empty URL whenever the attribute is present), and deleting the attribute is still not the WebJs fix, since a page has no `action` export and an unbound `method="post"` form is a 405 (a bare GET form just re-renders). Bind it: `<form action=${fn}>` |
|
|
@@ -170,6 +184,12 @@ The file stays `middleware.ts`, NOT Next 16's renamed `proxy.ts`. WebJs middlewa
|
|
|
170
184
|
|
|
171
185
|
Navigation is automatic. The client router auto-enables when `@webjsdev/core` loads (any page with a component), so a plain `<a href>` gets soft navigation for free. There is no `<Link>` to import and no `useRouter`. For programmatic navigation import `navigate()` / `revalidate()` from `@webjsdev/core`. There is no `next/image`, `next/font`, `next/script`, or `next/dynamic`. WebJs is no-build: use a plain `<img>`, a `<link>` / `@font-face`, a component's `static lazy = true` for viewport lazy-loading, and a dynamic `import()` where code should load lazily.
|
|
172
186
|
|
|
187
|
+
### No `<ScrollRestoration>`, and no scroll restore of your own
|
|
188
|
+
|
|
189
|
+
Remix ships a `<ScrollRestoration />` component, Next has a `scrollRestoration` flag and a pile of community `useEffect` + `scrollTo` recipes, and every one of them is a thing to NOT port. WebJs restores scroll on Back/Forward automatically: the router sets `history.scrollRestoration = 'manual'` on boot and is the sole authority on scroll for the whole navigation. There is no component to render and no option to enable. An app-level `popstate` listener that calls `scrollTo`, a remembered offset in `sessionStorage`, or a `scrollIntoView` on a saved element all race the router and win sometimes, which is worse than losing consistently.
|
|
190
|
+
|
|
191
|
+
This includes the case that most tempts a hand-rolled fix: Back landing BELOW where the reader left, on a page whose components size themselves after they render. The router already handles it, by suppressing the browser's scroll anchoring across the restore so late growth above the viewport is not added to the offset it just replayed (see `client-router-and-streaming.md`). If a restore still lands wrong, report it rather than patching around it in app code.
|
|
192
|
+
|
|
173
193
|
### Server-only code: the `.server.ts` boundary, not a `server-only` package
|
|
174
194
|
|
|
175
195
|
Next poisons a client-imported module with the `server-only` package. WebJs uses the file extension: `*.server.ts` is the path-level boundary (the file router refuses to serve the source). A `'use server'` file's exports are RPC-callable; a `.server.ts` file WITHOUT `'use server'` is a server-only utility whose browser import throws at load. Reach a no-`'use server'` utility through a `'use server'` action, `route.ts`, or `middleware`, never by direct import into a shipping page or component.
|
|
@@ -104,7 +104,7 @@ render() {
|
|
|
104
104
|
|
|
105
105
|
`method` and the enctype are supplied by the renderer, and the hidden identity field is re-inserted as the form's first child on every client render, so there is nothing to manage by hand.
|
|
106
106
|
|
|
107
|
-
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons
|
|
107
|
+
When a page owns SEVERAL mutations (create, toggle, delete), give each form its OWN binding (`action=${createTodo}` / `action=${toggleTodo}` / `action=${deleteTodo}`), or use per-button submitter server action bindings via `formaction=${action}` on submitter buttons (#1207, #1307: a bound submitter carries its own submission, so the enclosing form need not be bound). Alternatively, a form that dispatches dynamically can bind ONE action and inspect a submit button's `name="intent"`.
|
|
108
108
|
|
|
109
109
|
## Seed the list from the server for SSR plus optimistic
|
|
110
110
|
|
|
@@ -179,7 +179,7 @@ Three responses that are not the happy path:
|
|
|
179
179
|
|
|
180
180
|
The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
|
|
181
181
|
|
|
182
|
-
Refusals worth knowing: `formaction=${fn}` is supported
|
|
182
|
+
Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
|
|
183
183
|
|
|
184
184
|
## Error, loading, and 404 boundaries
|
|
185
185
|
|
|
@@ -170,6 +170,25 @@ A cross-runtime proof is often a plain assert script rather than a test file, so
|
|
|
170
170
|
- **Assert the exit code, never the logs.** These scripts conventionally pass a `quiet` logger, so anything that only logs is invisible. If your script catches its own failure, report it with an explicit `process.exit(1)` rather than a `console.error` alone, guarded as `if (import.meta.main) process.exit(1); else throw failure;`. The guard matters when a `.test.mjs` wrapper imports the script under `node --test`: an unguarded exit kills the whole single-process run and hides every other file's results, while the throw lets the harness report one failed test.
|
|
171
171
|
- **Prove the script can FAIL before you trust it passing.** Break one assertion on purpose and confirm the run exits non-zero. A proof that cannot go red is worse than no proof: it reports success forever.
|
|
172
172
|
|
|
173
|
+
## Proving display-only elision did not break anything
|
|
174
|
+
|
|
175
|
+
WebJs strips the JavaScript of every component that does no client work, so a wrong verdict costs an app real interactivity and does it silently. Two commands cover the two halves, and you need both.
|
|
176
|
+
|
|
177
|
+
```sh
|
|
178
|
+
webjs elision --verify
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
renders every static page route with elision on and off and diffs the served bytes. It is the framework's own differential guard pointed at your route table, and it exits non-zero on a divergence AND on a corpus where nothing could be compared, so it belongs in CI. It forces the ON side on rather than reading your config, and reports how many modules elision actually dropped, so a pass that compared two identical renders is visible rather than silent. Dynamic routes are skipped by name; add real paths with `--routes /,/blog/hello`.
|
|
182
|
+
|
|
183
|
+
That proves the bytes you SERVE did not change. It cannot prove post-hydration behaviour, because a wrongly dropped module shows up as a dead click, not as different bytes. Run your own browser or e2e suite twice for that half:
|
|
184
|
+
|
|
185
|
+
```sh
|
|
186
|
+
WEBJS_ELIDE=1 npm run test:e2e
|
|
187
|
+
WEBJS_ELIDE=0 npm run test:e2e
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
|
|
191
|
+
|
|
173
192
|
## Convention validation (`webjs check`)
|
|
174
193
|
|
|
175
194
|
`npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
|
package/src/mcp.js
CHANGED
|
@@ -163,6 +163,12 @@ const TOOL_DEFS = [
|
|
|
163
163
|
'List registered custom-element tags: tag name, defining file, and class name. Read-only.',
|
|
164
164
|
inputSchema: APPDIR_SCHEMA,
|
|
165
165
|
},
|
|
166
|
+
{
|
|
167
|
+
name: 'list_elision',
|
|
168
|
+
description:
|
|
169
|
+
'Report the display-only elision verdict: every component module with whether it is elided (the browser never downloads it) or shipped plus the evidence that produced the verdict, every page/layout route module as inert / import-only / shipped (with the first client-effecting blocker that pins it), and any ORPHAN component class, one that either has no registration call at all or registers a computed tag, which the scanner cannot see either way, so it always loses its elision verdict, its tag-to-module registry entry, and its preload hint (and never upgrades at all with no registration call, or when a computed-tag class has no importer that ships whole). Identical to `webjs elision --json`. Read-only.',
|
|
170
|
+
inputSchema: APPDIR_SCHEMA,
|
|
171
|
+
},
|
|
166
172
|
{
|
|
167
173
|
name: 'check',
|
|
168
174
|
description:
|
|
@@ -298,7 +304,7 @@ export function extractActionConfig(src) {
|
|
|
298
304
|
* JSON-serialisable projection of an existing server data function. All are
|
|
299
305
|
* read-only.
|
|
300
306
|
*
|
|
301
|
-
* @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
|
|
307
|
+
* @param {{ buildRouteTable: Function, buildActionIndex: Function, hashFile: Function, scanComponents: Function, analyzeAppElision: Function, checkConventions: Function, projectCheck: Function, readFile: Function }} deps
|
|
302
308
|
*/
|
|
303
309
|
export function makeToolRunners(deps) {
|
|
304
310
|
const {
|
|
@@ -306,6 +312,7 @@ export function makeToolRunners(deps) {
|
|
|
306
312
|
buildActionIndex,
|
|
307
313
|
hashFile,
|
|
308
314
|
scanComponents,
|
|
315
|
+
analyzeAppElision,
|
|
309
316
|
checkConventions,
|
|
310
317
|
projectCheck,
|
|
311
318
|
readFile,
|
|
@@ -369,6 +376,15 @@ export function makeToolRunners(deps) {
|
|
|
369
376
|
.sort((a, b) => a.tag.localeCompare(b.tag));
|
|
370
377
|
},
|
|
371
378
|
|
|
379
|
+
async list_elision(appDir) {
|
|
380
|
+
// The whole report IS the contract: `analyzeAppElision` already returns
|
|
381
|
+
// an app-relative, sorted, JSON-serializable object, so unlike
|
|
382
|
+
// `list_routes` there is no projector leaf here and nothing to keep in
|
|
383
|
+
// sync. `webjs elision --json` prints this same object, and a drift test
|
|
384
|
+
// asserts the two are equal.
|
|
385
|
+
return analyzeAppElision(appDir);
|
|
386
|
+
},
|
|
387
|
+
|
|
372
388
|
async check(appDir) {
|
|
373
389
|
const violations = await checkConventions(appDir);
|
|
374
390
|
return projectCheck(violations);
|
|
@@ -475,6 +491,7 @@ export async function runMcpServer(opts) {
|
|
|
475
491
|
buildActionIndex: server.buildActionIndex,
|
|
476
492
|
hashFile: server.hashFile,
|
|
477
493
|
scanComponents: server.scanComponents,
|
|
494
|
+
analyzeAppElision: server.analyzeAppElision,
|
|
478
495
|
checkConventions: check.checkConventions,
|
|
479
496
|
projectCheck,
|
|
480
497
|
readFile,
|