@webjsdev/server 0.7.2 → 0.8.0

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.
@@ -0,0 +1,797 @@
1
+ /**
2
+ * Static analyser deciding whether a webjs component module can be
3
+ * ELIDED from the browser, that is, never downloaded as JS because its
4
+ * SSR'd HTML is the complete, final output and the component does no
5
+ * client-side work.
6
+ *
7
+ * Direction of safety: a false "interactive" verdict only costs a
8
+ * missed optimization (we ship a module we could have skipped). A false
9
+ * "display-only" verdict BREAKS the page (an interactive component never
10
+ * boots). So every ambiguity resolves to "interactive / ship". The
11
+ * analyser is a DENYLIST of interactivity signals; anything it does not
12
+ * recognise ships by default.
13
+ *
14
+ * ─────────────────────────────────────────────────────────────────────
15
+ * SINGLE SOURCE OF TRUTH FOR INTERACTIVITY SIGNALS
16
+ * ─────────────────────────────────────────────────────────────────────
17
+ * The exported constants below (REACTIVE_IMPORTS, CLIENT_LIFECYCLE_HOOKS,
18
+ * CLIENT_METHOD_CALLS) ARE the contract. When the framework gains a new
19
+ * interactivity feature (a new lifecycle hook, a new reactive primitive,
20
+ * a new client-only directive, a new event-binding syntax), the marker
21
+ * for it MUST be added to the matching list here, or the analyser will
22
+ * wrongly elide components that use it.
23
+ *
24
+ * This is enforced two ways:
25
+ * 1. The guard test in test/elision/lifecycle-coverage.test.js
26
+ * introspects the live framework surface and fails on drift:
27
+ * it enumerates every overridable WebComponent hook (each must flip
28
+ * a component to interactive), classifies every @webjsdev/core/
29
+ * directives export as client-only or render-time (a new directive
30
+ * fails the test until classified), and checks that no
31
+ * REACTIVE_IMPORTS entry is stale. Adding an interactivity surface
32
+ * without updating these lists fails that test.
33
+ * 2. Maintainer pointers route changes back here: invariant 6 in
34
+ * packages/core/AGENTS.md and the MAINTAINER NOTE on the
35
+ * WebComponent lifecycle in packages/core/src/component.js both
36
+ * direct anyone adding an interactivity surface to update these
37
+ * lists, since webjs development is largely AI-agent driven.
38
+ */
39
+
40
+ import {
41
+ extractWebComponentClassBodies,
42
+ matchClosingBrace,
43
+ redactStringsAndTemplates,
44
+ } from './js-scan.js';
45
+ import { transitiveDeps } from './module-graph.js';
46
+
47
+ /**
48
+ * Named imports from a `@webjsdev/core` specifier that imply the
49
+ * component does reactive or async client work. Importing any of these
50
+ * forces the module to ship: signals re-render on change, Task resolves
51
+ * on the client, the streaming directives settle on the client, and
52
+ * Context consumers subscribe after upgrade.
53
+ *
54
+ * @type {ReadonlySet<string>}
55
+ */
56
+ export const REACTIVE_IMPORTS = new Set([
57
+ 'signal',
58
+ 'computed',
59
+ 'effect',
60
+ 'watch',
61
+ 'Task',
62
+ 'until',
63
+ 'asyncAppend',
64
+ 'asyncReplace',
65
+ 'ContextProvider',
66
+ 'ContextConsumer',
67
+ 'connectWS',
68
+ // Client-only directives. `ref` / `createRef` fire a callback against the
69
+ // live element (focus, measure, third-party mount); `live` syncs an input
70
+ // value against the DOM. All produce identical SSR HTML but do real work
71
+ // only after upgrade, so a component using them must ship. Render-time
72
+ // directives (repeat, unsafeHTML, keyed, guard, cache, map) are NOT here:
73
+ // they are SSR-renderable and common in display-only components.
74
+ 'ref',
75
+ 'createRef',
76
+ 'live',
77
+ ]);
78
+
79
+ /**
80
+ * Overridable WebComponent lifecycle hooks. Overriding any of them is
81
+ * client-side behaviour the SSR pass never runs, so the module must
82
+ * ship. `render` is deliberately absent: every display-only component
83
+ * defines `render`, and the SSR walker calls it directly. `constructor`
84
+ * is absent for the same reason (it runs during SSR to seed first
85
+ * paint). Keep this list in lockstep with the lifecycle table in
86
+ * agent-docs/components.md.
87
+ *
88
+ * @type {readonly string[]}
89
+ */
90
+ export const CLIENT_LIFECYCLE_HOOKS = [
91
+ 'connectedCallback',
92
+ 'disconnectedCallback',
93
+ 'attributeChangedCallback',
94
+ // Standard custom-element callback webjs does not itself define (so it
95
+ // is absent from the prototype guard's CLASSIFICATION), but an author
96
+ // can still override it to do client work. Kept for conservatism.
97
+ 'adoptedCallback',
98
+ 'shouldUpdate',
99
+ 'willUpdate',
100
+ 'update',
101
+ 'updated',
102
+ 'firstUpdated',
103
+ 'getUpdateComplete',
104
+ 'renderError',
105
+ ];
106
+
107
+ /**
108
+ * Method calls that only make sense on the client. `addController`
109
+ * registers a ReactiveController (client lifecycle). `requestUpdate`
110
+ * schedules a re-render. Either implies the component is not inert.
111
+ *
112
+ * @type {readonly string[]}
113
+ */
114
+ export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
115
+
116
+ /** Match a `@event=${...}` binding inside a template (unquoted per invariant 4). */
117
+ const EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*\$\{/;
118
+
119
+ /** Match a `.onclick=${...}` (native event-handler property) binding. */
120
+ const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*\$\{/;
121
+
122
+ /** Match a rendered `<slot>` / `<slot ` / `<slot/>`, but not `<slot-machine>`. */
123
+ const SLOT_RE = /<slot[\s/>]/;
124
+
125
+ /** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
126
+ const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
127
+
128
+ /** Side-effect or named import of the client router subpath. */
129
+ const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
130
+ /** Client-only named APIs from the `@webjsdev/core` main entry. */
131
+ const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate'];
132
+
133
+ /** Identifiers that only exist in a browser; their presence means client work. */
134
+ const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
135
+ /** Same, for component source, minus `customElements` (the registration call
136
+ * `customElements.define(...)` legitimately uses it and must not force ship). */
137
+ const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
138
+
139
+ /**
140
+ * Module-scope client work, detected by an ALLOWLIST of safe top-level forms
141
+ * rather than a denylist of browser globals. A module that runs ANY code when
142
+ * it loads (other than registering a component) does client work the render /
143
+ * lifecycle / event checks would miss, so it must ship. Unlike a global
144
+ * denylist this does not rot as browsers add APIs: a brand-new global trips it
145
+ * automatically, because it is the CALL (or `new`, or dynamic `import()`), not
146
+ * the global's name, that is recognised.
147
+ *
148
+ * Rule: at brace depth 0 (so code inside function / class / method bodies and
149
+ * template holes, which do not run at module load, is ignored), the only
150
+ * permitted forms are declarations (`import` / `export` / `function` / `class`
151
+ * / `const` / `let` / `var`) and the component registration call
152
+ * (`X.register(...)` / `customElements.define(...)`). Any other call, any
153
+ * `new`, any dynamic `import(...)`, or top-level `await` means client work.
154
+ *
155
+ * Scans the redacted copy (strings / templates / comments blanked, regex
156
+ * literals and nested `${...}` interpolation tracked by the lexer) so template
157
+ * prose and JSDoc / TS type annotations cannot trip it; quoted-string bodies,
158
+ * which redaction keeps verbatim for other rules, are blanked here too so a
159
+ * string like `"foo()"` or `"{"` is not read as a call and cannot unbalance
160
+ * the brace scan. The unbalanced-brace and unterminated-string fallbacks below
161
+ * are defense in depth: with the lexer tracking regex literals, neither should
162
+ * trigger on valid code, but if either does the module ships rather than risk
163
+ * hiding client work.
164
+ *
165
+ * Over-detection is safe (a top-level arrow whose body calls something, or a
166
+ * pure top-level helper call, only ships). The accepted residual misses, all
167
+ * contrived and structural (so they do not rot), are a call buried inside a
168
+ * top-level object / array initializer or a destructuring default, and a
169
+ * side-effecting tagged-template hole evaluated at module scope.
170
+ *
171
+ * @param {string} src raw module source
172
+ */
173
+ function hasModuleScopeSideEffect(src) {
174
+ const redacted = redactStringsAndTemplates(src);
175
+ // Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
176
+ // braces/parens inside a string neither desync the depth nor read as a call.
177
+ let depth = 0;
178
+ let frame = '';
179
+ for (let i = 0; i < redacted.length; i++) {
180
+ const c = redacted[i];
181
+ if (c === "'" || c === '"') {
182
+ i++;
183
+ let closed = false;
184
+ while (i < redacted.length) {
185
+ const d = redacted[i];
186
+ if (d === '\\') { i += 2; continue; }
187
+ if (d === '\n') break; // a real quoted string never spans a newline
188
+ if (d === c) { closed = true; break; }
189
+ i++;
190
+ }
191
+ // Not closed on its line is an unterminated string OR a regex literal
192
+ // containing a quote that desynced the upstream redaction (regex bodies
193
+ // are not tracked, so a stray quote shifts quote pairing). Either way the
194
+ // lexical state is unreliable below here, so ship conservatively.
195
+ if (!closed) return true;
196
+ if (depth === 0) frame += "''";
197
+ continue;
198
+ }
199
+ if (c === '{') { depth++; continue; }
200
+ if (c === '}') { if (depth > 0) depth--; continue; }
201
+ if (depth === 0) frame += c;
202
+ }
203
+ // Unbalanced braces mean a construct we could not lexically resolve (a regex
204
+ // literal with a stray brace is the common case). Ship rather than risk a
205
+ // hidden top-level statement.
206
+ if (depth !== 0) return true;
207
+ // Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
208
+ // identifier-before-paren match below).
209
+ if (/\?\.\s*[([]/.test(frame)) return true;
210
+ // Top-level `new` (a `new X()` constructor also trips the call check, but a
211
+ // `new X` without parens would not) and top-level `await`.
212
+ if (/(?<![.\w])(?:new|await)\s/.test(frame)) return true;
213
+ // A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
214
+ const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
215
+ // Identifiers that precede a `(` WITHOUT it being a call (keywords + a
216
+ // `function` declaration's parameter list).
217
+ const NOT_A_CALL = new Set([
218
+ 'if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'typeof',
219
+ 'instanceof', 'void', 'delete', 'in', 'of', 'yield', 'do', 'else',
220
+ 'case', 'default', 'function', 'await', 'new', 'async',
221
+ ]);
222
+ let m;
223
+ while ((m = CALL_RE.exec(frame)) !== null) {
224
+ const ident = m[1];
225
+ if (ident === 'import') return true; // dynamic import()
226
+ if (ident && NOT_A_CALL.has(ident)) continue;
227
+ // A `function`-declaration parameter list (`function name(` or
228
+ // `async function name(`) is not a call.
229
+ if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
230
+ // The component registration call is the one permitted top-level call.
231
+ if (ident === 'register' || ident === 'define') continue;
232
+ return true; // any other top-level call
233
+ }
234
+ return false;
235
+ }
236
+
237
+ /** Match a whole-line SIDE-EFFECT import: `import 'pkg';` (no binding clause).
238
+ * `\s*` before the quote (not `\s+`) so `import"pkg"` (no space) is caught;
239
+ * a binding clause still fails because a non-quote follows `import`. A
240
+ * trailing line comment is tolerated. */
241
+ const SIDE_EFFECT_BARE_IMPORT_RE = /^\s*import\s*(['"])([^'"]+)\1\s*;?\s*(?:\/\/[^\n]*)?$/gm;
242
+
243
+ /**
244
+ * True if `src` imports the client router (the `/client-router` subpath, or
245
+ * a router/nav API from the core main entry). A page or layout that does so
246
+ * is enabling client-side navigation and must ship.
247
+ * @param {string} src
248
+ * @returns {boolean}
249
+ */
250
+ function importsClientRouter(src) {
251
+ if (CLIENT_ROUTER_SUBPATH_RE.test(src)) return true;
252
+ for (const m of src.matchAll(CORE_IMPORT_RE)) {
253
+ const clause = m[1];
254
+ if (clause.startsWith('{')) {
255
+ const names = clause.slice(1, -1).split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim());
256
+ if (names.some((n) => CLIENT_ROUTER_IMPORTS.includes(n))) return true;
257
+ } else if (clause.startsWith('*')) {
258
+ // Namespace import: a router/nav member reached through `ns.member`,
259
+ // a destructure of `ns`, or computed access. Mirrors the reactive
260
+ // primitive detection so the two stay symmetric.
261
+ const ns = clause.replace(/^\*\s+as\s+/, '').trim();
262
+ if (!ns || !/^\w+$/.test(ns)) continue;
263
+ for (const name of CLIENT_ROUTER_IMPORTS) {
264
+ if (new RegExp(`\\b${ns}\\.${name}\\b`).test(src)) return true;
265
+ }
266
+ if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(src)) return true;
267
+ if (new RegExp(`\\b${ns}\\s*\\[`).test(src)) return true;
268
+ }
269
+ }
270
+ return false;
271
+ }
272
+
273
+ /**
274
+ * True if `src` SIDE-EFFECT imports a bare npm package other than the (inert)
275
+ * `@webjsdev/core` family (`import 'pkg'`, no binding). A side-effect import
276
+ * runs the package's top-level code when the module loads, which is real
277
+ * client work, so a module that has one must ship.
278
+ *
279
+ * A BINDING import (`import x from 'pkg'`) is deliberately NOT flagged: a page
280
+ * function never runs on the client and a display-only component's render
281
+ * never runs on the client when elided, so a package used only as a value in
282
+ * that code never executes client-side and rides away when the module is
283
+ * dropped. This is what lets an SSR-only dependency stay off the client
284
+ * without a `.server.{js,ts}` wrapper.
285
+ *
286
+ * Residual edge: a package that self-registers on import (e.g. calls
287
+ * `customElements.define` at module top level) imported via a binding clause
288
+ * is NOT caught here, so eliding the importer drops that registration and the
289
+ * element silently does not upgrade. This is the cross-module-registration
290
+ * caveat documented in agent-docs/components.md and server AGENTS invariant 7;
291
+ * the fix is `.server.{js,ts}` for genuinely server-only deps, or an
292
+ * interactivity signal on the consumer. (It is not caught by an SSR crash:
293
+ * the SSR `customElements` shim makes `define` a no-op server-side.)
294
+ * @param {string} src
295
+ * @returns {boolean}
296
+ */
297
+ function importsSideEffectNonCorePackage(src) {
298
+ for (const m of src.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
299
+ const spec = m[2];
300
+ if (spec.startsWith('.') || spec.startsWith('/')) continue; // relative / absolute
301
+ if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue; // inert framework / router handled separately
302
+ if (spec.startsWith('node:')) continue; // server-only builtins
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+
308
+ /** Match a named-import clause from a `@webjsdev/core` specifier. */
309
+ const CORE_IMPORT_RE =
310
+ /import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"](@webjsdev\/core[^'"]*|[^'"]*\/__webjs\/core\/[^'"]*)['"]/g;
311
+
312
+ /**
313
+ * Decide whether a component module is interactive (must ship) or
314
+ * display-only (may be elided).
315
+ *
316
+ * @param {string} src raw module source
317
+ * @returns {{ interactive: boolean, reason: string | null }}
318
+ */
319
+ export function analyzeComponentSource(src) {
320
+ // A reactive primitive imported from core is the strongest signal and
321
+ // is file-scoped, so check it first against the whole source.
322
+ const reactiveImport = importsReactivePrimitive(src);
323
+ if (reactiveImport) {
324
+ return { interactive: true, reason: `imports reactive primitive '${reactiveImport}'` };
325
+ }
326
+
327
+ // Event bindings live inside `html` template bodies, which redaction
328
+ // blanks. Scan the RAW source for them (over-detection is safe).
329
+ if (EVENT_BINDING_RE.test(src)) {
330
+ return { interactive: true, reason: 'template has an @event binding' };
331
+ }
332
+ if (EVENT_PROP_RE.test(src)) {
333
+ return { interactive: true, reason: 'template sets a native event-handler property' };
334
+ }
335
+
336
+ // A rendered `<slot>` relies on webjs's client slot-projection runtime
337
+ // for the slot API (assignedNodes, slotchange) and dynamic re-
338
+ // projection. Shadow DOM slots are native via Declarative Shadow DOM,
339
+ // but proving a given slot is purely native is beyond static analysis,
340
+ // so any rendered slot ships. Tag names like `<slot-machine>` are
341
+ // excluded by requiring a slot-closing character.
342
+ if (SLOT_RE.test(src)) {
343
+ return { interactive: true, reason: 'renders a <slot> (needs the projection runtime)' };
344
+ }
345
+
346
+ // Top-level client work the render/lifecycle checks would miss: a
347
+ // side-effect import of an npm package runs its code when the module
348
+ // loads, and a browser global (window/document/…, excluding the
349
+ // registration's customElements) means the module does client work even
350
+ // if its render is otherwise pure. Eliding such a component would drop
351
+ // that effect, so ship. (Mirrors the route-module analysis.)
352
+ if (importsSideEffectNonCorePackage(src)) {
353
+ return { interactive: true, reason: 'side-effect imports an npm package' };
354
+ }
355
+ if (COMPONENT_CLIENT_GLOBAL_RE.test(src)) {
356
+ return { interactive: true, reason: 'references a browser global at module scope' };
357
+ }
358
+ if (hasModuleScopeSideEffect(src)) {
359
+ return { interactive: true, reason: 'runs code at module scope (a top-level call, new, or dynamic import())' };
360
+ }
361
+
362
+ // The brace matcher counts depth reliably only on redacted source
363
+ // (template `${...}` holes would otherwise unbalance it). Code-shaped
364
+ // signals (lifecycle hooks, method calls, property descriptors) all
365
+ // survive redaction, so extract and inspect class bodies from there.
366
+ const bodies = extractWebComponentClassBodies(redactStringsAndTemplates(src));
367
+ // A registered component with no recognisable `extends WebComponent`
368
+ // body is a subclass of a custom base (or otherwise unparseable).
369
+ // Cannot prove it inert, so ship it.
370
+ if (bodies.length === 0) {
371
+ return { interactive: true, reason: 'no parseable WebComponent class body' };
372
+ }
373
+
374
+ for (const body of bodies) {
375
+ for (const hook of CLIENT_LIFECYCLE_HOOKS) {
376
+ // A client lifecycle hook as a method (`hook(`) OR as an arrow class
377
+ // field (`hook = () =>`), which shadows the prototype method and still
378
+ // runs. Either way the component is not inert.
379
+ if (new RegExp(`\\b${hook}\\s*[=(]`).test(body)) {
380
+ return { interactive: true, reason: `overrides lifecycle hook '${hook}'` };
381
+ }
382
+ }
383
+ for (const call of CLIENT_METHOD_CALLS) {
384
+ if (new RegExp(`\\b${call}\\s*\\(`).test(body)) {
385
+ return { interactive: true, reason: `calls '${call}'` };
386
+ }
387
+ }
388
+ if (hasNonStateReactiveProperty(body)) {
389
+ return {
390
+ interactive: true,
391
+ reason: 'declares a reactive property that is not { state: true }',
392
+ };
393
+ }
394
+ }
395
+
396
+ return { interactive: false, reason: null };
397
+ }
398
+
399
+ /**
400
+ * @param {string} src
401
+ * @returns {string | null} the offending imported name, or null
402
+ */
403
+ function importsReactivePrimitive(src) {
404
+ for (const m of src.matchAll(CORE_IMPORT_RE)) {
405
+ const clause = m[1];
406
+ if (clause.startsWith('{')) {
407
+ const names = clause
408
+ .slice(1, -1)
409
+ .split(',')
410
+ .map((s) => s.trim().split(/\s+as\s+/)[0].trim())
411
+ .filter(Boolean);
412
+ for (const name of names) {
413
+ if (REACTIVE_IMPORTS.has(name)) return name;
414
+ }
415
+ } else if (clause.startsWith('*')) {
416
+ // Namespace import (`import * as core from '@webjsdev/core'`). We
417
+ // cannot see which members are used from the clause, so look for a
418
+ // reactive member reached through the namespace identifier `ns`
419
+ // (which is a bare `\w+`, safe to interpolate into a RegExp).
420
+ const ns = clause.replace(/^\*\s+as\s+/, '').trim();
421
+ if (!ns || !/^\w+$/.test(ns)) continue;
422
+ for (const name of REACTIVE_IMPORTS) {
423
+ if (new RegExp(`\\b${ns}\\.${name}\\b`).test(src)) return name;
424
+ }
425
+ // Destructuring the namespace (`const { signal } = core`) or computed
426
+ // access (`core['signal']`) hides which members are pulled. Ship.
427
+ if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(src)) {
428
+ return `${ns} (destructured namespace)`;
429
+ }
430
+ if (new RegExp(`\\b${ns}\\s*\\[`).test(src)) {
431
+ return `${ns} (computed namespace access)`;
432
+ }
433
+ }
434
+ }
435
+ return null;
436
+ }
437
+
438
+ /**
439
+ * True if the class body declares a `static properties` block with any
440
+ * entry that is NOT marked `{ state: true }`. Non-state reactive
441
+ * properties ride an attribute or a `.prop` binding, the channel through
442
+ * which a parent pushes updates, which is client-side reactivity.
443
+ *
444
+ * Conservative on parse failure: a `static get properties()` accessor or
445
+ * an unbalanced object literal returns true (ship).
446
+ *
447
+ * @param {string} classBody
448
+ * @returns {boolean}
449
+ */
450
+ function hasNonStateReactiveProperty(classBody) {
451
+ // No reactive properties declared at all: nothing rides an attribute.
452
+ if (!/\bstatic\s+(?:get\s+)?properties\b/.test(classBody)) return false;
453
+ // Properties ARE declared. We can only clear a component as inert when
454
+ // the declaration is a brace literal whose every entry is { state: true }.
455
+ // A getter (`static get properties()`) or a non-literal assignment
456
+ // (`= buildProps()`, `= SHARED_PROPS`, `= Object.assign(...)`) cannot be
457
+ // parsed for state flags, so ship conservatively. `[^=]*` tolerates a TS
458
+ // type annotation between the name and `=` without crossing the `=`.
459
+ const m = /\bstatic\s+properties\b[^=]*=\s*\{/.exec(classBody);
460
+ if (!m) return true;
461
+ const objStart = m.index + m[0].length;
462
+ const objEnd = matchClosingBrace(classBody, objStart);
463
+ if (objEnd === -1) return true;
464
+ const obj = classBody.slice(objStart, objEnd);
465
+ // A spread (`...BASE_PROPS`) can inject reactive properties we cannot
466
+ // see; ship rather than guess they are all { state: true }.
467
+ if (/\.\.\./.test(obj)) return true;
468
+ for (const entry of topLevelPropertyValues(obj)) {
469
+ // Object-literal descriptor: inert only when it carries state: true.
470
+ if (entry.startsWith('{')) {
471
+ // Blank string / template bodies first. Redaction keeps quoted
472
+ // string contents verbatim (so register('tag') stays readable), so
473
+ // a descriptor like `{ attribute: 'data-state: true' }` would
474
+ // otherwise forge the state flag. The real `state: true` is code,
475
+ // not a string, so it survives this blanking.
476
+ const code = entry
477
+ .replace(/'[^'\n]*'/g, "''")
478
+ .replace(/"[^"\n]*"/g, '""')
479
+ .replace(/`[^`]*`/g, '``');
480
+ if (!/\bstate\s*:\s*true\b/.test(code)) return true;
481
+ } else {
482
+ // Shorthand like `count: Number` rides an attribute, not state.
483
+ return true;
484
+ }
485
+ }
486
+ return false;
487
+ }
488
+
489
+ /**
490
+ * Yield the trimmed VALUE text of each top-level `key: value` entry in a
491
+ * properties object body, splitting on depth-0 commas and respecting
492
+ * nested braces, brackets, parens, strings, and templates.
493
+ *
494
+ * @param {string} obj the body between the outer braces
495
+ * @returns {string[]}
496
+ */
497
+ function topLevelPropertyValues(obj) {
498
+ /** @type {string[]} */
499
+ const values = [];
500
+ let depth = 0;
501
+ let str = '';
502
+ let colonAt = -1;
503
+ const push = (end) => {
504
+ if (colonAt === -1) return;
505
+ values.push(obj.slice(colonAt + 1, end).trim());
506
+ };
507
+ for (let i = 0; i < obj.length; i++) {
508
+ const c = obj[i];
509
+ if (str) {
510
+ if (c === '\\') { i++; continue; }
511
+ if (c === str) str = '';
512
+ continue;
513
+ }
514
+ if (c === "'" || c === '"' || c === '`') { str = c; continue; }
515
+ if (c === '{' || c === '[' || c === '(') { depth++; continue; }
516
+ if (c === '}' || c === ']' || c === ')') { depth--; continue; }
517
+ if (depth === 0) {
518
+ if (c === ':' && colonAt === -1) colonAt = i;
519
+ else if (c === ',') {
520
+ push(i);
521
+ colonAt = -1;
522
+ }
523
+ }
524
+ }
525
+ push(obj.length);
526
+ return values;
527
+ }
528
+
529
+ /**
530
+ * Custom-element tag names a module references in its templates. A tag
531
+ * must contain a hyphen (HTML custom-element spec), which excludes
532
+ * native elements. Over-detection is safe: it only forces more modules
533
+ * to ship.
534
+ *
535
+ * @param {string} src raw module source
536
+ * @returns {Set<string>}
537
+ */
538
+ export function extractRenderedTags(src) {
539
+ /** @type {Set<string>} */
540
+ const tags = new Set();
541
+ const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
542
+ let m;
543
+ while ((m = re.exec(src)) !== null) tags.add(m[1]);
544
+ return tags;
545
+ }
546
+
547
+ /**
548
+ * Compute the set of component FILES whose browser download can be
549
+ * elided. A file is elidable only when every component it defines is
550
+ * display-only AND it is not pulled into the client by an interactive
551
+ * component (rendered by, or imported by, a shipping module).
552
+ *
553
+ * Two propagation rules iterate to a fixpoint:
554
+ * - render rule: a shipping component that can emit `<child-tag>` on a
555
+ * client re-render forces the child to ship. The tags a component can
556
+ * emit are not only those in its own template but also those returned
557
+ * by the template helpers it imports (the documented `lib/utils/ui.ts`
558
+ * pattern), so the rule scans the component's transitive app-internal
559
+ * import closure, not just its own source.
560
+ * - import rule: a component that imports a shipping component module
561
+ * ships too (matches the issue's transitive criterion; conservative).
562
+ *
563
+ * @param {Array<{ tag: string, file: string }>} components
564
+ * @param {import('./module-graph.js').ModuleGraph} moduleGraph
565
+ * @param {(file: string) => Promise<string>} readFileFn
566
+ * @param {string} [appDir] app root; enables the helper-closure render rule
567
+ * @returns {Promise<Set<string>>} absolute paths of elidable component files
568
+ */
569
+ export async function computeElidableComponents(components, moduleGraph, readFileFn, appDir) {
570
+ const { elidableComponents } = await analyzeElision(components, [], moduleGraph, readFileFn, appDir);
571
+ return elidableComponents;
572
+ }
573
+
574
+ /**
575
+ * Full elision analysis: which display-only COMPONENT modules can be elided,
576
+ * AND which page/layout ROUTE modules are inert (do no client work) and can
577
+ * therefore be dropped from the client boot script entirely. The second is
578
+ * the progressive-enhancement completion of the first: a route whose whole
579
+ * subtree is inert ships zero JavaScript.
580
+ *
581
+ * A route module is inert only when neither it nor its effective client
582
+ * closure (the import graph with elided components and `.server` stubs
583
+ * skipped, since those never run on the client) touches anything
584
+ * client-effecting: a reactive primitive, the client router, an `@event` /
585
+ * `.on*` binding, a non-core npm import (which may self-execute), a client
586
+ * global (`window`, `document`, …), or a shipping component. Anything
587
+ * ambiguous or unreadable keeps shipping.
588
+ *
589
+ * @param {Array<{ tag: string, file: string }>} components
590
+ * @param {string[]} routeModules absolute paths of page + layout files
591
+ * @param {import('./module-graph.js').ModuleGraph} moduleGraph
592
+ * @param {(file: string) => Promise<string>} readFileFn
593
+ * @param {string} [appDir]
594
+ * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string> }>}
595
+ */
596
+ export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
597
+ /** @type {Set<string>} */
598
+ const componentFiles = new Set();
599
+ /** @type {Map<string, string>} */
600
+ const tagToFile = new Map();
601
+ for (const c of components) {
602
+ componentFiles.add(c.file);
603
+ tagToFile.set(c.tag, c.file);
604
+ }
605
+
606
+ /** @type {Set<string>} */
607
+ const mustShip = new Set();
608
+ /** @type {Map<string, Set<string>>} */
609
+ const fileTags = new Map();
610
+ /** @type {Set<string>} modules importing a reactive primitive from core */
611
+ const reactiveFiles = new Set();
612
+ /** @type {Set<string>} modules enabling the client router */
613
+ const clientRouterFiles = new Set();
614
+ /** @type {Set<string>} modules with an @event/.on* binding, a non-core npm import, or a client global */
615
+ const clientGlobalOrBareFiles = new Set();
616
+ /** @type {Set<string>} */
617
+ const serverFiles = new Set();
618
+
619
+ /** @type {Set<string>} */
620
+ const allFiles = new Set(componentFiles);
621
+ for (const f of routeModules) allFiles.add(f);
622
+ for (const [k, vs] of moduleGraph) {
623
+ if (!appDir || k.startsWith(appDir)) allFiles.add(k);
624
+ for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
625
+ }
626
+
627
+ for (const file of allFiles) {
628
+ if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
629
+ let src;
630
+ try { src = await readFileFn(file); }
631
+ catch {
632
+ // A component file we cannot read ships conservatively; a helper we
633
+ // cannot read simply contributes no tags.
634
+ if (componentFiles.has(file)) mustShip.add(file);
635
+ continue;
636
+ }
637
+ if (typeof src !== 'string') continue;
638
+ fileTags.set(file, extractRenderedTags(src));
639
+ if (importsReactivePrimitive(src)) reactiveFiles.add(file);
640
+ if (importsClientRouter(src)) clientRouterFiles.add(file);
641
+ if (EVENT_BINDING_RE.test(src) || EVENT_PROP_RE.test(src) ||
642
+ importsSideEffectNonCorePackage(src) || CLIENT_GLOBAL_RE.test(src) ||
643
+ hasModuleScopeSideEffect(src)) {
644
+ clientGlobalOrBareFiles.add(file);
645
+ }
646
+ if (componentFiles.has(file) && analyzeComponentSource(src).interactive) {
647
+ mustShip.add(file);
648
+ }
649
+ }
650
+
651
+ // Ship any component whose transitive import closure does client work,
652
+ // through ANY import (not just npm): a relative helper that imports a
653
+ // reactive primitive (shared module-scope signal), enables the client
654
+ // router, references a browser global, or side-effect imports a package.
655
+ // Same closure rule the route analysis applies, so a display-only
656
+ // component that pulls in a client-effecting helper still ships.
657
+ const closureIsClientEffecting = (d) =>
658
+ reactiveFiles.has(d) || clientRouterFiles.has(d) || clientGlobalOrBareFiles.has(d);
659
+ if (appDir) {
660
+ for (const file of componentFiles) {
661
+ if (mustShip.has(file)) continue;
662
+ const deps = transitiveDeps(moduleGraph, [file], appDir, serverFiles);
663
+ if (deps.some(closureIsClientEffecting)) mustShip.add(file);
664
+ }
665
+ }
666
+
667
+ // Tags each component can emit on a client re-render (own + helper closure).
668
+ /** @type {Map<string, Set<string>>} */
669
+ const emittableTags = new Map();
670
+ for (const file of componentFiles) {
671
+ const tags = new Set(fileTags.get(file));
672
+ const deps = appDir ? transitiveDeps(moduleGraph, [file], appDir) : [];
673
+ for (const dep of deps) {
674
+ const dt = fileTags.get(dep);
675
+ if (dt) for (const t of dt) tags.add(t);
676
+ }
677
+ emittableTags.set(file, tags);
678
+ }
679
+
680
+ // Fixpoint: render rule + import rule.
681
+ let changed = true;
682
+ while (changed) {
683
+ changed = false;
684
+ for (const parent of mustShip) {
685
+ const tags = emittableTags.get(parent);
686
+ if (!tags) continue;
687
+ for (const tag of tags) {
688
+ const childFile = tagToFile.get(tag);
689
+ if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); changed = true; }
690
+ }
691
+ }
692
+ for (const file of componentFiles) {
693
+ if (mustShip.has(file)) continue;
694
+ const deps = moduleGraph.get(file);
695
+ if (!deps) continue;
696
+ for (const dep of deps) {
697
+ if (componentFiles.has(dep) && mustShip.has(dep)) { mustShip.add(file); changed = true; break; }
698
+ }
699
+ }
700
+ }
701
+
702
+ /** @type {Set<string>} */
703
+ const elidableComponents = new Set();
704
+ for (const file of componentFiles) {
705
+ if (!mustShip.has(file)) elidableComponents.add(file);
706
+ }
707
+
708
+ // A file does client work if it ships as a component, or itself reaches a
709
+ // reactive primitive / client router / event binding / non-core npm import
710
+ // / client global.
711
+ const isClientEffecting = (file) =>
712
+ (componentFiles.has(file) && mustShip.has(file)) ||
713
+ reactiveFiles.has(file) ||
714
+ clientRouterFiles.has(file) ||
715
+ clientGlobalOrBareFiles.has(file);
716
+
717
+ // Route modules: inert iff neither the module nor its effective client
718
+ // closure (skipping elided components and server stubs, which never load)
719
+ // is client-effecting.
720
+ /** @type {Set<string>} */
721
+ const skip = new Set([...elidableComponents, ...serverFiles]);
722
+ /** @type {Set<string>} */
723
+ const inertRouteModules = new Set();
724
+ for (const file of routeModules) {
725
+ if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from inert set)
726
+ if (isClientEffecting(file)) continue;
727
+ const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
728
+ if (closure.some(isClientEffecting)) continue;
729
+ inertRouteModules.add(file);
730
+ }
731
+
732
+ return { elidableComponents, inertRouteModules };
733
+ }
734
+
735
+ /** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
736
+ const SIDE_EFFECT_IMPORT_RE = /^([ \t]*)import\s+(['"])([^'"]+)\2\s*;?[ \t]*$/gm;
737
+
738
+ /**
739
+ * Remove side-effect imports of elidable components from a browser
740
+ * module's served source, so the browser never downloads them. This is
741
+ * what actually elides the JS: a component is fetched because the page
742
+ * (or another component) statically imports it for registration, and
743
+ * the modulepreload hint only parallelises that fetch.
744
+ *
745
+ * ONLY side-effect imports (`import './x'`) are stripped. A binding
746
+ * import (`import { X } from './x'`) is left intact: its binding may be
747
+ * used as a value at runtime, so removing it would break the module.
748
+ * That is also why eliding stays correct, an elidable component is one
749
+ * used purely as an SSR'd tag, never as an imported value.
750
+ *
751
+ * Fast path: if the importer has no elidable dependency in the graph,
752
+ * the source is returned untouched without any regex work.
753
+ *
754
+ * Matching runs over a REDACTED copy (comment / string / template bodies
755
+ * blanked, positions preserved) so a line that merely reads like an
756
+ * import inside an `html\`...\`` template or a comment is never rewritten.
757
+ * Real top-level import statements survive redaction; the quoted
758
+ * specifier survives too (redaction keeps string bodies verbatim), so it
759
+ * is read from the redacted match and the original source is spliced at
760
+ * the matched range.
761
+ *
762
+ * @param {string} source module source (already type-stripped if TS)
763
+ * @param {string} importerAbs absolute path of the importing module
764
+ * @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
765
+ * @param {Set<string> | undefined} elidableSet absolute paths of elidable files
766
+ * @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
767
+ * @param {string} appDir
768
+ * @returns {string}
769
+ */
770
+ export function elideImportsFromSource(source, importerAbs, moduleGraph, elidableSet, resolveImport, appDir) {
771
+ if (!elidableSet || elidableSet.size === 0) return source;
772
+ const deps = moduleGraph && moduleGraph.get(importerAbs);
773
+ if (!deps) return source;
774
+ let hasElidableDep = false;
775
+ for (const d of deps) {
776
+ if (elidableSet.has(d)) { hasElidableDep = true; break; }
777
+ }
778
+ if (!hasElidableDep) return source;
779
+
780
+ const redacted = redactStringsAndTemplates(source);
781
+ let out = '';
782
+ let last = 0;
783
+ for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
784
+ const start = /** @type {number} */ (m.index);
785
+ const end = start + m[0].length;
786
+ const resolved = resolveImport(m[3], importerAbs, appDir);
787
+ out += source.slice(last, start);
788
+ if (resolved && elidableSet.has(resolved)) {
789
+ out += `${m[1]}/* webjs: elided display-only component */`;
790
+ } else {
791
+ out += source.slice(start, end);
792
+ }
793
+ last = end;
794
+ }
795
+ out += source.slice(last);
796
+ return out;
797
+ }