@webjsdev/cli 0.10.56 → 0.10.58

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.
Files changed (36) hide show
  1. package/README.md +6 -1
  2. package/bin/webjs.js +219 -9
  3. package/lib/app-tasks.js +70 -10
  4. package/lib/check-target.js +1 -1
  5. package/lib/ci-config.js +250 -0
  6. package/lib/ci-runner.js +499 -0
  7. package/lib/create.js +59 -2
  8. package/lib/doctor/codes.js +1 -0
  9. package/lib/doctor/probes/framework-resolves.js +182 -6
  10. package/lib/doctor/runner.js +2 -1
  11. package/lib/doctor.js +1 -1
  12. package/lib/run-tasks.js +23 -3
  13. package/package.json +3 -3
  14. package/templates/.agents/rules/workflow.md +19 -12
  15. package/templates/.agents/skills/webjs/SKILL.md +6 -2
  16. package/templates/.agents/skills/webjs/references/built-ins.md +45 -1
  17. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +32 -3
  18. package/templates/.agents/skills/webjs/references/components.md +9 -1
  19. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +26 -2
  20. package/templates/.agents/skills/webjs/references/optimistic-ui.md +23 -11
  21. package/templates/.agents/skills/webjs/references/runtime.md +1 -1
  22. package/templates/.agents/skills/webjs/references/styling.md +48 -3
  23. package/templates/.agents/skills/webjs/references/testing.md +11 -0
  24. package/templates/.agents/skills/webjs/references/ui-kit.md +25 -0
  25. package/templates/.github/pull_request_template.md +3 -8
  26. package/templates/.github/workflows/ci.yml +38 -88
  27. package/templates/.hooks/pre-commit +5 -4
  28. package/templates/gallery/app/features/client-router/page.ts +5 -1
  29. package/templates/gallery/app/features/metadata/page.ts +7 -1
  30. package/templates/gallery/modules/client-router/components/router-controls.ts +7 -0
  31. package/templates/gallery/modules/stream/components/browser/stream-demo.test.js +64 -0
  32. package/templates/gallery/modules/stream/components/stream-demo.ts +14 -9
  33. package/templates/gallery/modules/stream/utils/ui/row.ts +41 -0
  34. package/templates/gallery/test/rate-limit/rate-limit.test.ts +2 -1
  35. package/templates/partials/agents-playbook-api.md +14 -8
  36. package/templates/partials/agents-playbook-fullstack.md +16 -10
@@ -1,5 +1,5 @@
1
- import { existsSync, statSync } from 'node:fs';
2
- import { join } from 'node:path';
1
+ import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from 'node:fs';
2
+ import { dirname, join, resolve, sep } from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
 
