@webjsdev/cli 0.10.65 → 0.10.67

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/bin/webjs.js CHANGED
@@ -456,6 +456,12 @@ async function refuseOutsideApp(command) {
456
456
  }
457
457
 
458
458
  async function main() {
459
+ // A `bun --hot` re-run of the dev server child (#1575): the first run's
460
+ // server owns the process, so hand it the reload and stop here.
461
+ if (cmd === 'dev' && process.env.__WEBJS_DEV_CHILD === '1' && process.versions.bun) {
462
+ const { rerunHotDevServer } = await import('../lib/dev-hot-rerun.js');
463
+ if (await rerunHotDevServer()) return;
464
+ }
459
465
  // `--version` / `-v` (top level): print the installed CLI version and exit.
460
466
  if (cmd === '--version' || cmd === '-v') {
461
467
  console.log(readCliVersion());
@@ -526,7 +532,11 @@ async function main() {
526
532
  // parent is gone (killed outright, or crashed), and a child left
527
533
  // running would hold the port against the next `webjs dev`. Unref'd
528
534
  // so the channel itself never keeps this process alive.
529
- if (process.connected) {
535
+ // Once per process: `bun --hot` re-runs this file on every edit
536
+ // (#1575), and each run used to add another listener.
537
+ const g = /** @type {any} */ (globalThis);
538
+ if (process.connected && !g.__webjsDevDisconnectHooked) {
539
+ g.__webjsDevDisconnectHooked = true;
530
540
  process.channel?.unref?.();
531
541
  process.on('disconnect', () => process.exit(0));
532
542
  }
@@ -1577,7 +1587,16 @@ async function main() {
1577
1587
  let tscPath;
1578
1588
  try {
1579
1589
  const req = createRequire(join(cwd, 'package.json'));
1580
- tscPath = req.resolve('typescript/bin/tsc');
1590
+ // The `tsc` bin the package itself declares. TypeScript 7 (the native
1591
+ // compiler) restricts its subpaths with `exports`, so
1592
+ // `typescript/bin/tsc` no longer resolves; its package.json does, in
1593
+ // every major, and names the bin. Its launcher spawns the native
1594
+ // binary: about a quarter of the 6.x compiler's memory, five times
1595
+ // faster.
1596
+ const pkgPath = req.resolve('typescript/package.json');
1597
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1598
+ const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.tsc;
1599
+ tscPath = bin ? join(dirname(pkgPath), bin) : req.resolve('typescript/bin/tsc');
1581
1600
  } catch {
1582
1601
  console.error(
1583
1602
  'webjs typecheck: TypeScript is not installed in this project.\n' +
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The fast path for a `bun --hot` re-run of the `webjs dev` server child
3
+ * (#1575).
4
+ *
5
+ * `bun --hot` re-evaluates this CLI on every reload. The first run's server
6
+ * owns the process (`dev/hot-host.js` in `@webjsdev/server`) and only needs to
7
+ * hear that the module registry was reset, so a re-run calls the host directly
8
+ * instead of importing the whole server again just to reach `startServer`,
9
+ * which re-evaluated every framework module on every edit for nothing.
10
+ *
11
+ * The one thing a re-run must still notice is the framework itself changing
12
+ * under it (an upgrade while `webjs dev` runs): the first run's code cannot
13
+ * load the new copy in place, so the child exits and the supervisor starts a
14
+ * fresh process.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const HOSTS = Symbol.for('webjs.dev.hotHosts');
21
+
22
+ /** The installed `@webjsdev/server` version, or '' when it cannot be read. */
23
+ function installedServerVersion() {
24
+ try {
25
+ const entry = fileURLToPath(import.meta.resolve('@webjsdev/server'));
26
+ return JSON.parse(readFileSync(join(dirname(entry), 'package.json'), 'utf8')).version || '';
27
+ } catch {
28
+ return '';
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Hand a re-run to the live dev server. Returns false when there is none (the
34
+ * first run), so the caller starts the server as usual.
35
+ *
36
+ * @param {{ exit?: (code: number) => void, log?: (line: string) => void }} [io]
37
+ * @returns {Promise<boolean>}
38
+ */
39
+ export async function rerunHotDevServer(io = {}) {
40
+ const exit = io.exit || ((code) => process.exit(code));
41
+ const log = io.log || ((line) => console.log(line));
42
+ const hosts = /** @type {any} */ (globalThis)[HOSTS];
43
+ if (!(hosts instanceof Map) || hosts.size === 0) return false;
44
+ const version = installedServerVersion();
45
+ for (const host of hosts.values()) {
46
+ if (version && host.version && host.version !== version) {
47
+ log(`[webjs] @webjsdev/server changed (${host.version} -> ${version}), restarting the dev server`);
48
+ exit(0);
49
+ return true;
50
+ }
51
+ }
52
+ for (const host of hosts.values()) await host.rerun();
53
+ return true;
54
+ }
package/lib/dev-reload.js CHANGED
@@ -32,6 +32,13 @@ import { PORT_IN_USE_EXIT_CODE } from './port.js';
32
32
  */
33
33
  export const RESTART_DEBOUNCE_MS = 50;
34
34
 
35
+ /**
36
+ * The IPC message a dev server child sends when it reloads every module in
37
+ * place under `bun --hot`, plugin-served ones included (#1575). Mirrors
38
+ * `HOT_IN_PLACE_MESSAGE` in `@webjsdev/server`'s `dev/hot-host.js`.
39
+ */
40
+ export const HOT_IN_PLACE_MESSAGE = 'hot-reload-in-place';
41
+
35
42
  /**
36
43
  * Delays before restarting a child that exited on its own (a crash), indexed
37
44
  * by consecutive crash count and capped at the last entry. A file change
@@ -338,12 +345,35 @@ export function createSupervisor({
338
345
  };
339
346
  }
340
347
 
348
+ /**
349
+ * Which changed paths restart the live child. A Bun child that reloads
350
+ * plugin-served modules in place announces it over IPC once it is up (#1575);
351
+ * from then on only `plan.inPlaceRestartFor` (the boot hooks) restarts it.
352
+ * Per child: `reset()` on every spawn, so a child that never announces (an
353
+ * older `@webjsdev/server`) keeps the restarts it needs.
354
+ *
355
+ * @param {{ restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean }} plan
356
+ */
357
+ export function inPlaceRestarts(plan) {
358
+ let inPlace = false;
359
+ const base = plan.restartFor || (() => true);
360
+ return {
361
+ /** @param {string} p */
362
+ restartFor: (p) => (inPlace && plan.inPlaceRestartFor ? plan.inPlaceRestartFor(p) : base(p)),
363
+ /** @param {unknown} msg */
364
+ onMessage: (msg) => {
365
+ if (msg && typeof msg === 'object' && /** @type {any} */ (msg).webjs === HOT_IN_PLACE_MESSAGE) inPlace = true;
366
+ },
367
+ reset: () => { inPlace = false; },
368
+ };
369
+ }
370
+
341
371
  /**
342
372
  * Run the dev server under the supervisor until a signal stops it.
343
373
  *
344
374
  * @param {{
345
375
  * cwd: string,
346
- * plan: { args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] },
376
+ * plan: { args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] },
347
377
  * env: NodeJS.ProcessEnv,
348
378
  * onExit: (code: number) => void,
349
379
  * }} opts
@@ -366,14 +396,16 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
366
396
  sup.stop().then(() => onExit(code));
367
397
  };
368
398
 
399
+ const restarts = inPlaceRestarts(plan);
369
400
  const sup = createSupervisor({
370
401
  restartOnChange: plan.restartOnChange,
371
- ...(plan.restartFor ? { restartFor: plan.restartFor } : {}),
402
+ ...(plan.restartFor ? { restartFor: restarts.restartFor } : {}),
372
403
  log,
373
404
  // The child already printed why (the port and its holder); stop with its
374
405
  // code rather than restarting a server that can never bind.
375
406
  onFinal: (code) => shutdown(code),
376
407
  spawnChild: () => {
408
+ restarts.reset();
377
409
  const c = spawn(process.execPath, plan.args, {
378
410
  // The IPC channel lets the child notice this process is gone and exit,
379
411
  // so a killed supervisor never leaves an orphan holding the port.
@@ -381,6 +413,7 @@ export function superviseDevServer({ cwd, plan, env, onExit }) {
381
413
  cwd,
382
414
  env,
383
415
  });
416
+ c.on('message', restarts.onMessage);
384
417
  // A failed spawn emits 'error' and may never emit 'exit'; report it as
385
418
  // an exit so the backoff retries it (a repeated exit is ignored).
386
419
  c.on('error', (err) => {
@@ -46,7 +46,29 @@ export const WATCH_DIRS = ['app', 'components', 'modules', 'lib', 'actions'];
46
46
  * never restarts the dev server when edited, which is the quiet half of the
47
47
  * bug where a `middleware.ts` was loaded by neither.
48
48
  */
49
- export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
49
+ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs', ...bootFiles()];
50
+
51
+ /**
52
+ * The app-root boot hooks the server runs ONCE per process (#1575):
53
+ * `instrumentation.*` (its `register()`) and `env.*` (the env validation). An
54
+ * edit to one restarts the dev server on either runtime, because no in-place
55
+ * reload re-runs them.
56
+ * @returns {string[]}
57
+ */
58
+ function bootFiles() {
59
+ return ['instrumentation', 'env'].flatMap((n) => ['ts', 'js', 'mts', 'mjs'].map((x) => `${n}.${x}`));
60
+ }
61
+
62
+ const BOOT_FILE = /^(?:instrumentation|env)\.m?[jt]s$/;
63
+
64
+ /**
65
+ * Whether a changed (app-relative) path is a boot hook (see `bootFiles`).
66
+ * @param {string} path
67
+ * @returns {boolean}
68
+ */
69
+ export function isBootFile(path) {
70
+ return BOOT_FILE.test(path);
71
+ }
50
72
 
51
73
  /**
52
74
  * Plan how `webjs dev` runs its server.
@@ -56,7 +78,7 @@ export const WATCH_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts',
56
78
  * @param {string[]} opts.argv `process.argv.slice(1)` (the script path followed by its args), forwarded to the child verbatim.
57
79
  * @param {boolean} opts.noHot Whether `--no-hot` was passed (opt out of the supervisor entirely).
58
80
  * @param {boolean} [opts.sourceLocations] Whether dev source locations are on (`WEBJS_SOURCE_LOCATIONS` / `webjs.dev.sourceLocations`), which puts every app module behind a Bun plugin.
59
- * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
81
+ * @returns {{ mode: 'inline' } | { mode: 'supervise', args: string[], restartOnChange: boolean, restartFor?: (path: string) => boolean, inPlaceRestartFor?: (path: string) => boolean, watchDirs: string[], watchFiles: string[] }}
60
82
  * `inline` runs the server in this process (no reload watcher); `supervise`
61
83
  * spawns `process.execPath` with `args` and `__WEBJS_DEV_CHILD=1` under the
62
84
  * supervisor, which watches `watchDirs` (recursively) and `watchFiles` (at
@@ -71,7 +93,20 @@ export function planDevSupervisor({ isBun, argv, noHot, sourceLocations = false
71
93
  if (noHot) return { mode: 'inline' };
72
94
 
73
95
  const watch = { watchDirs: [...WATCH_DIRS], watchFiles: [...WATCH_FILES] };
74
- if (isBun) return { mode: 'supervise', args: ['--hot', ...argv], restartOnChange: true, restartFor: bunPluginServed(sourceLocations), ...watch };
96
+ if (isBun) {
97
+ // `restartFor` covers a server that cannot reload a plugin-served module in
98
+ // place. A server that can says so over IPC once it is up (#1575), and the
99
+ // supervisor narrows to `inPlaceRestartFor`: only the boot hooks restart.
100
+ const plugin = bunPluginServed(sourceLocations);
101
+ return {
102
+ mode: 'supervise',
103
+ args: ['--hot', ...argv],
104
+ restartOnChange: true,
105
+ restartFor: (p) => plugin(p) || isBootFile(p),
106
+ inPlaceRestartFor: isBootFile,
107
+ ...watch,
108
+ };
109
+ }
75
110
  return { mode: 'supervise', args: [...argv], restartOnChange: true, ...watch };
76
111
  }
77
112
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.65",
3
+ "version": "0.10.67",
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,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.81",
21
+ "@webjsdev/server": "^0.8.84",
22
22
  "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
@@ -30,7 +30,7 @@ Most of what follows restates widely held component-model advice, ported. Lit's
30
30
 
31
31
  **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
32
32
 
33
- - Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers.
33
+ - A plain-attribute hole follows one rule on the server and the client (#1573): `null` / `undefined` omit the attribute, `false` omits it except on an `aria-*` name, where it is written as `"false"`, and `true` on an HTML boolean attribute (`checked`, `selected`, `disabled`, ...) writes the empty value like `?attr` (#1579). So `checked=${isDefault}` and `selected=${i === 0}` behave exactly like `?checked` / `?selected`, though `?attr` stays the clearer spelling for a boolean attribute. So `aria-expanded=${this.open}` serves `"true"` / `"false"` and hydrates unchanged, and `aria-current=${active ? 'page' : null}` omits the attribute on an inactive link. `?attr=${bool}` is not a substitute for a tri-state ARIA attribute, since a boolean binding omits the attribute when false.
34
34
  - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
35
35
 
36
36
  **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
@@ -402,7 +402,7 @@ A component that does no client-side work renders the same SSR'd HTML with or wi
402
402
  - a factory-declared reactive property that is not `{ state: true }`
403
403
  - an overridden lifecycle hook (including `renderFallback` / `renderError`)
404
404
  - an imported `signal` / `computed` / `watch` / `Task` / `ref` / streaming directive, or `addController` / `requestUpdate`
405
- - 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)
405
+ - 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. A call inside a declaration does not run at load, so it is not a blocker: an arrow's expression body (`export const usd = (m) => Math.round(m) / MICROS`) and a function parameter default (`function relativeTime(when, now = Date.now())`) are both fine. 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)
406
406
  - 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)
407
407
  - being rendered by a component that itself ships
408
408
 
@@ -139,6 +139,8 @@ The renderer omits the `action` attribute so the form posts to the page's own ur
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
 
142
+ A failing form action does not need to echo the submission back. On the 422 re-render `actionData.values` already carries every submitted text field (any `values` the action returns are layered on top), and with JavaScript on the client router restores what was typed into every control the re-render did not explicitly set (#1581). Return `values` only to normalize or blank a field; see `routing-and-pages.md` for the page side.
143
+
142
144
  Everything the action declares applies here too, or an action would be protected over RPC and open over a form:
143
145
 
144
146
  - `validate` runs on the submitted `FormData`.
@@ -174,7 +174,9 @@ html`<form action=${submitFeedback}><input name="email"></form>`;
174
174
  html`<form method="post"><input name="email"></form>`;
175
175
  ```
176
176
 
177
- A hole that resolves to `null` is NOT the same as omitting the attribute. `method=${null}` renders `method=""`, which cannot submit and is refused; `?method=${false}` emits nothing at all, so WebJs supplies `method="post"` and the form works. Both leave no attribute in the DOM, which is exactly why the check reads your template rather than the rendered element.
177
+ A plain attribute hole that resolves to `null` or `undefined` omits the attribute on both renderers (#1573), so `method=${null}` and `?method=${false}` both emit nothing and WebJs supplies `method="post"`. An EMPTY string is different: `method=${''}` renders `method=""`, which cannot submit and is refused.
178
+
179
+ A boolean in a plain hole on an HTML boolean attribute renders like `?attr` (core 0.7.64+, #1579): `<option selected=${i === 0}>` and `<input type="radio" checked=${v.attending !== 'no'}>` mark only the true one. On an older core the server served `selected="false"` / `checked="false"`, which HTML reads as PRESENT, so the LAST option or radio won on first paint. `?selected=${...}` / `?checked=${...}` is correct on every version.
178
180
 
179
181
  A string stays a string: `action="/search"` and `action=${'/search'}` are unchanged, which is what a search form (`<form method="get" action="/search">`) and a `route.ts` endpoint both want. Other attributes keep their existing stringify behaviour; only a FUNCTION under `action` / `formaction` is claimed.
180
182
 
@@ -220,6 +220,8 @@ export default function Contact({ actionData }: {
220
220
 
221
221
  How the result is read (server side): a success PRG-redirects with `303` (to a same-site `redirect` path if present, else the page's own URL); a failure re-SSRs the SAME page with `status` (default `422`) and the result on `ctx.actionData`. Failure is detected robustly (`success === false`, OR `fieldErrors` present, OR `error` present with `success !== true`), so an error is never swallowed. `result.redirect` must be a same-site local path (a single leading `/`); for a real external redirect, throw `redirect(absoluteUrl)` instead. On a plain GET render `actionData` is `undefined`. Prefer a bound `<form>` over `fetch` in a `@click` for any write a form can express.
222
222
 
223
+ **Typed values survive a failed submission without extra code (#1581).** On a failure re-render `actionData.values` carries EVERY submitted text field, with any `values` the action returned layered on top (the action's own win, so it can normalize or blank a field). So `value=${values.x}` refills a field the action never echoed, with JavaScript off. With JavaScript on, the client router goes further: it snapshots the submitted form and, when the non-2xx re-render is applied in place, puts back what was typed in every text input, textarea, select, radio and checkbox whose server-rendered default did not change. A control the server rendered differently on purpose (refilled, normalized, reset) keeps the server's value. Passwords, files and hidden inputs are never restored. Opt a form out with `data-preserve-values="false"`. Refilling from `actionData.values` is still the right habit, since it is what the no-JS path shows; a checkbox group or multi-select repeats its name, and `values` keeps only the last one, so read those from the submitted `FormData` in the action.
224
+
223
225
  Three responses that are not the happy path:
224
226
 
225
227
  - **A submission carrying no identity is a `405` + `Allow: GET, HEAD`.** A bare `<form method="post">` binds nothing, and the page path exists but only renders, so the method is what is wrong rather than the url.
@@ -32,21 +32,21 @@ Three seams pick a runtime-specific implementation, all inside the framework, no
32
32
  | Listener | `node:http` shell | native `Bun.serve` (faster on the listening path only, not end-to-end, because SSR render dominates a real page) |
33
33
  | TS strip | built-in `module.stripTypeScriptTypes` | `amaro` (byte-identical, position-preserving) |
34
34
  | SQLite | built-in `node:sqlite` + `drizzle-orm/node-sqlite` | built-in `bun:sqlite` + `drizzle-orm/bun-sqlite` |
35
- | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | `bun --hot`, plus a restart for a `*.server.*` edit and, with source locations on, any app module edit (#1550) |
35
+ | Hot reload | restart on change (the `webjs dev` supervisor, #1521) | in place in one long-lived process (`bun --hot` resets the module registry, #1575); only an `instrumentation.*` / `env.*` edit restarts it |
36
36
  | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter |
37
37
  | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) |
38
- | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398), unless source locations are on |
38
+ | Dev edit to a page / layout | full reload (the dev restart replaces the process) | refreshes IN PLACE, no reload (#1398) |
39
39
  | Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same |
40
40
 
41
41
  **`webjs dev` lets an idle host sleep (#1507).** The live-reload stream sends nothing between edits (no keepalive) and is held open only while a tab showing the app is visible, so a sandbox or preview host that suspends on network quiet can suspend with a backgrounded dev tab open. Showing the tab reconnects, and an edit made meanwhile reloads the page on return. A host that counts an OPEN request as activity (a sandbox that suspends on idle) also needs `"webjs": { "dev": { "reloadIdle": 20 } }` (or `WEBJS_DEV_RELOAD_IDLE=20`): after that many seconds with no edit and no interaction the stream closes, and the next interaction, a tab showing, or an embed-bridge host command (`{ source: 'webjs-embed-host', type: 'resume' }`) reopens it. Off by default. Every reconnect also compares the server's state with the state the page on screen was rendered at, so an edit whose reload signal was lost while the stream was being replaced (a host that closes a held stream when it wakes) still reloads the page.
42
42
 
43
43
  **The in-place dev refresh (#1398) needs the server process to SURVIVE the edit,** which is the whole of the Node-versus-Bun difference in that row. A page or layout never hydrates, so a freshly rendered page is the complete truth for it and the client router can swap it in without a reload, keeping scroll and (for a page edit) the hydrated state of components outside the changed region. The server classifies the changed file and puts the verdict on the live-reload event, so this needs a process that is still alive to do the classifying.
44
44
 
45
- Bun's `bun --hot` invalidates modules in place without restarting, so it gets the refresh. It cannot reload a module a `Bun.plugin` serves, though (#1550): every `'use server'` `*.server.*` module (the action-seeding plugin) and, when dev source locations are on (`WEBJS_SOURCE_LOCATIONS=1`), every app module (the source-locations plugin). The supervisor restarts the server for an edit to one of those, so on Bun with source locations on every app edit is a full reload, as on Node. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
45
+ Bun keeps ONE dev server process for the whole session (#1575), so it gets the refresh. `bun --hot` re-runs the CLI on a change; the first run's server owns the process (its listener, live-reload stream, watchers and analysis caches) and a re-run only tells it the module registry was reset, so nothing is started twice and memory stays flat over hundreds of edits (it used to grow about 25 MB an edit until `bun --hot` stopped reloading). The app's modules load through a `Bun.plugin` that reads them fresh, and its `#` imports resolve through the app's own `imports` map, because Bun keeps the old source of a file that was replaced (an atomic save) and a stale directory listing for a new file next to a `*.server.*` module. So `bun --hot` no longer sees app edits itself, and the dev server asks it for a registry reset after an edit to a module some other module imports, a new module, or a `*.server.*` module, all without a process restart. A page, layout or route handler nothing imports needs no reset: the dev re-import is keyed by the file's content, so an unchanged file reuses its loaded module (no new module instance per request) and an edited one is a new import. `instrumentation.*` and `env.*` run once per process, so an edit to one restarts the server on both runtimes. On Node the `webjs dev` supervisor (it replaced `node --watch` in #1521) RESTARTS the server process on a change under `app`, `components`, `modules`, `lib`, or `actions`, or to a root `middleware.{ts,js,mts,mjs}`, and a fresh process holds no record of what changed, so those edits are always a full reload. Two Node cases still refresh in place: an edit OUTSIDE that watched set (`db/schema.server.ts`, a `webjs.dev.watch` content dir), and running `npm run dev -- --no-hot`, which keeps the server in one process on either runtime. An in-place refresh loads the rebuilt stylesheets (a `webjs.dev.regenerate` compile runs on that request) BEFORE it swaps the new markup in, and drops the old sheets only after, so an element that gained a utility class never paints without its rule (#1535; a full reload never had the gap, since a head stylesheet is render-blocking). A component edit is a full reload everywhere by design, because `customElements.define` is once-per-tag and swapping fresh markup onto the old class would be worse than the reload.
46
46
 
47
47
  **`webjs dev` and `webjs start` serve the directory they are started in, and refuse anywhere else (#1526).** Run them in the app directory, the one holding `app/`. In a workspace (`apps/web` under a root `package.json` with `workspaces`) that is the member, even when the CLI is hoisted to the root `node_modules`: the hoisted bin and the Bun `--hot` child both keep the directory they were started in. Started where there is no `app/` (the workspace root, or a subdirectory such as `app/` itself), both exit 1 before any `before` step runs, naming the app to start (`cd apps/web && webjs dev`), instead of booting a server that answers 404 for every route.
48
48
 
49
- **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an edit `bun --hot` cannot reload in place (a `*.server.*` module, or with source locations on any app module, #1550), and otherwise does only the crash recovery. Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
49
+ **`webjs dev` does not stay down (#1521).** The supervisor and the server's own watcher handle every watcher error: a file in a watched dir the dev server cannot read or watch (the 0600 temp file `sed -i` creates when another user runs it, a file removed mid-scan) logs one `file watcher skipped <path> (EACCES)` warning and both keep running, where `node --watch` used to crash and leave the preview dead. Edits that REPLACE a file (`sed -i`, an editor saving through a temp file, an atomic write) are heard every time, however often the same file is replaced (#1529: on Linux under Node the watchers watch each directory, since Node 24's own recursive watcher stopped hearing a file after its first replacement). A server process that crashes is started again on the next file change, or by itself after a backoff of 0.5s growing to 10s for repeated crashes. On Bun the supervisor restarts the server only for an `instrumentation.*` / `env.*` edit (#1575), and otherwise does only the crash recovery. An agent writing files the way an AI editor does (bursts, partial writes, syntax errors then fixes, renames, deletes, atomic writes) is exercised by `scripts/dev-reload-stress.mjs` (`node scripts/dev-reload-stress.mjs <appDir> <url>` against any running app). Stopping `webjs dev` (Ctrl-C, SIGTERM) stops the server child too, and a child whose supervisor was killed outright exits on its own, so nothing is left holding the port.
50
50
 
51
51
  The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes.
52
52
 
@@ -81,9 +81,9 @@ So the loop is: `add` the component, then query `ui <name>` (MCP) or
81
81
  ## Inventory (run `npx webjsdev ui list` or the MCP `ui` tool for the authoritative, current set)
82
82
 
83
83
  **Tier 1 (class helpers):** accordion, alert, aspect-ratio, avatar, badge,
84
- breadcrumb, button, card, checkbox, collapsible, input, kbd, label,
85
- native-select, pagination, popover, progress, radio-group, separator, skeleton,
86
- switch, table, textarea.
84
+ breadcrumb, button, card, checkbox, collapsible, empty, field, input, kbd,
85
+ label, native-select, pagination, popover, progress, radio-group, separator,
86
+ skeleton, spinner, switch, table, textarea.
87
87
 
88
88
  **Tier 2 (custom elements, own their ARIA):** alert-dialog, dialog,
89
89
  dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
@@ -93,6 +93,17 @@ dropdown-menu, hover-card, sonner, tabs, tooltip, plus toggle and toggle-group
93
93
 
94
94
  - A helper is a function, so compose it: `class=${buttonClass({ variant: 'outline' })}`.
95
95
  The unquoted `${...}` is a normal `html` attribute hole.
96
+ - The three states a screen owes its user each have a primitive, so do not hand-roll
97
+ them. A list or table with no rows renders `empty` (title as a real heading, a
98
+ description saying what to do next, and the create action). A form field is
99
+ `field` (`fieldClass()` holding a `<label for>`, the control, `fieldDescriptionClass()`
100
+ text, and on a validation error `data-invalid="true"`, `aria-invalid` on the control,
101
+ and a `fieldErrorClass()` message with `role="alert"` whose id the control's
102
+ `aria-describedby` lists, with the typed value kept). A pending save puts
103
+ `spinner({ decorative: true })` in a `disabled` + `aria-busy="true"` submit button
104
+ that keeps its text. The `fieldClass` / `fieldLabelClass` in `components/ui/field.ts`
105
+ are not the same-named rhythm helpers in `lib/utils/cn.ts`; import each from its own
106
+ module and alias one if a file needs both.
96
107
  - Tier-1 helpers assume the design tokens exist; if a component paints unstyled,
97
108
  the tokens are missing (re-run `npx webjsdev ui init` or let `add` self-heal them).
98
109
  - Custom elements are display-only-safe at SSR and hydrate in the browser, the