@webjsdev/server 0.9.0 → 0.10.1

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 (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
@@ -1,1828 +0,0 @@
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
- maskComments,
45
- redactToPlaceholders,
46
- } from './js-scan.js';
47
- import { transitiveDeps, expandImportAlias } from './module-graph.js';
48
- import { ensureStripper } from './ts-strip.js';
49
-
50
- /**
51
- * Named imports from a `@webjsdev/core` specifier that imply the
52
- * component does reactive or async client work. Importing any of these
53
- * forces the module to ship: signals re-render on change, Task resolves
54
- * on the client, the streaming directives settle on the client, and
55
- * Context consumers subscribe after upgrade.
56
- *
57
- * @type {ReadonlySet<string>}
58
- */
59
- export const REACTIVE_IMPORTS = new Set([
60
- 'signal',
61
- 'computed',
62
- 'effect',
63
- 'watch',
64
- // `optimistic(signal, value, action)` mutates a signal (client work) and
65
- // settles after a server round-trip, so a component importing it is not
66
- // display-only and must ship.
67
- 'optimistic',
68
- 'Task',
69
- 'until',
70
- 'asyncAppend',
71
- 'asyncReplace',
72
- 'ContextProvider',
73
- 'ContextConsumer',
74
- 'connectWS',
75
- // `renderStream(payload)` applies a server stream-action payload to the live
76
- // DOM (append/replace/remove), so a component importing it does client work
77
- // and must ship (#248).
78
- 'renderStream',
79
- // Client-only directives. `ref` / `createRef` fire a callback against the
80
- // live element (focus, measure, third-party mount); `live` syncs an input
81
- // value against the DOM. All produce identical SSR HTML but do real work
82
- // only after upgrade, so a component using them must ship. Render-time
83
- // directives (repeat, unsafeHTML, keyed, guard, cache, map) are NOT here:
84
- // they are SSR-renderable and common in display-only components.
85
- 'ref',
86
- 'createRef',
87
- 'live',
88
- ]);
89
-
90
- /**
91
- * Overridable WebComponent lifecycle hooks. Overriding any of them is
92
- * client-side behaviour the SSR pass never runs, so the module must
93
- * ship. `render` is deliberately absent: every display-only component
94
- * defines `render`, and the SSR walker calls it directly. `constructor`
95
- * is absent for the same reason (it runs during SSR to seed first
96
- * paint). Keep this list in lockstep with the lifecycle table in
97
- * the skill's references/components.md.
98
- *
99
- * @type {readonly string[]}
100
- */
101
- export const CLIENT_LIFECYCLE_HOOKS = [
102
- 'connectedCallback',
103
- 'disconnectedCallback',
104
- 'attributeChangedCallback',
105
- // Standard custom-element callback WebJs does not itself define (so it
106
- // is absent from the prototype guard's CLASSIFICATION), but an author
107
- // can still override it to do client work. Kept for conservatism.
108
- 'adoptedCallback',
109
- 'shouldUpdate',
110
- 'willUpdate',
111
- 'update',
112
- 'updated',
113
- 'firstUpdated',
114
- 'getUpdateComplete',
115
- 'renderError',
116
- // The async-render re-fetch loading UI (#469): defining it means the
117
- // component suspends and re-renders on the client, so it must ship.
118
- 'renderFallback',
119
- ];
120
-
121
- /**
122
- * Method calls that only make sense on the client. `addController` registers a
123
- * ReactiveController (client lifecycle); `requestUpdate` schedules a re-render.
124
- * Any of these implies the component is not inert. (The dynamic slot read
125
- * surface lives in `SLOT_DYNAMIC_RE`, not here.)
126
- *
127
- * @type {readonly string[]}
128
- */
129
- export const CLIENT_METHOD_CALLS = ['addController', 'removeController', 'requestUpdate'];
130
-
131
- /**
132
- * Static class fields whose declaration (to a non-`false` value) marks a
133
- * component interactive (must ship). Kept as ONE registry so a new
134
- * interactivity-relevant static convention is added in a single place; the
135
- * per-class analysis loops over it. Unlike prototype methods (guarded by
136
- * `lifecycle-coverage.test.js` via prototype introspection), there is no
137
- * enumerable runtime source of "all static conventions", so the contract is
138
- * a documented one: a new interactivity static field MUST be added here AND
139
- * to the lifecycle table in the skill's references/components.md. `sigil-coverage.test.js`
140
- * asserts each entry is honoured as a ship signal.
141
- *
142
- * - `shadow`: Declarative Shadow DOM attaches ONLY during HTML parsing, so a
143
- * component that arrives via a client DOM insertion (a soft-nav swap, a
144
- * streamed <webjs-suspense> boundary's replaceWith) would never re-attach
145
- * its shadow root if elided. Context-free, so any shadow component ships.
146
- * - `interactive`: `static interactive = true` is the explicit author
147
- * override that forces a component to ship even when the analyser would
148
- * elide it. It is the escape hatch for interactivity the analyser cannot
149
- * see statically (an OBSERVER that computes the tag it waits for, a
150
- * `:defined` rule in an external stylesheet outside the module graph, a
151
- * consumer reaching the element through a string selector). It
152
- * cannot rescue a component whose OWN registration tag is computed: the
153
- * scanner requires a literal tag (invariant 3), so such a component is
154
- * never in `components` at all and nothing consults this analyser for it.
155
- * Context-free: any value other than `false` ships.
156
- *
157
- * @type {readonly string[]}
158
- */
159
- export const INTERACTIVITY_STATIC_FIELDS = ['shadow', 'interactive'];
160
-
161
- /** Why each INTERACTIVITY_STATIC_FIELDS entry forces a ship (analyser reason). */
162
- const STATIC_FIELD_REASONS = {
163
- shadow: 'declares static shadow (DSD must re-attach on a client swap)',
164
- interactive: 'declares static interactive = true (author-declared ship override)',
165
- };
166
-
167
- /**
168
- * A bare `async render()` is NOT a standalone ship signal (#474). Its SSR
169
- * pass bakes the resolved data into the first paint, so a light-DOM async
170
- * component with no OTHER client signal renders identical HTML with or
171
- * without its JS and is elidable like any display-only component, saving a
172
- * module download plus a redundant on-hydration re-fetch. It ships only when
173
- * it ALSO carries an independent signal: an `@event`, a declared reactive
174
- * prop, a reactive import, a lifecycle hook (`renderFallback()` included, via
175
- * CLIENT_LIFECYCLE_HOOKS), a `<slot>`, `static shadow = true`, `static
176
- * interactive = true`, cross-module observation, or a transitively-reachable
177
- * interactive dep / child (the fixpoint's import + render rules). The two
178
- * per-class static-field carve-outs handled below are `static shadow`
179
- * (Declarative Shadow DOM only attaches during HTML parsing, so a streamed or
180
- * soft-navigated shadow component needs its module to re-run `attachShadow`)
181
- * and the explicit `static interactive = true` override (forces a ship when
182
- * the analyser cannot see a component's interactivity statically).
183
- */
184
-
185
- /**
186
- * Template binding-prefix classification, the anchor for the elision drift
187
- * guard. Every prefix core's renderers recognise (core's `BINDING_PREFIXES`)
188
- * is one of two kinds:
189
- * - SSR_DROPPED_PREFIXES: a CLIENT-BEHAVIOUR ship signal. The binding drops
190
- * at SSR and is wired only after hydration (`@event`), so a component whose
191
- * only interactivity is such a binding MUST ship.
192
- * - ROUND_TRIP_PREFIXES: an SSR-SAFE binding that survives into the served
193
- * HTML (`.prop` via `data-webjs-prop-*`, `?bool` as a boolean attribute),
194
- * so it is NOT a ship signal on its own and stays elidable.
195
- * `sigil-coverage.test.js` asserts these two lists PARTITION `BINDING_PREFIXES`
196
- * exactly (union equal, disjoint), so a new sigil cannot be added to the
197
- * renderers without being classified here. That closes the one interactivity
198
- * surface the prototype-introspection guard (`lifecycle-coverage.test.js`)
199
- * cannot see, because a sigil is template syntax, not a method or an export.
200
- */
201
- export const SSR_DROPPED_PREFIXES = ['@'];
202
- export const ROUND_TRIP_PREFIXES = ['.', '?'];
203
-
204
- /** Escape a single char for safe inclusion in a regex character class. */
205
- const escapeForCharClass = (c) => c.replace(/[\\\]^-]/g, '\\$&');
206
-
207
- /**
208
- * Match a client-behaviour binding (`@event=${...}`), a ship signal. The
209
- * prefix set is DERIVED from SSR_DROPPED_PREFIXES (not hardcoded), so a new
210
- * client-behaviour sigil added to core is detected here automatically once it
211
- * is classified above. (unquoted per invariant 4)
212
- */
213
- const EVENT_BINDING_RE = new RegExp(
214
- `[${SSR_DROPPED_PREFIXES.map(escapeForCharClass).join('')}][A-Za-z][\\w-]*\\s*=\\s*[\"']?\\$\\{`,
215
- );
216
-
217
- /** Match a `.onclick=${...}` (native event-handler property) binding. */
218
- const EVENT_PROP_RE = /\.on[a-z]+\s*=\s*["']?\$\{/;
219
-
220
- /**
221
- * Narrow dynamic-slot signals (#1015, retained under #1021's native-parity
222
- * record model). The old blanket rule shipped ANY component that merely
223
- * RENDERS a `<slot>`, but the SSR output already carries the placed children,
224
- * so a display-only slotted wrapper is byte-identical with or without its JS
225
- * and is elidable. What genuinely needs the client slot runtime is the
226
- * DYNAMIC READ surface: a `slotchange` listener or an `assignedNodes` /
227
- * `assignedElements` / `assignedSlot` read. Native WRITE liveness
228
- * (appendChild, slot= flips) is consumer-driven and usually forces the ship
229
- * through the consumer's own signals (a shipping component that renders the
230
- * tag, or an observation form); the remaining carve-out, a shipped script
231
- * reaching an elided host only via document.querySelector, is inert by
232
- * design with the `static interactive = true` escape hatch (see the
233
- * slot.js banner).
234
- */
235
- const SLOT_DYNAMIC_RE = /\bslotchange\b|\bassignedNodes\s*\(|\bassignedElements\s*\(|\bassignedSlot\b/;
236
-
237
- /** A `.server.{js,ts,mjs,mts}` file: a stub on the client, inert there. */
238
- const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
239
-
240
- /** Side-effect or named import of the client router subpath. */
241
- const CLIENT_ROUTER_SUBPATH_RE = /['"]@webjsdev\/core\/client-router['"]/;
242
- /**
243
- * Client-only named APIs from the `@webjsdev/core` main entry. Exported so
244
- * `test/elision/lifecycle-coverage.test.js` can guard it the way it guards
245
- * REACTIVE_IMPORTS: a router function the barrel re-exports but this list omits
246
- * is not recognized as client work, so a component using it is wrongly elided.
247
- */
248
- export const CLIENT_ROUTER_IMPORTS = ['navigate', 'enableClientRouter', 'disableClientRouter', 'revalidate', 'refreshPage', 'loadFrame'];
249
-
250
- /**
251
- * Identifiers that only exist in a browser; their presence means client work.
252
- * Both this and COMPONENT_CLIENT_GLOBAL_RE are tested against the
253
- * PLACEHOLDER redaction (`redactToPlaceholders`), never against raw or
254
- * comment-masked source: a quoted string body or a template literal's TEXT is
255
- * data, not a reference, so `'lucide-app-window'` must not read as `window`,
256
- * while code inside a `${...}` hole stays real code and still matches. A global
257
- * written in template text is SSR output (an inline `<script>` runs from the
258
- * served HTML whether or not the module ships), the reasoning #623 applied to
259
- * route modules, now extended to every module.
260
- */
261
- const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
262
-
263
- /**
264
- * Cross-module observation of a component's registration. A module that
265
- * observes another component's tag forces that component to register on the
266
- * client, so the observed component cannot be elided even when its own render
267
- * is display-only (eliding it drops its `customElements.define`, after which
268
- * the observation silently fails). These scan for the three statically visible
269
- * forms; the captured group is the observed tag (or class) name.
270
- * - `customElements.whenDefined('my-tag')` / `whenDefined("my-tag")`
271
- * - a CSS `my-tag:defined { … }` selector
272
- * - `x instanceof MyClass` (mapped back to a tag via the component's className)
273
- */
274
- const WHEN_DEFINED_RE = /\bwhenDefined\s*\(\s*['"`]([a-z][a-z0-9]*-[a-z0-9-]*)['"`]/g;
275
- const TAG_DEFINED_RE = /\b([a-z][a-z0-9]*-[a-z0-9-]*):defined\b/g;
276
- const INSTANCEOF_RE = /\binstanceof\s+([A-Z][A-Za-z0-9_$]*)/g;
277
- /** Same, for component source, minus `customElements` (the registration call
278
- * `customElements.define(...)` legitimately uses it and must not force ship). */
279
- const COMPONENT_CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\b/;
280
-
281
- /**
282
- * The same globals reached through a string key on the global object
283
- * (`globalThis['matchMedia']`, `self["document"]`). The global scan reads the
284
- * placeholder redaction, which blanks string literals, and `globalThis` itself
285
- * exists on the server, so this one form has to be read from the RAW source.
286
- */
287
- const COMPONENT_CLIENT_GLOBAL_KEY_RE = /\b(?:globalThis|self)\s*(?:\?\.)?\[\s*(['"`])(?:window|document|navigator|localStorage|sessionStorage|matchMedia|addEventListener)\1\s*\]/;
288
-
289
- /**
290
- * Constructors that produce inert DATA with no side effect, so a module-scope
291
- * `export const X = new Set([...])` (a lookup table, a compiled RegExp, a
292
- * parsed URL) is not client work and must not pin an importing page/layout
293
- * (#623). Any constructor NOT in this set (`new WebSocket()`, `new Worker()`,
294
- * `new EventSource()`, `new Audio()`) IS a side effect and still ships.
295
- */
296
- const PURE_DATA_CONSTRUCTORS = new Set([
297
- 'Set', 'Map', 'WeakSet', 'WeakMap', 'Date', 'RegExp', 'Array', 'Object',
298
- 'Number', 'String', 'Boolean', 'BigInt', 'Symbol',
299
- 'Error', 'TypeError', 'RangeError', 'SyntaxError',
300
- 'URL', 'URLSearchParams', 'ArrayBuffer', 'DataView',
301
- 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
302
- 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
303
- 'BigInt64Array', 'BigUint64Array',
304
- ]);
305
-
306
- /**
307
- * Marks where `hasModuleScopeSideEffect` dropped a top-level `{...}` body from
308
- * its depth-0 frame, so an arrow followed by a block body is not mistaken for
309
- * an expression body that runs on into the next statement. Removed again
310
- * before the call scan.
311
- */
312
- const BLOCK = '\x01';
313
-
314
- /** `function`, `function*`, `async function name` up to its parameter list's `(`. */
315
- const FUNCTION_PARAMS_RE = /(?<![\w$.])function\s*\*?\s*(?:[A-Za-z_$][\w$]*)?\s*\(/g;
316
-
317
- /**
318
- * The text before an arrow's `=>` when that arrow is a declarator initializer
319
- * (`const usd = (m) =>`, `let f = async x =>`, `, g = (a) =>`). The parameter
320
- * list must hold no parens, so a call in a parameter default
321
- * (`(a = init()) =>`) keeps its arrow unrecognised and still ships.
322
- */
323
- const ARROW_DECLARATOR_RE =
324
- /(?:(?<![\w$.])(?:const|let|var)\s+|,\s*)[A-Za-z_$][\w$]*\s*=\s*(?:async\s*)?(?:\([^()]*\)|[A-Za-z_$][\w$]*)\s*$/;
325
-
326
- /**
327
- * An expression body that ends this way cannot stop at the newline (a binary
328
- * operator, `?`, `:`, or a curried `=>`). Postfix `++` / `--` and a regex
329
- * literal's closing `/` end a statement, so neither is listed.
330
- */
331
- const BODY_CONTINUES_RE = /(?:=>|&&|\|\||\?\?|[?:=*%|&^<>]|(?<![+])\+|(?<![-])-)\s*$/;
332
-
333
- /** A line starting this way continues the previous one: no statement starts with it. */
334
- const LINE_CONTINUES_RE = /^\s*(?:\?|:|&&|\|\||\.(?![.\d])|\*|%|=(?!>)|\|)/;
335
-
336
- /**
337
- * Removes the two depth-0 shapes that look like calls and run nothing at module
338
- * load: a `function` declaration's parameter list (a default such as
339
- * `now = Date.now()` runs per call) and an arrow's expression body when the
340
- * arrow initializes a declarator (`const usd = (m) => Math.round(m) / MICROS`).
341
- *
342
- * The arrow body ends at a `,` or `;` at its own paren/bracket depth 0, at a
343
- * `)` / `]` closing something outside it, or at a newline once the body has
344
- * content, unless `BODY_CONTINUES_RE` / `LINE_CONTINUES_RE` prove the
345
- * expression carries on. Ending early is always safe, because whatever is left
346
- * stays in the frame and is still scanned; ending late could swallow a real
347
- * statement, so every rule errs towards ending.
348
- *
349
- * @param {string} frame depth-0 text, with `BLOCK` where a `{...}` was dropped
350
- * @returns {string | null} the frame without those parts, or null when a
351
- * parameter list does not close (the caller then ships)
352
- */
353
- function blankDeclarationOnlyCode(frame) {
354
- let out = '';
355
- let last = 0;
356
- for (const m of frame.matchAll(FUNCTION_PARAMS_RE)) {
357
- if (m.index < last) continue;
358
- const open = m.index + m[0].length - 1;
359
- let depth = 0;
360
- let close = -1;
361
- for (let j = open; j < frame.length; j++) {
362
- if (frame[j] === '(') depth++;
363
- else if (frame[j] === ')' && --depth === 0) { close = j; break; }
364
- }
365
- if (close < 0) return null;
366
- out += frame.slice(last, open + 1);
367
- last = close;
368
- }
369
- frame = out + frame.slice(last);
370
-
371
- out = '';
372
- let depth = 0;
373
- for (let i = 0; i < frame.length; i++) {
374
- const c = frame[i];
375
- if (c === '(' || c === '[') depth++;
376
- else if ((c === ')' || c === ']') && depth > 0) depth--;
377
- if (depth === 0 && c === '=' && frame[i + 1] === '>' && ARROW_DECLARATOR_RE.test(out)) {
378
- out += '=>';
379
- i = arrowBodyEnd(frame, i + 2) - 1;
380
- continue;
381
- }
382
- out += c;
383
- }
384
- return out;
385
- }
386
-
387
- /**
388
- * Index where an arrow's expression body starting at `start` ends (see
389
- * `blankDeclarationOnlyCode`).
390
- *
391
- * @param {string} frame
392
- * @param {number} start index just past the `=>`
393
- * @returns {number}
394
- */
395
- function arrowBodyEnd(frame, start) {
396
- let depth = 0;
397
- let i = start;
398
- for (; i < frame.length; i++) {
399
- const c = frame[i];
400
- if (c === '(' || c === '[') { depth++; continue; }
401
- if (c === ')' || c === ']') {
402
- if (depth === 0) return i;
403
- depth--;
404
- continue;
405
- }
406
- if (depth > 0) continue;
407
- if (c === ',' || c === ';') return i;
408
- if (c === '\n') {
409
- const body = frame.slice(start, i);
410
- if (!body.trim() || BODY_CONTINUES_RE.test(body)) continue;
411
- if (LINE_CONTINUES_RE.test(frame.slice(i + 1))) continue;
412
- return i;
413
- }
414
- }
415
- return i;
416
- }
417
-
418
- /**
419
- * Module-scope client work, detected by an ALLOWLIST of safe top-level forms
420
- * rather than a denylist of browser globals. A module that runs ANY code when
421
- * it loads (other than registering a component) does client work the render /
422
- * lifecycle / event checks would miss, so it must ship. Unlike a global
423
- * denylist this does not rot as browsers add APIs: a brand-new global trips it
424
- * automatically, because it is the CALL (or `new`, or dynamic `import()`), not
425
- * the global's name, that is recognised.
426
- *
427
- * Rule: at brace depth 0 (so code inside function / class / method bodies and
428
- * template holes, which do not run at module load, is ignored), the only
429
- * permitted forms are declarations (`import` / `export` / `function` / `class`
430
- * / `const` / `let` / `var`) and the component registration call
431
- * (`X.register(...)` / `customElements.define(...)`). Any other call, any
432
- * `new`, any dynamic `import(...)`, or top-level `await` means client work.
433
- *
434
- * Scans the redacted copy (strings / templates / comments blanked, regex
435
- * literals and nested `${...}` interpolation tracked by the lexer) so template
436
- * prose and JSDoc cannot trip it; quoted-string bodies, which redaction keeps
437
- * verbatim for other rules, are blanked here too so a string like `"foo()"` or
438
- * `"{"` is not read as a call and cannot unbalance the brace scan. The
439
- * unbalanced-brace and unterminated-string fallbacks below are defense in
440
- * depth: with the lexer tracking regex literals, neither should trigger on
441
- * valid code, but if either does the module ships rather than risk hiding
442
- * client work.
443
- *
444
- * PASS TYPE-ERASED SOURCE for a TypeScript module. Redaction blanks comments
445
- * and literals, NOT type syntax, and TS annotations are call-shaped often
446
- * enough to matter: `readonly (readonly [number, number, number])[]` is an
447
- * identifier immediately followed by `(`, so a module holding nothing but a
448
- * typed data literal reads as running code at module scope (#1423). The
449
- * analyser erases types before it calls this (`eraseTypesForScan`), so the
450
- * request path is covered; a DIRECT caller handing it `.ts` source as authored
451
- * is opting into that false positive.
452
- *
453
- * A `function` declaration's parameter list and the expression body of an
454
- * arrow that initializes a `const` / `let` / `var` declarator are dropped from
455
- * the frame first (`blankDeclarationOnlyCode`): `now = Date.now()` and
456
- * `(m) => Math.round(m) / MICROS` declare code, they do not run it.
457
- *
458
- * Over-detection is safe (a pure top-level helper call, or an arrow outside a
459
- * declarator whose body calls something, only ships). The accepted residual misses, all
460
- * contrived and structural (so they do not rot), are a call buried inside a
461
- * top-level object / array initializer or a destructuring default, and a
462
- * side-effecting tagged-template hole evaluated at module scope.
463
- *
464
- * @param {string} src module source, type-erased if it came from a TypeScript file
465
- * @param {string[]} [literals] the redaction's literal bodies, when already computed
466
- * @returns {boolean}
467
- */
468
- export function hasModuleScopeSideEffect(src, literals) {
469
- let redacted = src;
470
- if (!literals) {
471
- const r = redactToPlaceholders(src);
472
- redacted = r.redacted;
473
- literals = r.literals;
474
- }
475
- // Keep only depth-0 text (outside every `{}`). Skip quoted-string bodies so
476
- // braces/parens inside a string neither desync the depth nor read as a call.
477
- let depth = 0;
478
- let frame = '';
479
- for (let i = 0; i < redacted.length; i++) {
480
- const c = redacted[i];
481
- if (c === "'" || c === '"') {
482
- i++;
483
- let closed = false;
484
- while (i < redacted.length) {
485
- const d = redacted[i];
486
- if (d === '\\') { i += 2; continue; }
487
- if (d === '\n') break; // a real quoted string never spans a newline
488
- if (d === c) { closed = true; break; }
489
- i++;
490
- }
491
- // Not closed on its line is an unterminated string OR a regex literal
492
- // containing a quote that desynced the upstream redaction (regex bodies
493
- // are not tracked, so a stray quote shifts quote pairing). Either way the
494
- // lexical state is unreliable below here, so ship conservatively.
495
- if (!closed) return true;
496
- if (depth === 0) frame += "''";
497
- continue;
498
- }
499
- if (c === '{') { if (depth === 0) frame += BLOCK; depth++; continue; }
500
- if (c === '}') { if (depth > 0) depth--; continue; }
501
- if (depth === 0) frame += c;
502
- }
503
- // Unbalanced braces mean a construct we could not lexically resolve (a regex
504
- // literal with a stray brace is the common case). Ship rather than risk a
505
- // hidden top-level statement.
506
- if (depth !== 0) return true;
507
- // Parameter lists and declarator arrow bodies are declarations, not code
508
- // that runs at load.
509
- const declared = blankDeclarationOnlyCode(frame);
510
- if (declared === null) return true;
511
- frame = declared.replaceAll(BLOCK, '');
512
- // Optional-chaining call/index: `foo?.()`, `x?.[i]()` (the `?.` defeats the
513
- // identifier-before-paren match below).
514
- if (/\?\.\s*[([]/.test(frame)) return true;
515
- // Top-level `new X`: a pure-data builtin constructor (Set / Map / Date /
516
- // RegExp / typed array / URL / ...) is inert module data, not client work, so
517
- // it stays elidable (#623); any other constructor is a side effect. Catches
518
- // both `new X` and `new X(...)` (the constructor's own `(`, if present, is
519
- // then skipped in the call scan below).
520
- for (const nm of frame.matchAll(/(?<![.\w])new\s+([A-Za-z_$][\w$]*)/g)) {
521
- if (!PURE_DATA_CONSTRUCTORS.has(nm[1])) return true;
522
- }
523
- // Top-level `await`.
524
- if (/(?<![.\w])await\s/.test(frame)) return true;
525
- // A call: `(` preceded (ignoring whitespace) by an identifier, `)`, or `]`.
526
- const CALL_RE = /(?:([A-Za-z_$][\w$]*)|[)\]])\s*\(/g;
527
- // Identifiers that precede a `(` WITHOUT it being a call (keywords + a
528
- // `function` declaration's parameter list).
529
- const NOT_A_CALL = new Set([
530
- 'if', 'for', 'while', 'switch', 'catch', 'with', 'return', 'typeof',
531
- 'instanceof', 'void', 'delete', 'in', 'of', 'yield', 'do', 'else',
532
- 'case', 'default', 'function', 'await', 'new', 'async',
533
- ]);
534
- let m;
535
- while ((m = CALL_RE.exec(frame)) !== null) {
536
- const ident = m[1];
537
- if (ident === 'import') return true; // dynamic import()
538
- if (ident && NOT_A_CALL.has(ident)) continue;
539
- // A `function`-declaration parameter list (`function name(` or
540
- // `async function name(`) is not a call.
541
- if (ident && /\bfunction\s*\*?\s*$/.test(frame.slice(0, m.index))) continue;
542
- // The component registration call is the one permitted top-level call.
543
- if (ident === 'register' || ident === 'define') continue;
544
- // `extends WebComponent({ … })` is the declare-free reactive-prop DX (#597
545
- // / #599): a class-declaration construct, not an independent side-effecting
546
- // statement. Exempt it so a display-only factory-form component stays
547
- // elidable; a genuinely interactive one still ships via the per-class and
548
- // module-level signal checks below (events, slots, shadow, lifecycle, a
549
- // declared factory prop, a reactive import). Scoped to the `extends`
550
- // position, so a top-level `WebComponent(...)` call used anywhere else
551
- // still counts as a side effect (#604).
552
- if (ident === 'WebComponent' && /\bextends\s+$/.test(frame.slice(0, m.index))) continue;
553
- // A `new X(...)` constructor call: the `new`-prefixed cases were already
554
- // vetted above (a non-pure constructor returned true; a pure-data one is
555
- // inert), so its `(` here is not an independent side-effecting call.
556
- if (ident && /(?<![.\w])new\s+$/.test(frame.slice(0, m.index))) continue;
557
- return true; // any other top-level call
558
- }
559
- return false;
560
- }
561
-
562
- /** Match a whole-line SIDE-EFFECT import: `import 'pkg';` (no binding clause).
563
- * `\s*` before the quote (not `\s+`) so `import"pkg"` (no space) is caught;
564
- * a binding clause still fails because a non-quote follows `import`. A
565
- * trailing line comment is tolerated. */
566
- const SIDE_EFFECT_BARE_IMPORT_RE = /^\s*import\s*(['"])([^'"]+)\1\s*;?\s*(?:\/\/[^\n]*)?$/gm;
567
-
568
- /**
569
- * True if `src` imports the client router (the `/client-router` subpath, or
570
- * a router/nav API from the core main entry). A page or layout that does so
571
- * is enabling client-side navigation and must ship.
572
- * @param {string} src
573
- * @param {string[]} [literals]
574
- * @returns {boolean}
575
- */
576
- function importsClientRouter(src, literals) {
577
- let redacted = src;
578
- if (!literals) {
579
- const r = redactToPlaceholders(src);
580
- redacted = r.redacted;
581
- literals = r.literals;
582
- }
583
- if (literals.includes('@webjsdev/core/client-router')) return true;
584
- for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
585
- const clause = m[1];
586
- const spec = m[2];
587
- const idx = m[3];
588
- if (idx !== undefined) {
589
- const resolved = literals[parseInt(idx, 10)];
590
- if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
591
- continue;
592
- }
593
- }
594
- if (clause.startsWith('{')) {
595
- const names = clause.slice(1, -1).split(',').map((s) => s.trim().split(/\s+as\s+/)[0].trim());
596
- if (names.some((n) => CLIENT_ROUTER_IMPORTS.includes(n))) return true;
597
- } else if (clause.startsWith('*')) {
598
- const ns = clause.replace(/^\*\s+as\s+/, '').trim();
599
- if (!ns || !/^\w+$/.test(ns)) continue;
600
- for (const name of CLIENT_ROUTER_IMPORTS) {
601
- if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return true;
602
- }
603
- if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) return true;
604
- if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) return true;
605
- }
606
- }
607
- return false;
608
- }
609
-
610
- /**
611
- * True if `src` SIDE-EFFECT imports a bare npm package other than the (inert)
612
- * `@webjsdev/core` family (`import 'pkg'`, no binding). A side-effect import
613
- * runs the package's top-level code when the module loads, which is real
614
- * client work, so a module that has one must ship.
615
- *
616
- * A BINDING import (`import x from 'pkg'`) is deliberately NOT flagged: a page
617
- * function never runs on the client and a display-only component's render
618
- * never runs on the client when elided, so a package used only as a value in
619
- * that code never executes client-side and rides away when the module is
620
- * dropped. This is what lets an SSR-only dependency stay off the client
621
- * without a `.server.{js,ts}` wrapper.
622
- *
623
- * Residual edge: a package that self-registers on import (e.g. calls
624
- * `customElements.define` at module top level) imported via a binding clause
625
- * is NOT caught here, so eliding the importer drops that registration and the
626
- * element silently does not upgrade. This is the cross-module-registration
627
- * caveat documented in the skill's references/components.md and server AGENTS invariant 7;
628
- * the fix is `.server.{js,ts}` for genuinely server-only deps, or an
629
- * interactivity signal on the consumer. (It is not caught by an SSR crash:
630
- * the SSR `customElements` shim makes `define` a no-op server-side.)
631
- *
632
- * A `#` PATH-ALIAS specifier (#555, e.g. `import '#components/x.ts'`, the
633
- * idiomatic way a page/layout registers a component) is NOT a bare npm
634
- * package: Node's `package.json` "imports" resolves it, in the scaffold's
635
- * catch-all `"#*": "./*"` to a LOCAL file. Expanding it through the same
636
- * `expandImportAlias` the module graph uses, a local-resolving alias is
637
- * treated like a relative import (skipped); only an alias mapped to a real
638
- * bare package (`"#crypto": "crypto-browserify"`) falls through to the
639
- * package check. Without this, every `#`-imported component side effect
640
- * wrongly pinned its importing page/layout to the browser (#623). An
641
- * unexpandable `#` (no `appDir`, or no matching key) is assumed local,
642
- * since `#` is the local-subpath sigil and an unmapped `#` is a Node error,
643
- * not a package.
644
- * @param {string} src
645
- * @param {string} [appDir] app root, used to expand `#` path aliases
646
- * @param {string[]} [literals]
647
- * @returns {boolean}
648
- */
649
- function importsSideEffectNonCorePackage(src, appDir, literals) {
650
- let redacted = src;
651
- if (!literals) {
652
- const r = redactToPlaceholders(src);
653
- redacted = r.redacted;
654
- literals = r.literals;
655
- }
656
- for (const m of redacted.matchAll(SIDE_EFFECT_BARE_IMPORT_RE)) {
657
- let spec = m[2];
658
- const match = /^__STR_(\d+)__$/.exec(spec);
659
- if (match) {
660
- const idx = parseInt(match[1], 10);
661
- spec = literals[idx];
662
- }
663
- if (!spec) continue;
664
- if (spec.startsWith('.') || spec.startsWith('/')) continue; // relative / absolute
665
- if (spec.startsWith('#')) {
666
- const expanded = appDir ? expandImportAlias(spec, appDir) : null;
667
- if (expanded == null) continue; // unmapped alias: local
668
- if (expanded.startsWith('.') || expanded.startsWith('/')) continue; // resolves to a local file
669
- spec = expanded; // mapped to a bare spec: check it below
670
- }
671
- if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue; // inert framework / router handled separately
672
- if (spec.startsWith('node:')) continue; // server-only builtins
673
- return true;
674
- }
675
- return false;
676
- }
677
-
678
- /** Match a named-import clause from a `@webjsdev/core` specifier (or its placeholder). */
679
- const CORE_IMPORT_RE =
680
- /import\s+(?:type\s+)?(\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+['"`](@webjsdev\/core[^'"`]*|[^'"`]*\/__webjs\/core\/[^'"`]*|__STR_(\d+)__)['"`]/g;
681
-
682
- /**
683
- * Decide whether a component module is interactive (must ship) or
684
- * display-only (may be elided).
685
- *
686
- * @param {string} src raw module source
687
- * @returns {{ interactive: boolean, reason: string | null }}
688
- */
689
- export function analyzeComponentSource(src) {
690
- // A reactive primitive imported from core is the strongest signal and
691
- // is file-scoped, so check it first against the whole source.
692
- const reactiveImport = importsReactivePrimitive(src);
693
- if (reactiveImport) {
694
- return { interactive: true, reason: `imports reactive primitive '${reactiveImport}'` };
695
- }
696
-
697
- // Event bindings live inside `html` template bodies, which redaction
698
- // blanks. Scan the RAW source for them (over-detection is safe).
699
- if (EVENT_BINDING_RE.test(src)) {
700
- return { interactive: true, reason: 'template has an @event binding' };
701
- }
702
- if (EVENT_PROP_RE.test(src)) {
703
- return { interactive: true, reason: 'template sets a native event-handler property' };
704
- }
705
-
706
- // Dynamic slot usage (#1015). Merely RENDERING a `<slot>` no longer
707
- // ships: the SSR output carries the placed children, and with no
708
- // observers in the runtime a display-only slotted wrapper is
709
- // byte-identical with or without its JS. Only the dynamic slot surface
710
- // (slotchange, the assigned* reads) needs the client runtime.
711
- if (SLOT_DYNAMIC_RE.test(src)) {
712
- return { interactive: true, reason: 'reads the dynamic slot surface (slotchange / assignedNodes / assignedElements / assignedSlot)' };
713
- }
714
-
715
- // Top-level client work the render/lifecycle checks would miss: a
716
- // side-effect import of an npm package runs its code when the module
717
- // loads, and a browser global (window/document/…, excluding the
718
- // registration's customElements) means the module does client work even
719
- // if its render is otherwise pure. Eliding such a component would drop
720
- // that effect, so ship. (Mirrors the route-module analysis.) The global
721
- // scan reads the placeholder redaction, so a name inside a string or in
722
- // template text is not a reference, while one in a `${...}` hole still is.
723
- if (importsSideEffectNonCorePackage(src)) {
724
- return { interactive: true, reason: 'side-effect imports an npm package' };
725
- }
726
- if (COMPONENT_CLIENT_GLOBAL_RE.test(redactToPlaceholders(src).redacted) || COMPONENT_CLIENT_GLOBAL_KEY_RE.test(src)) {
727
- return { interactive: true, reason: 'references a browser global at module scope' };
728
- }
729
- if (hasModuleScopeSideEffect(src)) {
730
- return { interactive: true, reason: 'runs code at module scope (a top-level call, new, or dynamic import())' };
731
- }
732
-
733
- // The brace matcher counts depth reliably only on redacted source
734
- // (template `${...}` holes would otherwise unbalance it). Code-shaped
735
- // signals (lifecycle hooks, method calls, property descriptors) all
736
- // survive redaction, so extract and inspect class bodies from there.
737
- const bodies = extractWebComponentClassBodies(redactStringsAndTemplates(src));
738
- // A registered component with no recognisable `extends WebComponent`
739
- // body is a subclass of a custom base (or otherwise unparseable).
740
- // Cannot prove it inert, so ship it.
741
- if (bodies.length === 0) {
742
- return { interactive: true, reason: 'no parseable WebComponent class body' };
743
- }
744
-
745
- for (const { body, factoryArg } of bodies) {
746
- // Interactivity-signal static conventions (`static shadow` / `static
747
- // interactive = true`), driven by the INTERACTIVITY_STATIC_FIELDS registry
748
- // so a new convention is added in one place (see its doc for why each ships).
749
- for (const field of INTERACTIVITY_STATIC_FIELDS) {
750
- if (declaresStaticTrue(body, field)) {
751
- return { interactive: true, reason: STATIC_FIELD_REASONS[field] };
752
- }
753
- }
754
- for (const hook of CLIENT_LIFECYCLE_HOOKS) {
755
- // A client lifecycle hook as a method (`hook(`) OR as an arrow class
756
- // field (`hook = () =>`), which shadows the prototype method and still
757
- // runs. Either way the component is not inert.
758
- if (new RegExp(`\\b${hook}\\s*[=(]`).test(body)) {
759
- return { interactive: true, reason: `overrides lifecycle hook '${hook}'` };
760
- }
761
- }
762
- for (const call of CLIENT_METHOD_CALLS) {
763
- if (new RegExp(`\\b${call}\\s*\\(`).test(body)) {
764
- return { interactive: true, reason: `calls '${call}'` };
765
- }
766
- }
767
- if (hasReactiveProperty(body) || hasFactoryProperty(factoryArg)) {
768
- return {
769
- interactive: true,
770
- reason: 'declares a reactive property',
771
- };
772
- }
773
- }
774
-
775
- return { interactive: false, reason: null };
776
- }
777
-
778
- /**
779
- * True if a class body declares a `static <name>` whose value is not the
780
- * literal `false`. Backs the `static shadow` (DSD-on-client-swap) and
781
- * `static interactive = true` (author-declared ship override) ship signals.
782
- *
783
- * Conservative on anything it cannot evaluate: a getter form
784
- * (`static get <name>()`) ships, and a non-`false` value of any shape
785
- * (`true`, a variable, an expression) ships. Only an absent declaration or a
786
- * literal `= false` is cleared as inert, matching the light-DOM /
787
- * not-declared defaults.
788
- *
789
- * @param {string} classBody redacted class body
790
- * @param {string} name the static field name (`shadow` / `interactive`)
791
- * @returns {boolean}
792
- */
793
- function declaresStaticTrue(classBody, name) {
794
- // A getter cannot be evaluated statically; ship.
795
- if (new RegExp(`\\bstatic\\s+get\\s+${name}\\b`).test(classBody)) return true;
796
- const m = new RegExp(`\\bstatic\\s+${name}\\b\\s*=\\s*([^;\\n]*)`).exec(classBody);
797
- if (!m) return false; // not declared: the default (light DOM / not forced)
798
- return !/^false\b/.test(m[1].trim()); // `= false` is inert; anything else ships
799
- }
800
-
801
- /**
802
- * @param {string} src
803
- * @param {string[]} [literals]
804
- * @returns {string | null} the offending imported name, or null
805
- */
806
- function importsReactivePrimitive(src, literals) {
807
- let redacted = src;
808
- if (!literals) {
809
- const r = redactToPlaceholders(src);
810
- redacted = r.redacted;
811
- literals = r.literals;
812
- }
813
- for (const m of redacted.matchAll(CORE_IMPORT_RE)) {
814
- const clause = m[1];
815
- const spec = m[2];
816
- const idx = m[3];
817
- if (idx !== undefined) {
818
- const resolved = literals[parseInt(idx, 10)];
819
- if (!resolved || (!resolved.startsWith('@webjsdev/core') && !resolved.includes('/__webjs/core/'))) {
820
- continue;
821
- }
822
- }
823
- if (clause.startsWith('{')) {
824
- const names = clause
825
- .slice(1, -1)
826
- .split(',')
827
- .map((s) => s.trim().split(/\s+as\s+/)[0].trim())
828
- .filter(Boolean);
829
- for (const name of names) {
830
- if (REACTIVE_IMPORTS.has(name)) return name;
831
- }
832
- } else if (clause.startsWith('*')) {
833
- // Namespace import (`import * as core from '@webjsdev/core'`). We
834
- // cannot see which members are used from the clause, so look for a
835
- // reactive member reached through the namespace identifier `ns`
836
- // (which is a bare `\w+`, safe to interpolate into a RegExp).
837
- const ns = clause.replace(/^\*\s+as\s+/, '').trim();
838
- if (!ns || !/^\w+$/.test(ns)) continue;
839
- for (const name of REACTIVE_IMPORTS) {
840
- if (new RegExp(`\\b${ns}\\.${name}\\b`).test(redacted)) return name;
841
- }
842
- // Destructuring the namespace (`const { signal } = core`) or computed
843
- // access (`core['signal']`) hides which members are pulled. Ship.
844
- if (new RegExp(`(?:const|let|var)\\s*\\{[^}]*\\}\\s*=\\s*${ns}\\b`).test(redacted)) {
845
- return `${ns} (destructured namespace)`;
846
- }
847
- if (new RegExp(`\\b${ns}\\s*\\[`).test(redacted)) {
848
- return `${ns} (computed namespace access)`;
849
- }
850
- }
851
- }
852
- return null;
853
- }
854
-
855
- /**
856
- * True if the class body declares a `static properties` block with any
857
- * entry. A reactive property rides an attribute or a `.prop` binding, the
858
- * channel through which a parent pushes updates, which is client-side
859
- * reactivity. Internal state is a signal, not a declared property (the
860
- * factory refuses a `state` option, #1677), so every entry counts.
861
- *
862
- * Conservative on parse failure: a `static get properties()` accessor, a
863
- * non-literal assignment, a spread, or an unbalanced object literal returns
864
- * true (ship).
865
- *
866
- * @param {string} classBody
867
- * @returns {boolean}
868
- */
869
- function hasReactiveProperty(classBody) {
870
- // No reactive properties declared at all: nothing rides an attribute.
871
- if (!/\bstatic\s+(?:get\s+)?properties\b/.test(classBody)) return false;
872
- // A getter (`static get properties()`) or a non-literal assignment
873
- // (`= buildProps()`, `= SHARED_PROPS`) cannot be read, so ship
874
- // conservatively. `[^=]*` tolerates a TS type annotation between the name
875
- // and `=` without crossing the `=`.
876
- const m = /\bstatic\s+properties\b[^=]*=\s*\{/.exec(classBody);
877
- if (!m) return true;
878
- const objStart = m.index + m[0].length;
879
- const objEnd = matchClosingBrace(classBody, objStart);
880
- if (objEnd === -1) return true;
881
- return declaresAnyEntry(classBody.slice(objStart, objEnd));
882
- }
883
-
884
- /**
885
- * True if the class factory argument (`extends WebComponent({ … })`) declares
886
- * any reactive property. Each one rides an HTML attribute or a `.prop`
887
- * hydration binding, the channel that forces the component to ship.
888
- * Conservative on a spread or an unbalanced brace (ship).
889
- *
890
- * @param {string} factoryArg
891
- * @returns {boolean}
892
- */
893
- function hasFactoryProperty(factoryArg) {
894
- if (!factoryArg) return false;
895
- const objStart = factoryArg.indexOf('{');
896
- if (objStart === -1) return false;
897
- // matchClosingBrace starts at depth 1, so pass the index AFTER the brace.
898
- const objEnd = matchClosingBrace(factoryArg, objStart + 1);
899
- if (objEnd === -1) return true;
900
- return declaresAnyEntry(factoryArg.slice(objStart + 1, objEnd));
901
- }
902
-
903
- /**
904
- * True if a properties object body holds at least one entry. A spread
905
- * (`...BASE_PROPS`) can inject entries we cannot see, so it counts.
906
- *
907
- * @param {string} obj the body between the outer braces
908
- * @returns {boolean}
909
- */
910
- function declaresAnyEntry(obj) {
911
- if (/\.\.\./.test(obj)) return true;
912
- return topLevelPropertyValues(obj).length > 0;
913
- }
914
-
915
- /**
916
- * Yield the trimmed VALUE text of each top-level `key: value` entry in a
917
- * properties object body, splitting on depth-0 commas and respecting
918
- * nested braces, brackets, parens, strings, and templates.
919
- *
920
- * @param {string} obj the body between the outer braces
921
- * @returns {string[]}
922
- */
923
- function topLevelPropertyValues(obj) {
924
- /** @type {string[]} */
925
- const values = [];
926
- let depth = 0;
927
- let str = '';
928
- let colonAt = -1;
929
- const push = (end) => {
930
- if (colonAt === -1) return;
931
- values.push(obj.slice(colonAt + 1, end).trim());
932
- };
933
- for (let i = 0; i < obj.length; i++) {
934
- const c = obj[i];
935
- if (str) {
936
- if (c === '\\') { i++; continue; }
937
- if (c === str) str = '';
938
- continue;
939
- }
940
- if (c === "'" || c === '"' || c === '`') { str = c; continue; }
941
- if (c === '{' || c === '[' || c === '(') { depth++; continue; }
942
- if (c === '}' || c === ']' || c === ')') { depth--; continue; }
943
- if (depth === 0) {
944
- if (c === ':' && colonAt === -1) colonAt = i;
945
- else if (c === ',') {
946
- push(i);
947
- colonAt = -1;
948
- }
949
- }
950
- }
951
- push(obj.length);
952
- return values;
953
- }
954
-
955
- /**
956
- * Custom-element tag names a module references in its templates. A tag
957
- * must contain a hyphen (HTML custom-element spec), which excludes
958
- * native elements. Over-detection is safe: it only forces more modules
959
- * to ship.
960
- *
961
- * @param {string} src raw module source
962
- * @returns {Set<string>}
963
- */
964
- export function extractRenderedTags(src) {
965
- /** @type {Set<string>} */
966
- const tags = new Set();
967
- // Mask comments first so a `<some-tag>` written in a doc comment is not read
968
- // as a rendered tag (#179). String and template content is kept, so real tags
969
- // inside `html` templates are still found.
970
- const masked = maskComments(src);
971
- const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
972
- let m;
973
- while ((m = re.exec(masked)) !== null) tags.add(m[1]);
974
- return tags;
975
- }
976
-
977
- /**
978
- * The extensions that can carry TypeScript type syntax INTO this analysis.
979
- *
980
- * Derive it from what actually reaches `allFiles`, which is seeded from three
981
- * places with three different filters: the component set from `scanComponents`
982
- * (`/\.m?[jt]sx?$/`), the route modules from the router (`js|mjs|ts|mts`), and
983
- * the module graph from its walker (`/\.(js|ts|mjs|mts)$/`). The COMPONENT set
984
- * is the widest and it does not pass through the other two, so a `.tsx`
985
- * component arrives here whatever the walker and the router admit. Hence
986
- * `.tsx`, even though nothing in WebJs serves one: leaving it out is how the
987
- * #1423 false positive comes back for exactly the file class no other filter
988
- * would have let through. `.cts` is absent because no filter admits it.
989
- *
990
- * The rule to hold, if any of those three filters moves: this set is every
991
- * TypeScript-carrying extension in their UNION. Narrowing it to the servable
992
- * set instead is a change that looks tighter and silently un-erases files.
993
- */
994
- const TS_SOURCE_RE = /\.m?tsx?$/;
995
-
996
- /**
997
- * Per-file memo of {@link eraseTypesForScan}, holding the source it was derived
998
- * from so a hit is proven rather than assumed. One entry per file, so it is
999
- * bounded by the app's file count and needs no eviction policy; a deleted file
1000
- * leaves one dead entry until the process ends, which is cheaper than tracking
1001
- * liveness for it.
1002
- *
1003
- * @type {Map<string, { src: string, out: string }>}
1004
- */
1005
- const STRIP_CACHE = new Map();
1006
-
1007
- /**
1008
- * Return `src` with its TYPE syntax erased, so every scan below reads the code
1009
- * that actually RUNS rather than the code as authored (#1423).
1010
- *
1011
- * The scans are lexical heuristics, and TypeScript annotations are syntax the
1012
- * heuristics were never designed for: `readonly (readonly [number, number,
1013
- * number])[]` is an identifier immediately followed by `(`, which the
1014
- * top-level-call matcher in `hasModuleScopeSideEffect` reads as a call, so a
1015
- * module holding nothing but a typed data literal was classified as running
1016
- * code at module scope and pinned every route module that imported it. Type
1017
- * syntax is erased before anything runs, so it can never be a runtime signal,
1018
- * and the only sound reading of it is none at all.
1019
- *
1020
- * This uses the framework's own stripper rather than a lexical annotation
1021
- * matcher: it is the same erasure the browser is served, so the analyser and
1022
- * the runtime agree by construction, where a hand-rolled matcher would meet
1023
- * generics, `as`, `satisfies`, and conditional types and get some of them
1024
- * wrong. It is position-preserving whitespace replacement, so every offset,
1025
- * line, and column the other scans depend on is unchanged.
1026
- *
1027
- * A non-TypeScript file is returned untouched (there is nothing to erase, and
1028
- * running a TS parser over one buys only new ways to fail). A PER-FILE strip
1029
- * failure also returns the source untouched, which is the pre-#1423 behaviour
1030
- * and therefore the conservative direction: the scans then over-detect at
1031
- * worst. Two things take that path, both silent because both are one file's
1032
- * problem. A non-erasable module, which invariant 10 forbids and `webjs check`
1033
- * catches at edit time, so it is a broken app rather than a supported one. And
1034
- * a `.tsx` holding real JSX, which the stripper parses as non-JSX TypeScript
1035
- * and rejects, so a JSX component is scanned as authored while a `.tsx` that is
1036
- * only TypeScript is erased normally.
1037
- *
1038
- * A missing stripper BACKEND is the failure that does not stay silent, and it
1039
- * is why the backend is resolved once by the caller rather than per file. It
1040
- * means no TypeScript in the app can be erased (a runtime with neither the
1041
- * built-in nor `amaro`), so every module is scanned as authored and the #1423
1042
- * verdict comes back app-wide. Swallowing that alongside a syntax rejection
1043
- * would make an app-wide regression indistinguishable from one broken file, so
1044
- * `resolveScanStripper` warns once and this returns the source untouched.
1045
- *
1046
- * Results are memoized per file and validated against the exact source, since
1047
- * this runs on every dev rebuild and stripping costs roughly 50x the read it
1048
- * sits beside. Validating on content rather than mtime means a same-mtime
1049
- * rewrite cannot serve a stale strip.
1050
- *
1051
- * @param {import('./ts-strip.js').Stripper | null} stripper resolved once per run, null when unavailable
1052
- * @param {string} file absolute path, the cache key and the extension source
1053
- * @param {string} src
1054
- * @returns {string}
1055
- */
1056
- function eraseTypesForScan(stripper, file, src) {
1057
- if (!stripper || !TS_SOURCE_RE.test(file)) return src;
1058
- const hit = STRIP_CACHE.get(file);
1059
- if (hit && hit.src === src) return hit.out;
1060
- let out;
1061
- try {
1062
- out = stripper.fn(src);
1063
- } catch {
1064
- out = src;
1065
- }
1066
- STRIP_CACHE.set(file, { src, out });
1067
- return out;
1068
- }
1069
-
1070
- /** Whether {@link resolveScanStripper} has already reported a missing backend. */
1071
- let warnedNoStripper = false;
1072
-
1073
- /**
1074
- * Resolve the TypeScript stripper backend once for a whole analysis run, or
1075
- * `null` when this runtime has none.
1076
- *
1077
- * `analyzeElision` runs outside the dev request handler too (`webjs check`,
1078
- * `webjs elision`), and `ensureStripper` is called on the dev path only, so
1079
- * this is where the no-backend case surfaces for the other entry points. It
1080
- * warns once per process rather than per file: the condition is a property of
1081
- * the runtime, so repeating it once per module would bury it.
1082
- *
1083
- * @returns {Promise<import('./ts-strip.js').Stripper | null>}
1084
- */
1085
- async function resolveScanStripper() {
1086
- try {
1087
- return await ensureStripper();
1088
- } catch (e) {
1089
- if (!warnedNoStripper) {
1090
- warnedNoStripper = true;
1091
- console.warn(
1092
- '[webjs] elision analysis could not resolve a TypeScript stripper, so type syntax ' +
1093
- 'is not erased before the scans read a module. A type annotation can then read as ' +
1094
- 'module-scope work, which ships modules that would otherwise be elided. Underlying: ' +
1095
- (e && e.message ? e.message : String(e)),
1096
- );
1097
- }
1098
- return null;
1099
- }
1100
- }
1101
-
1102
- /**
1103
- * Compute the set of component FILES whose browser download can be
1104
- * elided. A file is elidable only when every component it defines is
1105
- * display-only AND it is not pulled into the client by an interactive
1106
- * component (rendered by, or imported by, a shipping module).
1107
- *
1108
- * Two propagation rules iterate to a fixpoint:
1109
- * - render rule: a shipping component that can emit `<child-tag>` on a
1110
- * client re-render forces the child to ship. The tags a component can
1111
- * emit are not only those in its own template but also those returned
1112
- * by the template helpers it imports (the documented `lib/utils/ui.ts`
1113
- * pattern), so the rule scans the component's transitive app-internal
1114
- * import closure, not just its own source.
1115
- * - import rule: a component that imports a shipping component module
1116
- * ships too (matches the issue's transitive criterion; conservative).
1117
- *
1118
- * @param {Array<{ tag: string, file: string }>} components
1119
- * @param {import('./module-graph.js').ModuleGraph} moduleGraph
1120
- * @param {(file: string) => Promise<string>} readFileFn
1121
- * @param {string} [appDir] app root; enables the helper-closure render rule
1122
- * @returns {Promise<Set<string>>} absolute paths of elidable component files
1123
- */
1124
- export async function computeElidableComponents(components, moduleGraph, readFileFn, appDir) {
1125
- const { elidableComponents } = await analyzeElision(components, [], moduleGraph, readFileFn, appDir);
1126
- return elidableComponents;
1127
- }
1128
-
1129
- /**
1130
- * Per-file scan results, keyed by path and validated on the exact source and
1131
- * the two facts about the file the scan depends on (#1575). The scan reads one
1132
- * file and nothing else, so an unchanged file never needs it again; on a large
1133
- * app it was most of the elision pass (about 350 ms of Crisp's 450 ms rebuild),
1134
- * repeated in full after every edit.
1135
- * @type {Map<string, { src: string, isRoute: boolean, isComponent: boolean, result: ReturnType<typeof scanFileUncached> }>}
1136
- */
1137
- const SCAN_RESULT_CACHE = new Map();
1138
-
1139
- /**
1140
- * The per-file half of `analyzeElision`: every signal it reads off ONE file.
1141
- * @param {string} file
1142
- * @param {string} src the raw source as read
1143
- * @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx
1144
- */
1145
- function scanFileForElision(file, src, ctx) {
1146
- const hit = SCAN_RESULT_CACHE.get(file);
1147
- if (hit && hit.src === src && hit.isRoute === ctx.isRoute && hit.isComponent === ctx.isComponent) return hit.result;
1148
- const result = scanFileUncached(file, src, ctx);
1149
- SCAN_RESULT_CACHE.set(file, { src, isRoute: ctx.isRoute, isComponent: ctx.isComponent, result });
1150
- return result;
1151
- }
1152
-
1153
- /**
1154
- * @param {string} file
1155
- * @param {string} src
1156
- * @param {{ isRoute: boolean, isComponent: boolean, appDir: string, scanStripper: any }} ctx
1157
- */
1158
- function scanFileUncached(file, src, { isRoute, isComponent, appDir, scanStripper }) {
1159
- // Erase type syntax first, so every scan below reads the code that RUNS
1160
- // (#1423). This is the single point where the analyser reads a file, so
1161
- // doing it here covers the module-scope, template, import, and component
1162
- // scans at once.
1163
- src = eraseTypesForScan(scanStripper, file, src);
1164
- // Mask comments once for every signal scan below (#179): a `<tag>`, an
1165
- // `@event`, a browser global, an `import`, or a `whenDefined` written in a
1166
- // comment must not register as a real signal. String and template content
1167
- // is kept, so a real rendered tag, a real `@click=${}` in an html template,
1168
- // and a real `whenDefined('tag')` (the tag rides a string) still match. The
1169
- // browser-global scans are the exception: they read `redacted` below, since
1170
- // a global's name inside a string or template text is data, not a reference.
1171
- // (`importsSideEffectNonCorePackage` / `hasModuleScopeSideEffect` /
1172
- // `analyzeComponentSource` also redact strings/templates internally; running
1173
- // them on the comment-masked source just additionally drops comment prose.)
1174
- const masked = maskComments(src);
1175
- const tags = extractRenderedTags(masked);
1176
- const { redacted, literals } = redactToPlaceholders(src);
1177
- // A page/layout NEVER hydrates (#605), so its `html` TEMPLATE content is
1178
- // SSR output, not module client work: an inline `<script>`'s browser
1179
- // globals run from the rendered HTML, and a page-template `@event` is
1180
- // dropped at SSR. For a route module, scan the `@event` signals on the
1181
- // template-redacted source so they do not pin the module; a non-route
1182
- // module keeps its template text, because a helper's `@click=${}` binds when
1183
- // a shipping component renders it. The browser-global scan reads `redacted`
1184
- // for EVERY module: a global's name in a string or template text is data
1185
- // (an inline `<script>` runs from the HTML, a `'lucide-app-window'` class
1186
- // is a class), while a genuine `document.x` in code, including code in a
1187
- // `${...}` hole, still flags (#623).
1188
- // The import-based checks stay on `redacted`: a real `import 'pkg'` side
1189
- // effect DOES run when a page/layout module loads in the browser, and
1190
- // `importsSideEffectNonCorePackage` itself skips local `#`-alias imports.
1191
- const templateScan = isRoute ? redacted : masked;
1192
- const clientGlobalOrBare = EVENT_BINDING_RE.test(templateScan) || EVENT_PROP_RE.test(templateScan) ||
1193
- importsSideEffectNonCorePackage(redacted, appDir, literals) || CLIENT_GLOBAL_RE.test(redacted) ||
1194
- hasModuleScopeSideEffect(redacted, literals);
1195
- let interactive = false;
1196
- /** @type {string | null} */
1197
- let interactiveReason = null;
1198
- if (isComponent) {
1199
- const v = analyzeComponentSource(masked);
1200
- if (v.interactive) { interactive = true; interactiveReason = v.reason ?? null; }
1201
- }
1202
- return {
1203
- tags,
1204
- reactive: importsReactivePrimitive(redacted, literals),
1205
- clientRouter: importsClientRouter(redacted, literals),
1206
- clientGlobalOrBare,
1207
- interactive,
1208
- interactiveReason,
1209
- observedTags: [...[...masked.matchAll(WHEN_DEFINED_RE)].map((m) => m[1]), ...[...masked.matchAll(TAG_DEFINED_RE)].map((m) => m[1])],
1210
- observedClasses: [...masked.matchAll(INSTANCEOF_RE)].map((m) => m[1]),
1211
- };
1212
- }
1213
-
1214
- /**
1215
- * Full elision analysis: which display-only COMPONENT modules can be elided,
1216
- * AND which page/layout ROUTE modules are inert (do no client work) and can
1217
- * therefore be dropped from the client boot script entirely. The second is
1218
- * the progressive-enhancement completion of the first: a route whose whole
1219
- * subtree is inert ships zero JavaScript.
1220
- *
1221
- * A route module is inert only when neither it nor its effective client
1222
- * closure (the import graph with elided components and `.server` stubs
1223
- * skipped, since those never run on the client) touches anything
1224
- * client-effecting: a reactive primitive, the client router, an `@event` /
1225
- * `.on*` binding, a non-core npm import (which may self-execute), a client
1226
- * global (`window`, `document`, …), or a shipping component. Anything
1227
- * ambiguous or unreadable keeps shipping.
1228
- *
1229
- * @param {Array<{ tag: string, file: string, className?: string }>} components
1230
- * @param {string[]} routeModules absolute paths of page + layout files
1231
- * @param {import('./module-graph.js').ModuleGraph} moduleGraph
1232
- * @param {(file: string) => Promise<string>} readFileFn
1233
- * @param {string} [appDir]
1234
- * @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }>, componentVerdicts: Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }> }>}
1235
- */
1236
- export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
1237
- /** @type {Set<string>} */
1238
- const componentFiles = new Set();
1239
- /** @type {Map<string, string>} */
1240
- const tagToFile = new Map();
1241
- /** @type {Map<string, string>} className -> file, for instanceof observation */
1242
- const classToFile = new Map();
1243
- for (const c of components) {
1244
- componentFiles.add(c.file);
1245
- tagToFile.set(c.tag, c.file);
1246
- if (c.className) classToFile.set(c.className, c.file);
1247
- }
1248
-
1249
- /** @type {Set<string>} */
1250
- const mustShip = new Set();
1251
- /**
1252
- * WHY each shipping component ships (#1308), so the app-level report can name
1253
- * the evidence instead of only the verdict. Written alongside every
1254
- * `mustShip.add` of a COMPONENT file; FIRST write wins, matching the
1255
- * first-match convention the analyser uses everywhere else.
1256
- * @type {Map<string, { evidence: string, by: string|null, reason: string|null }>}
1257
- */
1258
- const shipEvidence = new Map();
1259
- /**
1260
- * @param {string} file the component file that ships
1261
- * @param {string} evidence one of own | observed | closure | render | import | unreadable
1262
- * @param {string|null} by the module that forced it, where one exists
1263
- * @param {string|null} [reason]
1264
- */
1265
- const noteShip = (file, evidence, by, reason) => {
1266
- if (!shipEvidence.has(file)) shipEvidence.set(file, { evidence, by, reason: reason ?? null });
1267
- };
1268
- /** @type {Map<string, Set<string>>} */
1269
- const fileTags = new Map();
1270
- /** @type {Set<string>} modules importing a reactive primitive from core */
1271
- const reactiveFiles = new Set();
1272
- /** @type {Set<string>} modules enabling the client router */
1273
- const clientRouterFiles = new Set();
1274
- /** @type {Set<string>} modules with an @event/.on* binding, a non-core npm import, or a client global */
1275
- const clientGlobalOrBareFiles = new Set();
1276
- /** @type {Set<string>} */
1277
- const serverFiles = new Set();
1278
- /** @type {Set<string>} component files forced to ship because some module
1279
- * observes their registration (whenDefined / :defined / instanceof). */
1280
- const observedComponentFiles = new Set();
1281
- /** @type {Map<string, string>} observed component file -> the module observing it (#1308) */
1282
- const observedBy = new Map();
1283
-
1284
- /** @type {Set<string>} */
1285
- const allFiles = new Set(componentFiles);
1286
- /** @type {Set<string>} page/layout modules: never hydrate, so template content is SSR output. */
1287
- const routeModuleSet = new Set(routeModules);
1288
- for (const f of routeModules) allFiles.add(f);
1289
- for (const [k, vs] of moduleGraph) {
1290
- if (!appDir || k.startsWith(appDir)) allFiles.add(k);
1291
- for (const v of vs) if (!appDir || v.startsWith(appDir)) allFiles.add(v);
1292
- }
1293
-
1294
- // Resolve the stripper ONCE for the run: the backend is a property of the
1295
- // runtime, not of a file, and the no-backend case must be reported rather
1296
- // than swallowed per module (see `eraseTypesForScan`).
1297
- const scanStripper = await resolveScanStripper();
1298
-
1299
- for (const file of allFiles) {
1300
- if (SERVER_FILE_RE.test(file)) { serverFiles.add(file); continue; }
1301
- let src;
1302
- try { src = await readFileFn(file); }
1303
- catch {
1304
- // A component file we cannot read ships conservatively; a helper we
1305
- // cannot read simply contributes no tags.
1306
- if (componentFiles.has(file)) { mustShip.add(file); noteShip(file, 'unreadable', null, null); }
1307
- continue;
1308
- }
1309
- if (typeof src !== 'string') continue;
1310
- // Erase type syntax first, so every scan below reads the code that RUNS
1311
- // (#1423). This is the single point where the analyser reads a file, so
1312
- // doing it here covers the module-scope, template, import, and component
1313
- // scans at once.
1314
- const scan = scanFileForElision(file, src, {
1315
- isRoute: routeModuleSet.has(file),
1316
- isComponent: componentFiles.has(file),
1317
- appDir,
1318
- scanStripper,
1319
- });
1320
- fileTags.set(file, scan.tags);
1321
- if (scan.reactive) reactiveFiles.add(file);
1322
- if (scan.clientRouter) clientRouterFiles.add(file);
1323
- if (scan.clientGlobalOrBare) clientGlobalOrBareFiles.add(file);
1324
- if (scan.interactive) { mustShip.add(file); noteShip(file, 'own', null, scan.interactiveReason); }
1325
- // Cross-module registration observation (#169): if THIS module observes
1326
- // another component's tag, that component must register client-side, so
1327
- // it cannot be elided. Map each observed tag/class back to its component
1328
- // file. Resolution against tagToFile / classToFile happens here, outside
1329
- // the per-file cache, because the component set is not a property of this
1330
- // file. Verdict-safe: only ever forces MORE components to ship.
1331
- for (const tag of scan.observedTags) {
1332
- const f = tagToFile.get(tag); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1333
- }
1334
- for (const cls of scan.observedClasses) {
1335
- const f = classToFile.get(cls); if (f) { observedComponentFiles.add(f); if (!observedBy.has(f)) observedBy.set(f, file); }
1336
- }
1337
- }
1338
-
1339
- // Force every observed component to ship before the fixpoint runs, so the
1340
- // render/import rules propagate from it too. Dynamic tag strings and external
1341
- // (non graph-reachable) stylesheets remain an author-facing caveat, since
1342
- // static analysis cannot see them.
1343
- for (const f of observedComponentFiles) { mustShip.add(f); noteShip(f, 'observed', observedBy.get(f) ?? null, null); }
1344
-
1345
- // Reverse import edges (who imports each file), built once from the graph.
1346
- // Drives both the closure-client-work reachability below and the fixpoint's
1347
- // import rule, each in O(N+E) rather than a per-component closure walk.
1348
- /** @type {Map<string, Set<string>>} */
1349
- const importersOf = new Map();
1350
- for (const [file, deps] of moduleGraph) {
1351
- for (const dep of deps) {
1352
- let set = importersOf.get(dep);
1353
- if (!set) { set = new Set(); importersOf.set(dep, set); }
1354
- set.add(file);
1355
- }
1356
- }
1357
-
1358
- // Files that reach client work through their imports: a reactive primitive,
1359
- // the client router, a browser global, an `@event` binding, or a side-effect
1360
- // npm import. Computed by propagating BACKWARD from the client-effecting
1361
- // files through the reverse edges, stopping at `.server` files (the forward
1362
- // closure skips them, since the browser only ever sees their stub). This is
1363
- // O(N+E) instead of a full transitive-closure walk per component, which was
1364
- // the second O(N^2) on a deep component chain.
1365
- /** @type {Set<string>} */
1366
- const reachesClientWork = new Set();
1367
- {
1368
- const work = [];
1369
- for (const f of [...reactiveFiles, ...clientRouterFiles, ...clientGlobalOrBareFiles]) {
1370
- if (!reachesClientWork.has(f)) { reachesClientWork.add(f); work.push(f); }
1371
- }
1372
- while (work.length) {
1373
- const node = /** @type {string} */ (work.pop());
1374
- const importers = importersOf.get(node);
1375
- if (!importers) continue;
1376
- for (const imp of importers) {
1377
- if (serverFiles.has(imp)) continue; // a server file blocks the forward closure
1378
- if (!reachesClientWork.has(imp)) { reachesClientWork.add(imp); work.push(imp); }
1379
- }
1380
- }
1381
- }
1382
-
1383
- // Ship any component whose transitive import closure does client work (the
1384
- // helper-imports-a-signal case): it ships if any of its direct, non-server
1385
- // deps reaches client work. Equivalent to the old per-component closure walk
1386
- // (a component that is itself client-effecting is already shipping via
1387
- // analyzeComponentSource), but linear.
1388
- if (appDir) {
1389
- for (const file of componentFiles) {
1390
- if (mustShip.has(file)) continue;
1391
- const deps = moduleGraph.get(file);
1392
- if (!deps) continue;
1393
- for (const dep of deps) {
1394
- if (serverFiles.has(dep)) continue;
1395
- if (reachesClientWork.has(dep)) { mustShip.add(file); noteShip(file, 'closure', dep, null); break; }
1396
- }
1397
- }
1398
- }
1399
-
1400
- // Tags each component can emit on a client re-render: its OWN rendered tags
1401
- // plus tags returned by the template HELPERS it imports (the lib/utils/ui.ts
1402
- // pattern). The closure deliberately SKIPS component and server files:
1403
- // importing another component does not mean rendering its tag (a rendered tag
1404
- // is already in the importer's own source via extractRenderedTags), and a
1405
- // server file renders nothing client-side. Following component edges here
1406
- // makes the closure O(N^2) in time AND memory on a deep component chain
1407
- // (every component would accumulate every downstream tag); helper-only
1408
- // closures keep it linear. This is verdict-SAFE: it never elides a component
1409
- // that the render rule requires to ship (so it can never break a page), and
1410
- // it actually elides strictly MORE in some shapes, because following
1411
- // component edges made the old version over-ship components whose tags
1412
- // nothing actually renders client-side.
1413
- const tagClosureSkip = new Set([...componentFiles, ...serverFiles]);
1414
- /** @type {Map<string, Set<string>>} */
1415
- const emittableTags = new Map();
1416
- for (const file of componentFiles) {
1417
- const tags = new Set(fileTags.get(file));
1418
- const deps = appDir ? transitiveDeps(moduleGraph, [file], appDir, tagClosureSkip) : [];
1419
- for (const dep of deps) {
1420
- const dt = fileTags.get(dep);
1421
- if (dt) for (const t of dt) tags.add(t);
1422
- }
1423
- emittableTags.set(file, tags);
1424
- }
1425
-
1426
- // Fixpoint by worklist (render rule + import rule), O(N+E). Seed with the
1427
- // components already known to ship; each shipping node forces the components
1428
- // whose tags it can emit (render rule) and the COMPONENT files that import it
1429
- // (import rule). Replaces the old iterate-until-stable double loop, which was
1430
- // O(N^2) per pass and O(N^3) / out-of-memory on a deep render chain.
1431
- const queue = [...mustShip];
1432
- while (queue.length) {
1433
- const node = /** @type {string} */ (queue.pop());
1434
- const tags = emittableTags.get(node);
1435
- if (tags) {
1436
- for (const tag of tags) {
1437
- const childFile = tagToFile.get(tag);
1438
- if (childFile && !mustShip.has(childFile)) { mustShip.add(childFile); noteShip(childFile, 'render', node, null); queue.push(childFile); }
1439
- }
1440
- }
1441
- const importers = importersOf.get(node);
1442
- if (importers) {
1443
- for (const imp of importers) {
1444
- if (!componentFiles.has(imp)) continue; // import rule is component -> component
1445
- if (!mustShip.has(imp)) { mustShip.add(imp); noteShip(imp, 'import', node, null); queue.push(imp); }
1446
- }
1447
- }
1448
- }
1449
-
1450
- /** @type {Set<string>} */
1451
- const elidableComponents = new Set();
1452
- for (const file of componentFiles) {
1453
- if (!mustShip.has(file)) elidableComponents.add(file);
1454
- }
1455
-
1456
- // A file does client work if it ships as a component, or itself reaches a
1457
- // reactive primitive / client router / event binding / non-core npm import
1458
- // / client global.
1459
- const isClientEffecting = (file) =>
1460
- (componentFiles.has(file) && mustShip.has(file)) ||
1461
- reactiveFiles.has(file) ||
1462
- clientRouterFiles.has(file) ||
1463
- clientGlobalOrBareFiles.has(file);
1464
-
1465
- // A human reason for WHY a file does client work, for the advisory that
1466
- // names why a page/layout ships (#646). Mirrors the isClientEffecting
1467
- // branches, most specific first.
1468
- const clientEffectReason = (file) => {
1469
- if (componentFiles.has(file) && mustShip.has(file)) return 'is an interactive component';
1470
- if (clientRouterFiles.has(file)) return 'imports the client router';
1471
- if (reactiveFiles.has(file)) return 'imports a reactive primitive (signal / computed / watch)';
1472
- if (clientGlobalOrBareFiles.has(file)) return 'references a browser global at module scope, runs code at module scope, or has a bare side-effect import';
1473
- return 'does client work';
1474
- };
1475
-
1476
- // Per-component verdict plus the evidence that produced it (#1308). Assembled
1477
- // ENTIRELY from what the passes above already computed: nothing is re-read and
1478
- // nothing is re-analysed, so this is a projection, not a second analysis. A
1479
- // file may register more than one tag, so rows are keyed by FILE with a
1480
- // sorted tag list.
1481
- /** @type {Map<string, { tags: string[], className: string|null, shipped: boolean, evidence: string|null, by: string|null, reason: string|null }>} */
1482
- const componentVerdicts = new Map();
1483
- for (const c of components) {
1484
- let row = componentVerdicts.get(c.file);
1485
- if (!row) {
1486
- const shipped = mustShip.has(c.file);
1487
- const ev = shipped ? shipEvidence.get(c.file) : undefined;
1488
- row = {
1489
- tags: [], className: c.className ?? null, shipped,
1490
- evidence: ev ? ev.evidence : null,
1491
- by: ev ? ev.by : null,
1492
- // An ELIDED component carries no reason on purpose: elision is the
1493
- // ABSENCE of every signal, so there is no positive fact to report.
1494
- // A shipping component with no evidence is only reachable if a future
1495
- // rule adds to `mustShip` without calling `noteShip`, so report null
1496
- // rather than a wrong claim (sigil-coverage guards against that drift).
1497
- reason: !shipped || !ev ? null
1498
- : ev.evidence === 'own' ? ev.reason
1499
- : ev.evidence === 'observed' ? `its registration is observed by ${ev.by}`
1500
- : ev.evidence === 'closure' ? `its import ${ev.by} ${clientEffectReason(/** @type {string} */ (ev.by))}`
1501
- : ev.evidence === 'render' ? `${ev.by} ships and can render its tag`
1502
- : ev.evidence === 'import' ? `${ev.by} ships and imports it`
1503
- : 'its source could not be read (ships conservatively)',
1504
- };
1505
- componentVerdicts.set(c.file, row);
1506
- }
1507
- if (!row.tags.includes(c.tag)) row.tags.push(c.tag);
1508
- }
1509
- for (const row of componentVerdicts.values()) row.tags.sort();
1510
-
1511
- // Route modules fall into three classes by their effective client closure
1512
- // (skipping elided components and server stubs, which never load on the
1513
- // client):
1514
- // - INERT: neither the module nor its closure does any client work. Dropped
1515
- // from the boot script entirely (#179).
1516
- // - IMPORT-ONLY (#605): the module itself does no client work, and the ONLY
1517
- // client work its closure reaches is SHIPPING COMPONENTS (or modules
1518
- // those components carry). Such a page / layout module is just the
1519
- // import-graph carrier for its components; the boot can emit those
1520
- // component modules directly and drop the module.
1521
- // - SHIP: anything else (the module itself is client-effecting, or its
1522
- // closure reaches a client-effecting non-component through a path that
1523
- // does NOT pass through a shipping component). Unchanged behaviour.
1524
- //
1525
- // The walk is PATH-AWARE (#963): it stops descending at a shipping
1526
- // component, because that component is emitted in the module's place, so
1527
- // its whole subtree loads regardless of whether the page module ships.
1528
- // Consequently a client-effecting non-component reachable ONLY through
1529
- // shipping components (the module-scope-signal-in-its-own-file idiom of
1530
- // invariant 5, imported by a component the page renders) does not block
1531
- // import-only: dropping the page loses nothing, the emitted component
1532
- // still imports it. A client-effecting non-component reachable through a
1533
- // component-free path still ships the whole module, since dropping it
1534
- // would lose that side effect.
1535
- /** @type {Set<string>} */
1536
- const skip = new Set([...elidableComponents, ...serverFiles]);
1537
- /** @type {Set<string>} */
1538
- const inertRouteModules = new Set();
1539
- /** @type {Map<string, string[]>} route file -> component files to emit in its place */
1540
- const importOnlyRouteModules = new Map();
1541
- // Advisory (#646): for each route module that SHIPS WHOLE (neither inert nor
1542
- // import-only), record the first client-effecting blocker so a tool can name
1543
- // why it ships. blocker is null when the module's OWN code is the cause.
1544
- /** @type {Map<string, { blocker: string|null, reason: string }>} */
1545
- const shippedRouteModules = new Map();
1546
- for (const file of routeModules) {
1547
- if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from both sets)
1548
- if (isClientEffecting(file)) {
1549
- // The module itself ships whole; its own signal is the blocker.
1550
- shippedRouteModules.set(file, { blocker: null, reason: clientEffectReason(file) });
1551
- continue;
1552
- }
1553
- // Truncated walk (path-aware, #963): BFS the module's import graph, but
1554
- // do NOT descend into shipping components (they are emitted in the
1555
- // module's place, so their subtrees load regardless). Collect those
1556
- // frontier components as the emit set: what loading the module would
1557
- // have registered, so a component imported but only conditionally
1558
- // rendered still registers. Skip elided components and server files
1559
- // exactly like transitiveDeps (they never load client-side; a stub is
1560
- // served instead). Non-appDir deps (core, vendor) are traversed but
1561
- // never classified, matching the old closure's appDir restriction.
1562
- //
1563
- // A `static lazy` component is NOT special-cased here: it only appears
1564
- // on this STATIC walk when the route statically imports it, and in that
1565
- // case loading the module already eager-loaded it before elision, so
1566
- // emitting it directly preserves that exact behaviour. A normally-used
1567
- // lazy component is tag-referenced (never statically imported), so it
1568
- // is absent from this walk and still loads via the IntersectionObserver
1569
- // `observeLazy` path.
1570
- /** @type {Set<string>} shipping components to emit in the module's place */
1571
- const frontier = new Set();
1572
- /** @type {string|null} first client-effecting non-component on a component-free path */
1573
- let blocker = null;
1574
- if (appDir) {
1575
- const visited = new Set([file]);
1576
- const queue = [file];
1577
- while (queue.length) {
1578
- const cur = /** @type {string} */ (queue.shift());
1579
- const deps = moduleGraph.get(cur);
1580
- if (!deps) continue;
1581
- for (const dep of deps) {
1582
- if (visited.has(dep)) continue;
1583
- visited.add(dep);
1584
- // Elided components and appDir server files are in `skip`;
1585
- // SERVER_FILE_RE additionally stops at a `.server.*` OUTSIDE appDir
1586
- // (a relative import escaping the app dir), exactly like
1587
- // transitiveDeps: the browser only ever sees its stub, so its
1588
- // subtree never loads and must not contribute a blocker.
1589
- if (skip.has(dep) || SERVER_FILE_RE.test(dep)) continue;
1590
- if (componentFiles.has(dep)) { frontier.add(dep); continue; } // emitted; subtree carried
1591
- if (dep.startsWith(appDir) && isClientEffecting(dep)) blocker ??= dep;
1592
- queue.push(dep);
1593
- }
1594
- }
1595
- }
1596
- if (blocker !== null) {
1597
- // A client-effecting non-component is reachable on a component-free
1598
- // path; ship the whole module and name the blocker for the advisory.
1599
- shippedRouteModules.set(file, { blocker, reason: clientEffectReason(blocker) });
1600
- } else if (frontier.size === 0) {
1601
- inertRouteModules.add(file);
1602
- } else {
1603
- importOnlyRouteModules.set(file, [...frontier]);
1604
- }
1605
- }
1606
-
1607
- return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules, componentVerdicts };
1608
- }
1609
-
1610
- /** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
1611
- const SIDE_EFFECT_IMPORT_RE = /^([ \t]*)import\s+(['"])([^'"]+)\2\s*;?[ \t]*$/gm;
1612
-
1613
- /**
1614
- * Remove side-effect imports of elidable components from a browser
1615
- * module's served source, so the browser never downloads them. This is
1616
- * what actually elides the JS: a component is fetched because the page
1617
- * (or another component) statically imports it for registration, and
1618
- * the modulepreload hint only parallelises that fetch.
1619
- *
1620
- * ONLY side-effect imports (`import './x'`) are stripped. A binding
1621
- * import (`import { X } from './x'`) is left intact: its binding may be
1622
- * used as a value at runtime, so removing it would break the module.
1623
- * That is also why eliding stays correct, an elidable component is one
1624
- * used purely as an SSR'd tag, never as an imported value.
1625
- *
1626
- * Fast path: if the importer has no elidable dependency in the graph,
1627
- * the source is returned untouched without any regex work.
1628
- *
1629
- * Matching runs over a REDACTED copy (comment / string / template bodies
1630
- * blanked, positions preserved) so a line that merely reads like an
1631
- * import inside an `html\`...\`` template or a comment is never rewritten.
1632
- * Real top-level import statements survive redaction; the quoted
1633
- * specifier survives too (redaction keeps string bodies verbatim), so it
1634
- * is read from the redacted match and the original source is spliced at
1635
- * the matched range.
1636
- *
1637
- * @param {string} source module source (already type-stripped if TS)
1638
- * @param {string} importerAbs absolute path of the importing module
1639
- * @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
1640
- * @param {Set<string> | undefined} elidableSet absolute paths of elidable files
1641
- * @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
1642
- * @param {string} appDir
1643
- * @returns {string}
1644
- */
1645
- export function elideImportsFromSource(source, importerAbs, moduleGraph, elidableSet, resolveImport, appDir) {
1646
- if (!elidableSet || elidableSet.size === 0) return source;
1647
- const deps = moduleGraph && moduleGraph.get(importerAbs);
1648
- if (!deps) return source;
1649
- let hasElidableDep = false;
1650
- for (const d of deps) {
1651
- if (elidableSet.has(d)) { hasElidableDep = true; break; }
1652
- }
1653
- if (!hasElidableDep) return source;
1654
-
1655
- const redacted = redactStringsAndTemplates(source);
1656
- let out = '';
1657
- let last = 0;
1658
- for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
1659
- const start = /** @type {number} */ (m.index);
1660
- const end = start + m[0].length;
1661
- const resolved = resolveImport(m[3], importerAbs, appDir);
1662
- out += source.slice(last, start);
1663
- if (resolved && elidableSet.has(resolved)) {
1664
- out += `${m[1]}/* webjs: elided display-only component */`;
1665
- } else {
1666
- out += source.slice(start, end);
1667
- }
1668
- last = end;
1669
- }
1670
- out += source.slice(last);
1671
- return out;
1672
- }
1673
-
1674
- /**
1675
- * Defer side-effect imports of `static lazy` components (#1524). A lazy
1676
- * component that a shipping module imports for registration used to load
1677
- * eagerly as part of that module's static graph, which defeated `static lazy`
1678
- * whenever the class had to be imported at all (and SSR needs the import: the
1679
- * server only knows the tag is lazy once the class registered). The SERVER
1680
- * keeps the real import; this rewrites only the BROWSER copy, replacing the
1681
- * import line in place (same line, so positions stay stable) with a
1682
- * registration on the lazy loader:
1683
- *
1684
- * import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy({"x-tag":"/url"}));
1685
- *
1686
- * The loader then imports the module the first time an element with one of
1687
- * its tags is in (or near) the viewport: a hidden tab panel when it first
1688
- * shows, a closed dialog's content when it opens, a below-the-fold element
1689
- * when it scrolls near. Its MutationObserver also catches a tag rendered
1690
- * later on the client, so an imported-but-not-yet-rendered component still
1691
- * registers when it appears.
1692
- *
1693
- * Same contract as {@link elideImportsFromSource}: only a side-effect import
1694
- * is rewritten (a binding import needs its value now, so it stays eager), the
1695
- * match runs over a redacted copy so an import-looking line inside a string,
1696
- * template, or comment is never touched, and an importer with no lazy
1697
- * dependency in the graph returns untouched with no regex work. Run it AFTER
1698
- * the elision strip: an elided import is already a comment by then.
1699
- *
1700
- * @param {string} source module source (already type-stripped if TS)
1701
- * @param {string} importerAbs absolute path of the importing module
1702
- * @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
1703
- * @param {Map<string, string[]> | undefined} lazyFiles lazy component file -> the tags it registers
1704
- * @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
1705
- * @param {string} appDir
1706
- * @param {(abs: string) => string} urlFor the browser URL the loader imports (base path + content hash applied)
1707
- * @returns {string}
1708
- */
1709
- export function deferLazyImportsFromSource(source, importerAbs, moduleGraph, lazyFiles, resolveImport, appDir, urlFor) {
1710
- if (!lazyFiles || lazyFiles.size === 0) return source;
1711
- const deps = moduleGraph && moduleGraph.get(importerAbs);
1712
- if (!deps) return source;
1713
- let hasLazyDep = false;
1714
- for (const d of deps) {
1715
- if (lazyFiles.has(d)) { hasLazyDep = true; break; }
1716
- }
1717
- if (!hasLazyDep) return source;
1718
-
1719
- const redacted = redactStringsAndTemplates(source);
1720
- let out = '';
1721
- let last = 0;
1722
- for (const m of redacted.matchAll(SIDE_EFFECT_IMPORT_RE)) {
1723
- const start = /** @type {number} */ (m.index);
1724
- const end = start + m[0].length;
1725
- const resolved = resolveImport(m[3], importerAbs, appDir);
1726
- const tags = resolved ? lazyFiles.get(resolved) : undefined;
1727
- out += source.slice(last, start);
1728
- if (tags && tags.length) {
1729
- const url = urlFor(/** @type {string} */ (resolved));
1730
- const entries = Object.fromEntries(tags.map((t) => [t, url]));
1731
- out += `${m[1]}import('@webjsdev/core/lazy-loader').then((m) => m.observeLazy(${JSON.stringify(entries)}));`;
1732
- } else {
1733
- out += source.slice(start, end);
1734
- }
1735
- last = end;
1736
- }
1737
- out += source.slice(last);
1738
- return out;
1739
- }
1740
-
1741
- /**
1742
- * The lazy component files of an app (#1524): every scanned component file
1743
- * that declares `static lazy = true`, mapped to the tags it registers. An
1744
- * elided (display-only) file is left out: its import is stripped outright,
1745
- * which beats deferring it.
1746
- *
1747
- * @param {Array<{ tag: string, file: string, lazy?: boolean }>} components
1748
- * @param {Set<string>} [elidable]
1749
- * @returns {Map<string, string[]>}
1750
- */
1751
- export function lazyComponentFiles(components, elidable) {
1752
- /** @type {Map<string, string[]>} */
1753
- const out = new Map();
1754
- for (const c of components) {
1755
- if (!c.lazy || (elidable && elidable.has(c.file))) continue;
1756
- const tags = out.get(c.file);
1757
- if (tags) { if (!tags.includes(c.tag)) tags.push(c.tag); } else out.set(c.file, [c.tag]);
1758
- }
1759
- return out;
1760
- }
1761
-
1762
- /** A test module (the same rule the component scanner skips). */
1763
- const TEST_FILE_RE = /\.(test|spec)\.m?[jt]sx?$/;
1764
-
1765
- /** `import <bindings> from 'x'` (the bindings part is captured). */
1766
- const BINDING_IMPORT_RE = /\bimport\s+(type\s+)?([\w$*{}\s,]+?)\s+from\s+(['"])([^'"]+)\3/g;
1767
- /** `export { a } from 'x'` / `export * from 'x'` (a re-export needs the module too). */
1768
- const REEXPORT_RE = /\bexport\s+(type\s+)?(?:\*|\{[^}]*\}|\*\s+as\s+[\w$]+)\s+from\s+(['"])([^'"]+)\2/g;
1769
-
1770
- /**
1771
- * Is this import clause types only (`import type { A }`, or every named
1772
- * specifier marked `type`)? Those are erased before the browser sees them.
1773
- *
1774
- * @param {string | undefined} typeKw
1775
- * @param {string} clause
1776
- */
1777
- function typeOnlyClause(typeKw, clause) {
1778
- if (typeKw) return true;
1779
- const m = clause.trim().match(/^\{([\s\S]*)\}$/);
1780
- if (!m) return false;
1781
- const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
1782
- return names.length > 0 && names.every((n) => /^type\s/.test(n));
1783
- }
1784
-
1785
- /**
1786
- * The lazy component files some module imports WITH bindings (#1524). A
1787
- * binding import loads the module eagerly whatever the class says, because the
1788
- * importer needs the value when it runs. Such a file is not lazy in practice,
1789
- * so the caller drops it from the lazy set: otherwise the preload walks would
1790
- * skip a module the browser still fetches up front, which only makes it slower.
1791
- *
1792
- * Only importers whose graph edges reach a lazy file are read. A server file is
1793
- * never served, and a test file (`*.test.*` / `*.spec.*`) never ships to a
1794
- * page, so their imports do not count: a component's own browser test imports
1795
- * it with bindings, and that must not make it eager in the app.
1796
- *
1797
- * @param {Map<string, string[]>} lazyFiles
1798
- * @param {import('./module-graph.js').ModuleGraph | undefined} moduleGraph
1799
- * @param {(file: string) => Promise<string>} readFileFn
1800
- * @param {(spec: string, fromFile: string, appDir: string) => (string|null)} resolveImport
1801
- * @param {string} appDir
1802
- * @returns {Promise<Set<string>>}
1803
- */
1804
- export async function bindingImportedLazyFiles(lazyFiles, moduleGraph, readFileFn, resolveImport, appDir) {
1805
- /** @type {Set<string>} */
1806
- const out = new Set();
1807
- if (!lazyFiles.size || !moduleGraph) return out;
1808
- for (const [importer, deps] of moduleGraph) {
1809
- if (SERVER_FILE_RE.test(importer) || TEST_FILE_RE.test(importer)) continue;
1810
- let reaches = false;
1811
- for (const d of deps) if (lazyFiles.has(d) && !out.has(d)) { reaches = true; break; }
1812
- if (!reaches) continue;
1813
- let src;
1814
- try { src = await readFileFn(importer); } catch { continue; }
1815
- const code = redactStringsAndTemplates(maskComments(src));
1816
- for (const m of code.matchAll(BINDING_IMPORT_RE)) {
1817
- if (typeOnlyClause(m[1], m[2])) continue;
1818
- const abs = resolveImport(m[4], importer, appDir);
1819
- if (abs && lazyFiles.has(abs)) out.add(abs);
1820
- }
1821
- for (const m of code.matchAll(REEXPORT_RE)) {
1822
- if (m[1]) continue;
1823
- const abs = resolveImport(m[3], importer, appDir);
1824
- if (abs && lazyFiles.has(abs)) out.add(abs);
1825
- }
1826
- }
1827
- return out;
1828
- }