5
5
  /**
@@ -60,9 +60,7 @@ export function checkFrameworkResolves(appDir) {
60
60
  '@webjsdev/core cannot be resolved from this directory, and this is a git worktree with no ' +
61
61
  'node_modules. Git worktrees do not copy node_modules, so the framework is unresolvable here ' +
62
62
  'and `webjs dev` / `webjs start` would fail at SSR with a raw ERR_MODULE_NOT_FOUND.',
63
- fix:
64
- 'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
65
- 'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`).',
63
+ fix: freshWorktreeFix(appDir),
66
64
  };
67
65
  }
68
66
  if (!hasNodeModules) {
@@ -79,6 +77,184 @@ export function checkFrameworkResolves(appDir) {
79
77
  message:
80
78
  '@webjsdev/core cannot be resolved from this directory even though node_modules exists ' +
81
79
  '(a partial or corrupted install).',
82
- fix: 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).',
80
+ fix: linkAwareReinstallFix(appDir),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Whether `dir`'s own `node_modules` is a SYMLINK, which in a linked worktree
86
+ * means it points at the primary checkout's tree. The remedies below branch on
87
+ * this, because `npm install` is the right advice when it is false and is the
88
+ * exact command that corrupts the primary when it is true (#1442).
89
+ * @param {string} dir
90
+ * @returns {boolean}
91
+ */
92
+ function modulesAreLinked(dir) {
93
+ try { return lstatSync(join(dir, 'node_modules')).isSymbolicLink(); } catch { return false; }
94
+ }
95
+
96
+ /**
97
+ * The `npm run worktree:link` sentence, but ONLY where that script exists.
98
+ *
99
+ * This module ships in the PUBLISHED CLI, and `bin/webjs.js` prints these
100
+ * remedies verbatim as the `webjs dev` / `webjs start` preflight failure. A
101
+ * scaffolded app has no `worktree:link` script, so naming it unconditionally
102
+ * sends the exact audience this check exists for (#954, a fresh app worktree)
103
+ * to run something that does not exist. Walk up for a package.json that really
104
+ * declares it, and stay silent otherwise.
105
+ *
106
+ * @param {string} appDir
107
+ * @returns {string} a leading-space sentence, or the empty string
108
+ */
109
+ function linkScriptHint(appDir) {
110
+ let dir = resolve(appDir);
111
+ for (let i = 0; i < 8; i += 1) {
112
+ try {
113
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
114
+ if (pkg?.scripts?.['worktree:link']) {
115
+ return ' In this repo, `npm run worktree:link` does the whole setup and also repairs the shared tree.';
116
+ }
117
+ } catch { /* no package.json here, keep walking */ }
118
+ const up = dirname(dir);
119
+ if (up === dir) break;
120
+ dir = up;
121
+ }
122
+ return '';
123
+ }
124
+
125
+ /**
126
+ * Remedy for the #954 fresh-worktree case, which is a worktree with NO
127
+ * `node_modules` at all. There is no symlink in the way yet, so a real install
128
+ * is safe here, and this stays the app-generic advice it has always been.
129
+ * @param {string} appDir
130
+ * @returns {string}
131
+ */
132
+ function freshWorktreeFix(appDir) {
133
+ return (
134
+ 'Install dependencies in this worktree (`npm install`), or symlink node_modules from the ' +
135
+ 'primary checkout (`ln -s ../<primary-checkout>/node_modules node_modules`). If you symlink, ' +
136
+ 'never run an install through that link afterwards: it acts on the checkout that owns the ' +
137
+ 'tree, not this one (#1442).' + linkScriptHint(appDir)
138
+ );
139
+ }
140
+
141
+ /**
142
+ * Remedy for a `node_modules` that exists but does not resolve the framework.
143
+ * When it is a SYMLINK, a bare `npm install` is the action that corrupts the
144
+ * checkout that owns the tree, so the advice has to differ.
145
+ * @param {string} appDir
146
+ * @returns {string}
147
+ */
148
+ function linkAwareReinstallFix(appDir) {
149
+ if (modulesAreLinked(appDir)) {
150
+ return (
151
+ 'node_modules here is a SYMLINK at another checkout, so do NOT run `npm install`: it would act ' +
152
+ 'on that checkout, not this one (#1442). Either reinstall in the checkout that owns the tree, ' +
153
+ 'or remove every node_modules symlink first, nested ones included ' +
154
+ '(`find . -maxdepth 5 -type l -name node_modules -delete`), and install here.' + linkScriptHint(appDir)
155
+ );
156
+ }
157
+ return 'Reinstall dependencies (`npm install`, or remove node_modules and reinstall).';
158
+ }
159
+
160
+ /**
161
+ * Classify the `@webjsdev/core` entry that `appDir` would resolve through.
162
+ *
163
+ * Walks up for the first `node_modules` carrying the package, then judges the
164
+ * link against the tree that PHYSICALLY owns that `node_modules`. That owner
165
+ * rule is the only one correct in a linked worktree: there `node_modules` is
166
+ * itself a symlink at the primary's, so the owning tree is the PRIMARY and a
167
+ * target inside it is right rather than foreign. Judging against `appDir` would
168
+ * report every correctly linked worktree as corrupted.
169
+ *
170
+ * @param {string} appDir
171
+ * @param {string} [pkg]
172
+ * @returns {{ state: 'absent'|'real'|'ok'|'dangling'|'foreign', entry?: string, target?: string, owner?: string }}
173
+ */
174
+ export function inspectFrameworkLink(appDir, pkg = '@webjsdev/core') {
175
+ const parts = pkg.split('/');
176
+ let dir = resolve(appDir);
177
+ for (;;) {
178
+ const modules = join(dir, 'node_modules');
179
+ const entry = join(modules, ...parts);
180
+ let st = null;
181
+ try { st = lstatSync(entry); } catch { st = null; }
182
+ if (st) {
183
+ if (!st.isSymbolicLink()) return { state: 'real', entry };
184
+ let target = '';
185
+ try { target = readlinkSync(entry); } catch { return { state: 'real', entry }; }
186
+ // Resolve the target against the directory the link PHYSICALLY sits in,
187
+ // which is what the OS does. In a linked worktree `node_modules` is itself
188
+ // a symlink, so the lexical `dirname(entry)` is under the WORKTREE while
189
+ // the link really lives in the primary. Resolving lexically turns every
190
+ // correct `../../packages/core` into a worktree path and reports a healthy
191
+ // linked worktree as `foreign`.
192
+ let base = dirname(entry);
193
+ try { base = realpathSync(base); } catch { /* fall back to the lexical path */ }
194
+ const abs = resolve(base, target);
195
+ let owner = dir;
196
+ try { owner = dirname(realpathSync(modules)); } catch { /* use dir as given */ }
197
+ if (!existsSync(abs)) return { state: 'dangling', entry, target, owner };
198
+ let real = abs;
199
+ try { real = realpathSync(abs); } catch { /* compare the unresolved path */ }
200
+ let ownerReal = owner;
201
+ try { ownerReal = realpathSync(owner); } catch { /* compare as given */ }
202
+ if (real !== ownerReal && !real.startsWith(ownerReal + sep)) {
203
+ return { state: 'foreign', entry, target, owner: ownerReal };
204
+ }
205
+ return { state: 'ok', entry, target, owner: ownerReal };
206
+ }
207
+ const up = dirname(dir);
208
+ if (up === dir) return { state: 'absent' };
209
+ dir = up;
210
+ }
211
+ }
212
+
213
+ /**
214
+ * CHECK: framework link integrity (#1442). WARN when the `@webjsdev/core` entry
215
+ * in node_modules is a symlink that DANGLES or resolves OUTSIDE the tree that
216
+ * owns it. That is what an install run inside a linked worktree leaves behind,
217
+ * and it is invisible to the framework-resolve check above, which only asks
218
+ * whether the package resolves at all: a link into a live FOREIGN checkout
219
+ * resolves perfectly and silently runs another branch's framework source.
220
+ *
221
+ * Silent PASS for a real directory, a correct link, and no entry at all, so a
222
+ * normally installed app pays one `lstat`. WARN rather than fail, the same
223
+ * environment tier as the framework-resolve and version-coherence checks.
224
+ * @param {string} appDir
225
+ * @returns {DoctorResult}
226
+ */
227
+ export function checkFrameworkLinks(appDir) {
228
+ const name = 'framework-links';
229
+ const r = inspectFrameworkLink(appDir);
230
+ if (r.state === 'absent' || r.state === 'real' || r.state === 'ok') {
231
+ return {
232
+ name,
233
+ status: 'pass',
234
+ message: 'The @webjsdev framework links resolve inside the checkout that owns them.',
235
+ };
236
+ }
237
+ const fix =
238
+ 'Repoint the entry at the package inside the checkout that owns this node_modules. Do NOT run ' +
239
+ '`npm install` here while node_modules is a symlink: it acts on that owning checkout, not this ' +
240
+ 'one (#1442).' + linkScriptHint(appDir);
241
+ if (r.state === 'dangling') {
242
+ return {
243
+ name,
244
+ status: 'warn',
245
+ message:
246
+ `${r.entry} is a symlink to ${r.target}, which does not exist. An install run inside a linked ` +
247
+ 'worktree leaves these behind, and the worktree it named has since been removed.',
248
+ fix,
249
+ };
250
+ }
251
+ return {
252
+ name,
253
+ status: 'warn',
254
+ message:
255
+ `${r.entry} is a symlink to ${r.target}, which resolves OUTSIDE ${r.owner}, the checkout that ` +
256
+ 'owns this node_modules. It resolves fine, so nothing fails, and the framework source being run ' +
257
+ "is another checkout's. A deliberate `npm link` produces the same shape.",
258
+ fix,
83
259
  };
84
260
  }
@@ -10,7 +10,7 @@ import { checkGitHook } from './probes/git-hook.js';
10
10
  import { checkElisionCarriers, checkElisionComponents } from './probes/elision.js';
11
11
  import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
12
12
  import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
13
- import { checkFrameworkResolves } from './probes/framework-resolves.js';
13
+ import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js';
14
14
 
15
15
  /**
16
16
  * @typedef {import('./codes.js').DoctorResult} DoctorResult
@@ -57,6 +57,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
57
57
  checkVendorGitignore(appDir),
58
58
  checkWebjsVersions(appDir),
59
59
  Promise.resolve(checkFrameworkResolves(appDir)),
60
+ Promise.resolve(checkFrameworkLinks(appDir)),
60
61
  checkImportmapCoherence(appDir, opts),
61
62
  Promise.resolve(checkGitHook(appDir)),
62
63
  checkElisionCarriers(elision),
package/lib/doctor.js CHANGED
@@ -53,5 +53,5 @@
53
53
  export { DOCTOR_SEVERITIES, DOCTOR_CODES, codeForName } from './doctor/codes.js';
54
54
  export { readDoctorPolicy, applyDoctorPolicy } from './doctor/policy.js';
55
55
  export { readAppBasePath } from './doctor/route-modules.js';
56
- export { frameworkResolves, checkFrameworkResolves } from './doctor/probes/framework-resolves.js';
56
+ export { frameworkResolves, checkFrameworkResolves, inspectFrameworkLink, checkFrameworkLinks } from './doctor/probes/framework-resolves.js';
57
57
  export { runDoctorChecks } from './doctor/runner.js';
package/lib/run-tasks.js CHANGED
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  * @param {string} cwd
13
13
  * @param {NodeJS.ProcessEnv} [env]
14
14
  */
15
- function envWithLocalBin(cwd, env = process.env) {
15
+ export function envWithLocalBin(cwd, env = process.env) {
16
16
  const bins = [];
17
17
  let dir = cwd;
18
18
  // Walk up to the filesystem root, collecting each node_modules/.bin.
@@ -44,7 +44,10 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
44
44
  if (opts.onStep) opts.onStep(step);
45
45
  const code = await new Promise((res) => {
46
46
  const c = spawn(step, { shell: true, stdio: 'inherit', cwd, env });
47
- c.on('exit', (code) => res(code ?? 0));
47
+ // A child killed by a signal exits with `code` null and `signal` set.
48
+ // That is a failure (an OOM-killed `db migrate` must not boot the
49
+ // server over a half-applied schema), so it maps to 1, never 0.
50
+ c.on('exit', (code, signal) => res(code ?? (signal ? 1 : 0)));
48
51
  c.on('error', () => res(1));
49
52
  });
50
53
  if (code !== 0) return { ok: false, step, code };
@@ -52,6 +55,23 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
52
55
  return { ok: true };
53
56
  }
54
57
 
58
+ /**
59
+ * Quote one argv entry for a POSIX shell so it survives `shell: true` as ONE
60
+ * word with no expansion. A plain word passes through untouched; anything
61
+ * else is single-quoted, with an embedded single quote spliced as `'\''`.
62
+ * Used by `webjs db <verb> [args]` (#1468) to append the CLI's args to a
63
+ * mapped command string, so `--name "add users"` reaches the ORM as one arg
64
+ * and a `$` / `;` / glob is never expanded, matching what the drizzle-kit
65
+ * default (a real argv, no shell) already guarantees.
66
+ *
67
+ * @param {string} arg
68
+ * @returns {string}
69
+ */
70
+ export function shellQuote(arg) {
71
+ if (/^[A-Za-z0-9_\-.\/=:@,+%]+$/.test(arg)) return arg;
72
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
73
+ }
74
+
55
75
  /**
56
76
  * Spawn the configured dev `parallel` tasks (#550) as long-lived children and
57
77
  * return a killer that tears them ALL down (idempotent), so a watcher cannot
@@ -90,7 +110,7 @@ export function startParallelTasks(commands, cwd, opts = {}) {
90
110
  *
91
111
  * @param {import('node:child_process').ChildProcess} child
92
112
  */
93
- function killChildTree(child) {
113
+ export function killChildTree(child) {
94
114
  try {
95
115
  if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGTERM');
96
116
  else child.kill();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.56",
3
+ "version": "0.10.58",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.0",
22
- "@webjsdev/ui": "^0.3.1"
21
+ "@webjsdev/server": "^0.8.67",
22
+ "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"
@@ -50,18 +50,25 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
50
50
  2. Browser tests in `test/<feature>/browser/*.test.js` for hydration, DOM, slots,
51
51
  and the client router.
52
52
  3. Documentation stays in sync on the SAME PR as the code, never a follow-up.
53
- 4. `npm run check` must pass (correctness), and so must `npm run doctor`
54
- (project health). CI runs both. Doctor fails on whatever your `package.json`
55
- `webjs.doctor.gate` marks `error`, which starts as the un-versioned
56
- stylesheet link check, plus the two hard toolchain checks that default to
57
- `error` with no gate entry at all: `NODE_VERSION` (the Node floor) and
58
- `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an existing
59
- tsconfig), either of which would 500 the app at runtime. Everything else it
60
- reports is a warning that cannot fail the build. Widen or narrow the gate in
61
- `package.json` rather than in the workflow.
62
- 5. Pre-merge self-review: before saying a PR is ready, run fresh-context review
63
- rounds until one round finds zero issues (minimum two rounds, rotate focus).
64
- Skip only for a one-line trivial change.
53
+ 4. `npm run ci` must pass before you push. It runs the step list declared in
54
+ `package.json` under `webjs.ci`, one result line per step: `webjs check`
55
+ (correctness), `webjs doctor` (project health), `webjs typecheck`, a
56
+ dependency audit, then the server, browser, and e2e test layers. The GitHub
57
+ workflow runs the same list on every PR and push, so the two cannot drift;
58
+ `npm run ci -- --only Tests` runs one layer while you iterate, and
59
+ `npm run ci -- --signoff` posts a green commit status (basecamp/gh-signoff)
60
+ a branch-protection rule can require. Doctor fails on whatever your
61
+ `package.json` `webjs.doctor.gate` marks `error`, which starts as the
62
+ un-versioned stylesheet link check, plus the two hard toolchain checks that
63
+ default to `error` with no gate entry at all: `NODE_VERSION` (the Node
64
+ floor) and `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an
65
+ existing tsconfig), either of which would 500 the app at runtime. Everything
66
+ else it reports is a warning that cannot fail the build. Widen or narrow the
67
+ gate, and the step list, in `package.json` rather than in the workflow.
68
+
69
+ How a PR gets REVIEWED is deliberately not specified here. Use whatever your
70
+ team already does. WebJs has opinions about the code (the conventions above,
71
+ `webjs check`, the test layers) and none about your review process.
65
72
 
66
73
  ## Git rules
67
74
 
@@ -80,6 +80,7 @@ The table above routes by the job; this one routes by the topic, for when you al
80
80
  | Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
81
81
  | Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
82
82
  | Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | `references/styling.md` |
83
+ | Where a repeated markup helper lives (`utils/ui/` vs `lib/`), and fragment vs display-only component | `references/styling.md` |
83
84
  | Client router, prefetch, frames, view transitions, Suspense streaming | `references/client-router-and-streaming.md` |
84
85
  | Optimistic UI for a user-facing mutation | `references/optimistic-ui.md` |
85
86
  | The `@webjsdev/ui` component kit (a `components.json` is present): class helpers, tokens, `add` / `view`, the MCP `ui` tool | `references/ui-kit.md` |
@@ -122,8 +123,10 @@ app/ ROUTING ONLY (thin adapters importing from modules/)
122
123
  error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
123
124
  middleware.ts root middleware
124
125
  modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
125
- components/, utils/ (pure), types.ts
126
- lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers
126
+ components/ (custom elements), types.ts,
127
+ utils/ (pure; returns data, or an html fragment under utils/ui/)
128
+ lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers,
129
+ lib/utils/ui.ts app-wide html fragments (lib/ui/ once they grow)
127
130
  components/*.ts shared presentational custom elements (one per file)
128
131
  db/*.server.ts Drizzle: schema, connection
129
132
  public/* static assets, served at /public/<name>
@@ -261,6 +264,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
261
264
 
262
265
  ## Testing Defaults
263
266
 
267
+ - `npm run ci` before every push: it runs the `webjs.ci` step list in `package.json` (correctness, project health, types, a dependency audit, then the server, browser, and e2e test layers) with a result line per step, and CI runs the same list, so a green local run predicts the pipeline. `npm run ci -- --only Tests` runs one layer while iterating. See `references/testing.md` and `references/built-ins.md`.
264
268
  - Prefer server/handler tests first: drive the app with `handle()` from `@webjsdev/server/testing` and assert on the `Response`.
265
269
  - Add a browser test (`npm run test:browser`) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change.
266
270
  - Render the app and LOOK for any UI change: `npm run check` and `npm run typecheck` pass even when a layout collapses. Static tools give no signal for a visual defect.
@@ -93,6 +93,8 @@ html`<link rel="stylesheet" href=${asset('/public/app.css')}>`
93
93
 
94
94
  That emits `/public/app.css?v=<hash>` in production and gets the immutable year; the same url un-marked gets a ~1h cap and can serve stale bytes from a CDN after a deploy until something purges it. `asset()` resolves on the server; the browser has no resolver and returns the path unchanged. Call it from a PAGE, LAYOUT, or metadata route, which render only on the server. Inside a component that ships to the browser it silently costs you the caching: hydration is a full client re-render, so the bare path overwrites the hashed one and the asset downloads twice. The url stays valid either way, so this is a convention rather than a `webjs check` rule (`webjs doctor` does flag the plain form, see below). Under `webjs.basePath`, include the prefix yourself (`asset('/app/public/x.css')`): the framework base-path-prefixes only the urls it emits, so an author-written url is already yours to prefix. Two more constraints: call it INSIDE the render function, because a module-scope call is a side effect the elision analyser reads as client work and it ships the whole module; and mark only files that change with a DEPLOY, because the hash is memoized for the process lifetime, so a `public/` file rewritten in place at runtime would keep its old url while being served `immutable` for a year. Off in dev, so dev output is byte-identical. Only `public/` paths resolve; anything else (and a path that fails to resolve) is returned untouched.
95
95
 
96
+ `asset()` is a PROVIDER SEAM: `@webjsdev/server` installs the resolver at boot by importing `@webjsdev/core` and calling a setter, and that only reaches your app when both sides load the SAME copy of core. Two copies on disk are two independent sets of module-scope state, so the setter lands on one and your `asset()` reads the other, which returns bare paths and never says why. `cspNonce()` and the bound-form identity resolver behind `<form action=${fn}>` sit on the same seam and go inert together. `@webjsdev/server` therefore declares core as a PEER dependency, so npm resolves it against your app's copy and reports a genuine conflict at install time rather than nesting a second one. If you ever do end up with two (a hand-rolled install, a vendored copy), the symptom is those three features quietly doing nothing rather than any error, so reach for `npm ls @webjsdev/core` and `npm dedupe` before looking anywhere else.
97
+
96
98
  Forgetting it is the one real cost of opt-in, so `webjs doctor` catches it: a page, layout, or error boundary writing a plain `<link rel="stylesheet" href="/public/app.css">` gets a WARN naming the `file:line` and the fix (#1095). It reads your source and rewrites nothing, and it stays quiet about the non-marks that are deliberate: a cross-origin sheet, a `rel="icon"`, a `rel="preload"`, and any `href=${expr}` hole. Same posture as Rails (a `stylesheet_link_tag` helper over a digest manifest) and Remix (a hashed url from the build graph, surfaced through `links()`): take the fingerprint at the point the url is PRODUCED, never by rewriting a rendered document. A warning is easy to miss, so make it fatal in the app that cares: gate `UNMARKED_ASSET_LINKS` to `error` (see the doctor severity gate below) and one `npm run doctor` step in CI stops the un-versioned url reaching a deploy. The scaffold ships exactly that.
97
99
 
98
100
  It is opt-in rather than automatic because only the author knows which urls are the REQUEST. Do NOT mark a `rel="preload"` hint whose asset is actually fetched by CSS `url()`: the preload cache is keyed on the full url, so a versioned hint can never satisfy the unversioned request the stylesheet makes, and the file is fetched twice. Mark the thing that fetches, not the hint. Every cacheable response also carries a weak `ETag`, and a repeat request with a matching `If-None-Match` gets a `304 Not Modified` with no body. Unstorable (`no-store`) and streamed responses are excluded from the ETag path. A `private` response IS validated: `private` forbids SHARED storage, not validation, and the ETag hashes that response's own body, so two users with different bodies get different ETags and neither can match the other's, while two users with identical bodies are asking about identical bytes, where a 304 discloses nothing (#1140). That is what keeps the client router's partial responses cheap on a page that opted into caching; a default `no-store` page has nothing to validate either way. Dev is byte-faithful (no hashing).
@@ -214,9 +216,51 @@ An over-limit body responds `413` without buffering the whole payload.
214
216
 
215
217
  `before` runs to completion first (a non-zero exit aborts the boot). `parallel` (dev only) runs long-lived watchers alongside the server and tears them down on exit. `watch` (dev only) adds extra live-reload directories outside the app tree.
216
218
 
219
+ ### Local CI (`webjs.ci`)
220
+
221
+ `webjs ci` runs the step list the block declares, the Rails 8.1 `bin/ci` posture: your machine is the first CI runner, and a cloud pipeline runs the SAME list by calling `npm run ci`, so the two cannot drift. Each step prints a heading, then `✅ <title> passed in 2.11s` or `❌ <title> failed in 0.01s`; the run ends with every failure listed and one total line, and exits 1 on any failure.
222
+
223
+ ```jsonc
224
+ { "webjs": { "ci": { "steps": [
225
+ { "title": "Setup", "run": "webjs db migrate" },
226
+ { "title": "Checks", "parallel": 2, "steps": [ // two at a time
227
+ "webjs check", // a string is a command titled by itself
228
+ { "title": "Types", "run": "webjs typecheck" },
229
+ { "title": "Tests", "steps": [ // a nested group takes ONE slot, runs in order
230
+ { "title": "Tests: server", "run": "webjs test --server" },
231
+ { "title": "Tests: e2e", "run": "webjs test --server", "env": { "WEBJS_E2E": "1" } }
232
+ ] }
233
+ ] }
234
+ ] } } }
235
+ ```
236
+
237
+ A step is a string, a `{ title, run, env? }` command, or a `{ title, steps, parallel? }` group. `parallel` is a slot count (default 1); a parallel group captures each step's output and replays it whole when the step finishes, so two steps never interleave, and a group nested inside it takes one slot and runs sequentially (it cannot declare `parallel`, which the reader reports rather than honours). `env` is per-step, so the e2e opt-in does not depend on a shell prefix. Every child runs with `CI=true`, `node_modules/.bin` on PATH, and `.env` loaded first (a real env var wins), so a `webjs db migrate` step sees `DATABASE_URL`.
238
+
239
+ Flags: `-f` / `--fail-fast` stops after the first failure (the default runs everything and lists every failure), `--only <title>` runs one step or group by title (repeatable, an unknown title is an error rather than an empty green run), `--json` emits one document on stdout (`{ ok, seconds, steps: [{ title, run, group, ok, code, seconds, output? }] }`, failed steps carrying their captured output, the human report on stderr) for an agent loop, and `--signoff` runs `gh signoff` after a green run. To hold a merge until a LOCAL run is green, install `basecamp/gh-signoff`, run `gh signoff install` once (a branch-protection rule requiring the `signoff` status), and run `npm run ci -- --signoff`; a red run posts nothing.
240
+
241
+ Under GitHub Actions each step is a `::group::` in the log, a failed step is an `::error::` annotation, and a step table is appended to the job summary, so a single job running the whole list still names the layer that broke. The scaffold's workflow is exactly that one job; `webjs create --skip-ci` omits it and the local list always ships. A malformed block (a group with no title, a nested `parallel`, an unknown key) refuses to run and names every problem by JSON path, because a silently dropped step is a check that never ran. Nothing declared is exit 1 too, naming any workspace member that declares one, since "ran zero steps" would read as green.
242
+
243
+ ### Bring your own ORM (`webjs.db`)
244
+
245
+ Drizzle is the scaffold DEFAULT, not lock-in. The runtime never imports it, `db/connection.server.ts` is the app's own file, and `webjs db` is adapter-driven: a `db` block maps each verb to the shell command `webjs db <verb>` runs instead of the drizzle-kit default (node_modules/.bin on PATH like a `before` step, extra CLI args appended).
246
+
247
+ ```jsonc
248
+ { "webjs": {
249
+ "db": {
250
+ "generate": "prisma migrate dev --create-only",
251
+ "migrate": "prisma migrate deploy",
252
+ "push": "prisma db push",
253
+ "studio": "prisma studio",
254
+ "reset": "prisma migrate reset --force"
255
+ }
256
+ } }
257
+ ```
258
+
259
+ Any key is a verb (`reset` above adds `webjs db reset`). A verb the block does not name keeps its default (drizzle-kit for `generate` / `migrate` / `push` / `studio`, `db/seed.server.ts` for `seed`), so an app with no block is unchanged and the scaffold emits none. The payoff is that `webjs db migrate` stays one spelling across ORMs, so the scaffolded `dev.before` / `start.before`, the Dockerfile, CI, and the deploy docs all keep working after a swap. Write the bare binary (`prisma migrate deploy`), not `npx prisma ...`, since a pure Bun image has no `npx`. The swap itself is the app's own files: replace `db/connection.server.ts` with the new client, drop `drizzle.config.ts` / `db/columns.server.ts`, and keep server-only imports behind `.server.ts` as before.
260
+
217
261
  ### Doctor severity gate
218
262
 
219
- `webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` depends on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
263
+ `webjs doctor` reports project health, and by default only a broken toolchain fails the exit. `--strict` makes EVERY warning fatal, which is unusable in CI, because four checks are environment-shaped: `GIT_HOOK` wants a local pre-commit hook a runner has no reason to have, `ENV_DRIFT` compares against a `.env` CI does not carry, `VENDOR_PIN` fetches the network, and `FRAMEWORK_RESOLVE` plus `FRAMEWORK_LINKS` depend on the environment. So per-check severity is CONFIG, keyed by the stable code every result carries.
220
264
 
221
265
  ```jsonc
222
266
  { "webjs": {
@@ -44,17 +44,35 @@ enableClientRouter(); // turn soft navigation back on
44
44
 
45
45
  Per link, opt out with `data-no-router` (auth flows like `/logout`, OAuth redirects, print views, an experimental route with a different runtime). Cross-origin hrefs, `download`, a non-`_self` target, pure same-page hash jumps, and non-HTML extensions are auto-skipped.
46
46
 
47
+ **Per link, keep the reader's scroll offset with `data-preserve-scroll`.** A forward navigation scrolls to top, matching what a browser does and what Next and Remix 3 do. The attribute is the escape hatch for a navigation that changes only part of what the reader is looking at: a filter, sort, or tab link whose control sits below the fold, a pager, or a form that re-renders in place with validation errors. WebJs wants it more than most, because a searchParams-only navigation already morphs the deepest shared boundary and preserves hydrated component state, so the scroll is the only thing such a navigation still throws away.
48
+
49
+ ```html
50
+ <nav data-preserve-scroll> <!-- covers every link inside -->
51
+ <a href="?sort=new">Newest</a>
52
+ <a href="?sort=top">Top</a>
53
+ <a href="/" data-preserve-scroll="false">Home</a> <!-- opts back out -->
54
+ </nav>
55
+ <form method="post" action=${saveDraft} data-preserve-scroll>...</form>
56
+ ```
57
+
58
+ It resolves through `closest()`, so one mark on a wrapping element covers every link in it (the same walk `data-webjs-frame` uses), and `data-preserve-scroll="false"` on a nearer element opts back out. On a form the lookup starts at the submitter and falls back to the form itself, so a marked form covers its own buttons whether they sit inside it or are attached from elsewhere with `form="id"`. The fallback only fills in a missing mark, so `data-preserve-scroll="false"` on a button still opts that button out of a marked form.
59
+
60
+ Three things it does NOT do. A hash link still scrolls to its anchor, because the reader named a target and a named target beats a blanket preference. It is inert on a frame-targeted link, since a frame swap never writes a scroll to begin with. And it is inert with JS off, where the link is a plain `<a>` and the browser does whatever it does, so nothing about a page's correctness may depend on it.
61
+
62
+ It carries the reader's CURRENT offset onto the destination; it does not restore the destination's remembered offset. Those are different features, and the second one is not something WebJs ships. So this is the wrong tool for a "back to the list" link, where the offset the reader wants is the one they had in the list, not the one they have in the article.
63
+
47
64
  **Programmatic navigation and cache eviction.**
48
65
 
49
66
  ```js
50
67
  import { navigate, revalidate } from '@webjsdev/core';
51
68
  await navigate('/about'); // push history
52
69
  await navigate('/login', { replace: true }); // replace history
70
+ await navigate('/products?sort=new', { scroll: false }); // keep the reader's offset
53
71
  revalidate('/products/123'); // evict one URL from the snapshot cache
54
72
  revalidate(); // clear the entire snapshot cache
55
73
  ```
56
74
 
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.
75
+ 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. The popstate an in-page fragment CLICK produces is absorbed rather than re-navigated (#1437), so an anchor click restores nothing and re-fetches nothing, the repeat click of one anchor included (that one REPLACES its entry rather than pushing, so it arrives with the url unchanged). The gate is PROVENANCE, the router marking the click it bowed out of and the next popstate consuming that mark, rather than any comparison of urls: a Back between two entries differing only by fragment can still need a re-render, because `getSubmitAction` prefers the raw `action` ATTRIBUTE and that carries no fragment, so a bound-submitter form declaring `action="/p"` pushes its 422 re-render at `/p` while the reader sits at `/p#sec`. A popstate with no click behind it therefore stays on the normal path, which means an ordinary Back or Forward between two fragment states still re-renders. Telling those apart would need to know whether the DOM was replaced between the two ENTRIES, which is per-entry state the router does not keep.
58
76
 
59
77
  **In-place refresh of the page you are on.** `refreshPage(mode)` re-renders the CURRENT url on the server and applies it without a page load.
60
78
 
@@ -70,12 +88,15 @@ It sends no `X-Webjs-Have`, deliberately: the server short-circuits at the first
70
88
 
71
89
  It does NOT reload changed component modules and cannot: `customElements.define` is once-per-tag and a module url is fetched once per document. A caller whose change touched browser code has to reload. This is exactly why the dev live-reload client calls `refreshPage` for a page or layout edit and `location.reload()` for a component edit (#1398, and see `references/runtime.md` for which dev modes get the refresh).
72
90
 
91
+ Keep the two scroll concerns apart. The Back/Forward restore below is the BROWSER's and has no per-link knob, because the offset it replays is one the browser recorded. The forward-navigation scroll-to-top is the router's own write, and `data-preserve-scroll` is its knob.
92
+
73
93
  **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:
74
94
 
75
- - **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.
95
+ - **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 restore, which is the BROWSER's (see the next bullet) and which the router protects with a suppression window while the page settles. If Back lands in the wrong place, that is a framework bug to report, not something to patch in app code.
96
+ - **The BROWSER restores Back/Forward scroll, not the router, and an app must not set `history.scrollRestoration = 'manual'`** (#1428). The router FORCES `history.scrollRestoration` to `auto` on start (and puts the app's own value back on `disableClientRouter()`), so setting `'manual'` yourself does not take effect while the router runs. Under `auto` the browser records a scroll position per history entry, replays it on a traverse, and composes the iOS edge back-swipe GESTURE PREVIEW from that same recorded state. The router writes no scroll on a restore at all: it reserves the recorded height (below) so the browser's replay lands on a document that can hold the offset, and that is the whole mechanism. One writer, the same model Next and Remix 3 use. Taking `manual` suppresses the recording, so every scrolled page previews BLANK for the whole gesture. That is what the router itself used to do, inherited from Turbo Drive's `assumeControlOfScrollRestoration`, and it is why Turbo still previews blank the same way: Turbo is single-writer too, but the writer is the APP. An app that sets `manual` re-breaks the preview app-wide.
76
97
  - **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.
77
98
  - **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.
78
- - **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.
99
+ - **The recorded HEIGHT is reserved across the restore, so the offset is always reachable.** A snapshot records the page's settled `scrollHeight` alongside the offset, and the restore holds that height on the root element until the page has filled in. Without it the swapped-in markup is briefly shorter than the page it came from, the browser clamps the restore to whatever the short document allowed, and the reader lands short. The reservation removes that window rather than correcting for it afterwards, which is what retired the older catch-up that used to chase the offset as the page grew. It is released on the same settle that closes the anchoring window, on the same ceiling, and when another navigation supersedes the restore, but never on user input: releasing the height under a reader mid-scroll is the one harm an early release could do. An app's own inline `min-height` on the root is saved and put back, the same contract the anchoring window keeps.
79
100
  - **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.
80
101
 
81
102
  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.
@@ -138,6 +159,14 @@ html`<webjs-frame id="activity">…contents…</webjs-frame>`
138
159
 
139
160
  On click the router walks `closest('webjs-frame')` from the target. If a frame is found and the response carries a matching `<webjs-frame id>`, the swap is scoped to that frame's children, and the server returns ONLY that subtree. A link that drives a frame participates in link prefetch like any other, in that frame's own dimension (#1407), so a hovered or viewport-warmed frame link swaps on click with no round trip. A `<webjs-frame src>` SELF-load is the exception: it neither reads nor keeps that cache, since asking a frame to load its own src is a freshness request rather than a hover being followed. See the prefetch section above for the frame dimension's rules.
140
161
 
162
+ **A frame swap never moves the window scroll.** A page navigation scrolls to top, the way a browser does; a frame swap changes one region and leaves the rest of the document standing, the reader's scroll offset included. That holds for a nested link, an external `data-webjs-frame` trigger, a frame-targeted form submission, and a `src` self-load alike, and it holds for a `#hash` on a frame link too, which rides the URL without moving the viewport. It does NOT cover a pure fragment link whose path and query match the page it sits on, because the router never sees one: the click handler bows out before `preventDefault`, so the browser does its own native fragment jump and the window moves.
163
+
164
+ **Every spelling of a fragment link is the browser's, the bare `#` included** (#1437). `href="#"` is the back-to-top idiom and it serializes with an EMPTY fragment, which reads identically to no fragment at all through `URL.hash`, so the bow-out tests the `href` for a `#` instead. A `<a href="#">Back to top</a>` therefore scrolls to top natively, inside a frame as well as outside one. `href=""` is NOT a fragment link: it resolves to the current url with the fragment REMOVED, which the spec reloads rather than jumps, so the router navigates it like any other link.
165
+
166
+ The escapes are page navigations and DO scroll: `data-webjs-frame="_top"`, and an id `resolveTargetFrameId` cannot match to a live frame, which warns and degrades to a normal nav. Do not read that second one as covering a RESPONSE that lacks the requested frame (the `webjs:frame-missing` warning). There the frame resolved and the nav stayed frame-scoped, so the offset holds and only the panel is left unchanged. Turbo's `autoscroll` opt-in, which scrolls the frame itself into view on swap, has no WebJs equivalent; the router simply never writes scroll for a frame.
167
+
168
+ **Read "never moves" as "WebJs never writes one", not as a guarantee the viewport cannot move.** A swap that makes the panel SHORTER shortens the document with it, and a reader parked near the bottom is then holding an offset the document can no longer reach, so the browser clamps it. Measured on the gallery's frames demo: filtering from All to Done at the bottom of the page moves the window from 474 to 405, exactly the 69px the document lost. The router wrote no scroll there (verified with every scrolling API instrumented), and any DOM change that shortens a page does the same thing. Keeping the frame a stable height across its states avoids it entirely.
169
+
141
170
  **External targeting.** A trigger does not have to be nested inside the frame. An `<a>` or `<form>` carrying `data-webjs-frame="<id>"` drives that frame from anywhere (an explicit `data-webjs-frame` wins over the enclosing-frame default). `data-webjs-frame="_top"` is a reserved token forcing a full-page navigation that breaks out of the frame.
142
171
 
143
172
  **Self-loading.** Give a frame a `src` and it self-fetches (through the same swap path).
@@ -382,6 +382,14 @@ Import from `@webjsdev/core/directives`. Everything a `class`/`style`/conditiona
382
382
  | `asyncAppend(iter)` / `asyncReplace(iter)` | Stream from an async iterable, appending each value or replacing with the latest. |
383
383
  | `templateContent(el)` | Render the content of a `<template>` element. |
384
384
 
385
+ Every directive here is CLIENT behaviour. At SSR the server renders one shot, so
386
+ `guard` always invokes its function, `watch` reads its signal once and inlines
387
+ the value, and `live` is fully transparent, resolving to the value it wraps in
388
+ every hole position (a child, a plain attribute, a `?bool`, a `.prop`). So
389
+ `?open=${live(false)}` omits its attribute exactly as `?open=${false}` does. It
390
+ was previously resolved only in a child hole, which served `open=""` and let
391
+ hydration close the element a moment later (#1443).
392
+
385
393
  ## Display-only elision
386
394
 
387
395
  A component that does no client-side work renders the same SSR'd HTML with or without its JS, so WebJs strips its import from the served source (and any vendor reachable only through it). This is automatic and conservative. A component stays elidable while it has NONE of:
@@ -390,7 +398,7 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
390
398
  - a factory-declared reactive property that is not `{ state: true }`
391
399
  - an overridden lifecycle hook (including `renderFallback` / `renderError`)
392
400
  - an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
393
- - code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed
401
+ - code that runs at module load (a top-level call, non-data `new`, dynamic `import(...)`, top-level `await`); only declarations and `X.register(...)` are allowed. TypeScript types are erased before the analyser reads a module, so an annotation can never be a blocker however call-shaped it looks (`readonly (readonly [number, number, number])[]` is fine)
394
402
  - 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)
395
403
  - being rendered by a component that itself ships
396
404