phantom-build 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +378 -0
  2. package/dist/analyzer.d.ts +11 -0
  3. package/dist/analyzer.d.ts.map +1 -0
  4. package/dist/analyzer.js +330 -0
  5. package/dist/analyzer.js.map +1 -0
  6. package/dist/ast-compat.d.ts +11 -0
  7. package/dist/ast-compat.d.ts.map +1 -0
  8. package/dist/ast-compat.js +84 -0
  9. package/dist/ast-compat.js.map +1 -0
  10. package/dist/classify/boundary.d.ts +30 -0
  11. package/dist/classify/boundary.d.ts.map +1 -0
  12. package/dist/classify/boundary.js +145 -0
  13. package/dist/classify/boundary.js.map +1 -0
  14. package/dist/classify/browser-globals.d.ts +29 -0
  15. package/dist/classify/browser-globals.d.ts.map +1 -0
  16. package/dist/classify/browser-globals.js +197 -0
  17. package/dist/classify/browser-globals.js.map +1 -0
  18. package/dist/classify/index.d.ts +14 -0
  19. package/dist/classify/index.d.ts.map +1 -0
  20. package/dist/classify/index.js +294 -0
  21. package/dist/classify/index.js.map +1 -0
  22. package/dist/classify/lazy-llm.d.ts +122 -0
  23. package/dist/classify/lazy-llm.d.ts.map +1 -0
  24. package/dist/classify/lazy-llm.js +142 -0
  25. package/dist/classify/lazy-llm.js.map +1 -0
  26. package/dist/classify/lazy.d.ts +23 -0
  27. package/dist/classify/lazy.d.ts.map +1 -0
  28. package/dist/classify/lazy.js +686 -0
  29. package/dist/classify/lazy.js.map +1 -0
  30. package/dist/classify/llm-client.d.ts +59 -0
  31. package/dist/classify/llm-client.d.ts.map +1 -0
  32. package/dist/classify/llm-client.js +193 -0
  33. package/dist/classify/llm-client.js.map +1 -0
  34. package/dist/classify/purity.d.ts +21 -0
  35. package/dist/classify/purity.d.ts.map +1 -0
  36. package/dist/classify/purity.js +47 -0
  37. package/dist/classify/purity.js.map +1 -0
  38. package/dist/classify/react-patterns.d.ts +15 -0
  39. package/dist/classify/react-patterns.d.ts.map +1 -0
  40. package/dist/classify/react-patterns.js +82 -0
  41. package/dist/classify/react-patterns.js.map +1 -0
  42. package/dist/classify/taint.d.ts +32 -0
  43. package/dist/classify/taint.d.ts.map +1 -0
  44. package/dist/classify/taint.js +68 -0
  45. package/dist/classify/taint.js.map +1 -0
  46. package/dist/cli.d.ts +3 -0
  47. package/dist/cli.d.ts.map +1 -0
  48. package/dist/cli.js +109 -0
  49. package/dist/cli.js.map +1 -0
  50. package/dist/extract/chunk-module.d.ts +20 -0
  51. package/dist/extract/chunk-module.d.ts.map +1 -0
  52. package/dist/extract/chunk-module.js +163 -0
  53. package/dist/extract/chunk-module.js.map +1 -0
  54. package/dist/extract/client-stub.d.ts +25 -0
  55. package/dist/extract/client-stub.d.ts.map +1 -0
  56. package/dist/extract/client-stub.js +233 -0
  57. package/dist/extract/client-stub.js.map +1 -0
  58. package/dist/extract/import-resolver.d.ts +20 -0
  59. package/dist/extract/import-resolver.d.ts.map +1 -0
  60. package/dist/extract/import-resolver.js +51 -0
  61. package/dist/extract/import-resolver.js.map +1 -0
  62. package/dist/extract/index.d.ts +20 -0
  63. package/dist/extract/index.d.ts.map +1 -0
  64. package/dist/extract/index.js +105 -0
  65. package/dist/extract/index.js.map +1 -0
  66. package/dist/extract/lazy-transform.d.ts +14 -0
  67. package/dist/extract/lazy-transform.d.ts.map +1 -0
  68. package/dist/extract/lazy-transform.js +473 -0
  69. package/dist/extract/lazy-transform.js.map +1 -0
  70. package/dist/index.d.ts +4 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +3 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/plugin.d.ts +7 -0
  75. package/dist/plugin.d.ts.map +1 -0
  76. package/dist/plugin.js +535 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/runtime/index.d.ts +28 -0
  79. package/dist/runtime/index.d.ts.map +1 -0
  80. package/dist/runtime/index.js +73 -0
  81. package/dist/runtime/index.js.map +1 -0
  82. package/dist/types.d.ts +219 -0
  83. package/dist/types.d.ts.map +1 -0
  84. package/dist/types.js +2 -0
  85. package/dist/types.js.map +1 -0
  86. package/dist/vite.d.ts +3 -0
  87. package/dist/vite.d.ts.map +1 -0
  88. package/dist/vite.js +3 -0
  89. package/dist/vite.js.map +1 -0
  90. package/dist/webpack.d.ts +3 -0
  91. package/dist/webpack.d.ts.map +1 -0
  92. package/dist/webpack.js +3 -0
  93. package/dist/webpack.js.map +1 -0
  94. package/package.json +79 -0
@@ -0,0 +1,686 @@
1
+ import { dirname, relative, resolve } from 'node:path';
2
+ // ── Constants ───────────────────────────────────────────────────────────
3
+ /**
4
+ * Minimum estimated JS cost (bytes) for a component to be worth lazifying.
5
+ * Below this threshold, the Suspense boundary overhead exceeds the savings.
6
+ * React.lazy + Suspense adds ~200 bytes of wrapper code plus a loading
7
+ * waterfall step — not worth it for tiny leaf components.
8
+ */
9
+ const MIN_JS_COST_BYTES = 3072; // 3KB
10
+ /**
11
+ * JSX position threshold: children at or above this index in a route-level
12
+ * component's render tree are assumed to be below the initial viewport.
13
+ * This is a conservative heuristic — positions 0 and 1 are kept static.
14
+ */
15
+ const BELOW_FOLD_POSITION = 2;
16
+ /**
17
+ * Component names that are almost certainly context providers.
18
+ * Matched as suffixes (e.g., "ThemeProvider", "AuthProvider").
19
+ */
20
+ const PROVIDER_SUFFIXES = ['Provider', 'Context'];
21
+ /**
22
+ * Known route-level wrapper component names.
23
+ * These are the parent components where we count child JSX positions.
24
+ */
25
+ const ROUTE_COMPONENT_PATTERNS = /^(Page|Layout|Route|Screen|View|Template)$|Page$|Layout$|Screen$|View$/;
26
+ // ── Main entry ──────────────────────────────────────────────────────────
27
+ /**
28
+ * Detect which imported child components are candidates for React.lazy wrapping.
29
+ *
30
+ * Runs after segment classification so it can use handler/effect counts
31
+ * from sibling modules (via componentProfiles). Works standalone with
32
+ * heuristics when profiles aren't available.
33
+ *
34
+ * @param analyzed - The parsed + scope-analyzed module
35
+ * @param sourceCode - Raw source text
36
+ * @param segments - Already-classified segments for THIS module
37
+ * @param componentProfiles - Optional: analysis results from imported modules
38
+ * (keyed by resolved file path or import source)
39
+ * @param reExportMap - Optional: barrel file re-export mappings accumulated during build
40
+ */
41
+ export function detectLazyCandidates(analyzed, sourceCode, segments, componentProfiles, reExportMap) {
42
+ const lazy = [];
43
+ const keepStatic = [];
44
+ // Step 0: Skip modules that don't export React components.
45
+ // Entry points (e.g., main.tsx calling ReactDOM.render) have module-level JSX
46
+ // but don't export components — lazy detection doesn't apply to them.
47
+ if (!hasExportedComponent(analyzed.ast)) {
48
+ return { lazy, keepStatic };
49
+ }
50
+ // Step 1: Find all component imports (PascalCase from relative paths)
51
+ // Pass reExportMap to resolve through barrel files when available.
52
+ const componentImports = findComponentImports(analyzed, reExportMap);
53
+ if (componentImports.length === 0) {
54
+ return { lazy, keepStatic };
55
+ }
56
+ // Step 2: Find JSX usages of each imported component
57
+ const jsxUsageMap = findJSXUsages(analyzed.ast, componentImports.map((c) => c.localName));
58
+ // Step 3: Detect which components are used as context providers
59
+ const providerNames = detectContextProviders(analyzed.ast, componentImports.map((c) => c.localName));
60
+ // Step 4: Determine if this module is a route-level component
61
+ const isRouteComponent = checkIsRouteComponent(analyzed.path, analyzed.ast);
62
+ // Step 5: Evaluate each component import
63
+ for (const imp of componentImports) {
64
+ const usages = jsxUsageMap.get(imp.localName);
65
+ if (!usages || usages.length === 0) {
66
+ // Imported but never used in JSX — skip (might be a utility, HOC, etc.)
67
+ continue;
68
+ }
69
+ // Try resolved source first for profile lookup (barrel-resolved path),
70
+ // fall back to original import source
71
+ const profileSource = imp.resolvedSource ?? imp.source;
72
+ const profile = resolveComponentProfile(profileSource, analyzed.path, componentProfiles) ?? null;
73
+ const isProvider = providerNames.has(imp.localName);
74
+ // Rule 1: Context providers must hydrate before consumers — never lazify
75
+ if (isProvider) {
76
+ keepStatic.push({
77
+ localName: imp.localName,
78
+ source: imp.source,
79
+ reason: 'Context provider — must hydrate before consumers',
80
+ });
81
+ continue;
82
+ }
83
+ // Rule 2: If we have a profile and the component has no meaningful JS cost, skip
84
+ if (profile && !hasSignificantJSCost(profile)) {
85
+ keepStatic.push({
86
+ localName: imp.localName,
87
+ source: imp.source,
88
+ reason: `Low JS cost (${profile.estimatedSize}B, no handlers/effects) — Suspense overhead exceeds savings`,
89
+ });
90
+ continue;
91
+ }
92
+ // Gather JSX context for each usage
93
+ const positions = usages.map((u) => u.position);
94
+ const minPosition = Math.min(...positions);
95
+ const isConditional = usages.some((u) => u.conditional);
96
+ const isOnlyConditional = usages.every((u) => u.conditional);
97
+ // Rule 3: Components above the fold in route-level components should stay static.
98
+ // Positions below BELOW_FOLD_POSITION (0, 1) are assumed to be in the initial viewport.
99
+ // The Suspense boundary overhead and loading waterfall hurts LCP for above-fold content.
100
+ if (isRouteComponent && minPosition < BELOW_FOLD_POSITION && !isOnlyConditional) {
101
+ keepStatic.push({
102
+ localName: imp.localName,
103
+ source: imp.source,
104
+ reason: `Position ${minPosition} in route component — above fold (threshold: ${BELOW_FOLD_POSITION})`,
105
+ });
106
+ continue;
107
+ }
108
+ // Assign prefetch strategy based on heuristics
109
+ const prefetch = assignStrategy(minPosition, isOnlyConditional, isRouteComponent, profile);
110
+ lazy.push({
111
+ localName: imp.localName,
112
+ source: imp.source,
113
+ resolvedSource: imp.resolvedSource ?? undefined,
114
+ importKind: imp.importKind,
115
+ importedName: imp.importedName,
116
+ jsxUsages: usages.map((u) => ({ start: u.start, end: u.end })),
117
+ prefetch,
118
+ suspenseGroup: null, // Heuristic: one boundary per component. LLM can optimize grouping.
119
+ conditional: isConditional,
120
+ jsxPosition: minPosition,
121
+ reason: buildReason(minPosition, isOnlyConditional, prefetch, profile),
122
+ });
123
+ }
124
+ // Step 6: Assign suspense groups for adjacent lazy components
125
+ assignSuspenseGroups(lazy, analyzed.ast);
126
+ return { lazy, keepStatic };
127
+ }
128
+ /**
129
+ * Find imports that look like React component imports:
130
+ * - PascalCase local name (components by convention)
131
+ * - From a relative path (./Foo, ../components/Bar)
132
+ * - Not from node_modules (bare specifiers like 'react')
133
+ *
134
+ * When a reExportMap is provided, resolves through barrel files:
135
+ * if `import { PaymentForm } from './components'` and the barrel file
136
+ * `./components/index.ts` re-exports `PaymentForm` from `./PaymentForm`,
137
+ * the resolved source becomes `./components/PaymentForm`.
138
+ */
139
+ function findComponentImports(analyzed, reExportMap) {
140
+ const results = [];
141
+ for (const imp of analyzed.imports) {
142
+ // Only relative imports — node_modules components can't be lazified
143
+ // without knowing their export shape
144
+ if (!isRelativeImport(imp.source))
145
+ continue;
146
+ for (const spec of imp.specifiers) {
147
+ if (isPascalCase(spec.local)) {
148
+ let resolvedSource = null;
149
+ let importKind = spec.kind;
150
+ let importedName = spec.imported;
151
+ // Try to resolve through barrel files
152
+ if (reExportMap && reExportMap.size > 0) {
153
+ const resolved = resolveBarrelImport(imp.source, spec.imported ?? spec.local, spec.kind, analyzed.path, reExportMap);
154
+ if (resolved) {
155
+ resolvedSource = resolved.source;
156
+ importKind = resolved.importKind;
157
+ importedName = resolved.importedName;
158
+ }
159
+ }
160
+ results.push({
161
+ localName: spec.local,
162
+ source: imp.source,
163
+ resolvedSource,
164
+ importKind,
165
+ importedName,
166
+ });
167
+ }
168
+ }
169
+ }
170
+ return results;
171
+ }
172
+ /**
173
+ * Resolve through a barrel file re-export.
174
+ *
175
+ * Given `import { PaymentForm } from './components'` where `./components/index.ts`
176
+ * has `export { PaymentForm } from './PaymentForm'`, returns the resolved source
177
+ * `./components/PaymentForm` so the lazy dynamic import targets the actual module.
178
+ */
179
+ function resolveBarrelImport(importSource, importedName, importKind, modulePath, reExportMap) {
180
+ // Namespace imports can't resolve through barrel files
181
+ if (importKind === 'namespace')
182
+ return null;
183
+ const dir = dirname(modulePath);
184
+ // Try to find the barrel file in the re-export map
185
+ // The map is keyed by absolute paths, so resolve the import source
186
+ const EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js', ''];
187
+ let barrelMappings;
188
+ let barrelDir;
189
+ for (const ext of EXTENSIONS) {
190
+ const candidate = resolve(dir, importSource + ext);
191
+ barrelMappings = reExportMap.get(candidate);
192
+ if (barrelMappings) {
193
+ barrelDir = dirname(candidate);
194
+ break;
195
+ }
196
+ }
197
+ if (!barrelMappings || !barrelDir)
198
+ return null;
199
+ // Look for the exported name in the barrel's re-exports
200
+ const lookupName = importKind === 'default' ? 'default' : importedName;
201
+ const reExport = barrelMappings.get(lookupName);
202
+ if (!reExport)
203
+ return null;
204
+ // Build the resolved source path relative to the importing module.
205
+ // The re-export source is relative to the barrel file, so we need to
206
+ // resolve it from the barrel's directory then make it relative to the importer.
207
+ const absoluteTarget = resolve(barrelDir, reExport.source);
208
+ let resolvedSource = makeRelative(dir, absoluteTarget);
209
+ // Determine the import kind through the re-export
210
+ const resolvedImportKind = reExport.importedName === 'default' ? 'default' : 'named';
211
+ const resolvedImportedName = resolvedImportKind === 'default' ? null : reExport.importedName;
212
+ return { source: resolvedSource, importKind: resolvedImportKind, importedName: resolvedImportedName };
213
+ }
214
+ /**
215
+ * Make a path relative, ensuring it starts with './' or '../'.
216
+ */
217
+ function makeRelative(from, to) {
218
+ let rel = relative(from, to);
219
+ if (!rel.startsWith('.'))
220
+ rel = './' + rel;
221
+ return rel;
222
+ }
223
+ /**
224
+ * Find all JSX element usages of the given component names.
225
+ *
226
+ * For each JSX element `<Foo ...>`, records:
227
+ * - Its span in the source
228
+ * - Its position among sibling elements in the parent
229
+ * - Whether it appears inside a conditional context (&&, ternary, if)
230
+ */
231
+ function findJSXUsages(ast, componentNames) {
232
+ const nameSet = new Set(componentNames);
233
+ const result = new Map();
234
+ for (const name of componentNames) {
235
+ result.set(name, []);
236
+ }
237
+ // Walk AST and collect JSX elements with parent context
238
+ walkWithContext(ast, (node, context) => {
239
+ if (node.type !== 'JSXElement')
240
+ return;
241
+ const jsx = node;
242
+ // Only handle simple identifier tags: <Foo /> not <foo.Bar />
243
+ const tagName = jsx.openingElement?.name;
244
+ if (!tagName || tagName.type !== 'JSXIdentifier')
245
+ return;
246
+ if (!tagName.name || !nameSet.has(tagName.name))
247
+ return;
248
+ const start = node.start ?? 0;
249
+ const end = node.end ?? 0;
250
+ const usage = {
251
+ start,
252
+ end,
253
+ position: context.siblingIndex,
254
+ conditional: context.isConditional,
255
+ };
256
+ result.get(tagName.name).push(usage);
257
+ });
258
+ return result;
259
+ }
260
+ // ── Context provider detection ──────────────────────────────────────────
261
+ /**
262
+ * Detect which imported component names are used as context providers.
263
+ *
264
+ * Heuristics:
265
+ * 1. Name ends with "Provider" or "Context" (convention)
266
+ * 2. Used as a JSX element that wraps other JSX children
267
+ * (i.e., `<ThemeProvider>...children...</ThemeProvider>`)
268
+ */
269
+ function detectContextProviders(ast, componentNames) {
270
+ const providers = new Set();
271
+ const nameSet = new Set(componentNames);
272
+ // Heuristic 1: Name-based detection
273
+ for (const name of componentNames) {
274
+ if (PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix))) {
275
+ providers.add(name);
276
+ }
277
+ }
278
+ // Heuristic 2: Structural detection — component has JSX children
279
+ walkNode(ast, (node) => {
280
+ if (node.type !== 'JSXElement')
281
+ return;
282
+ const jsx = node;
283
+ const tagName = jsx.openingElement?.name;
284
+ if (!tagName || tagName.type !== 'JSXIdentifier')
285
+ return;
286
+ if (!tagName.name || !nameSet.has(tagName.name))
287
+ return;
288
+ // Check if it has JSX children (not just text/whitespace)
289
+ const hasJSXChildren = jsx.children?.some((child) => {
290
+ const c = child;
291
+ return (c.type === 'JSXElement' ||
292
+ c.type === 'JSXFragment' ||
293
+ c.type === 'JSXExpressionContainer');
294
+ });
295
+ if (hasJSXChildren) {
296
+ providers.add(tagName.name);
297
+ }
298
+ });
299
+ return providers;
300
+ }
301
+ // ── Route component detection ───────────────────────────────────────────
302
+ /**
303
+ * Check if this module exports a route-level component.
304
+ *
305
+ * Route components are where JSX position matters for above/below fold.
306
+ * Detection heuristics:
307
+ * 1. File path contains /pages/, /routes/, /app/, or /views/
308
+ * 2. Component name matches route patterns (ends with Page, Layout, etc.)
309
+ * 3. Is the default export of the module
310
+ */
311
+ function checkIsRouteComponent(filePath, ast) {
312
+ // Path-based heuristic
313
+ const routePathPattern = /\/(pages?|routes?|app|views?|screens?)\//i;
314
+ if (routePathPattern.test(filePath))
315
+ return true;
316
+ // Name-based heuristic: check exported function/component names
317
+ let hasRouteExport = false;
318
+ walkNode(ast, (node) => {
319
+ if (hasRouteExport)
320
+ return;
321
+ // export default function CheckoutPage() { ... }
322
+ if (node.type === 'ExportDefaultDeclaration') {
323
+ const decl = node.declaration;
324
+ if (decl?.type === 'FunctionDeclaration') {
325
+ const name = decl.id?.name;
326
+ if (name && ROUTE_COMPONENT_PATTERNS.test(name)) {
327
+ hasRouteExport = true;
328
+ }
329
+ }
330
+ }
331
+ // export function CheckoutPage() { ... }
332
+ if (node.type === 'ExportNamedDeclaration') {
333
+ const decl = node.declaration;
334
+ if (decl?.type === 'FunctionDeclaration') {
335
+ const name = decl.id?.name;
336
+ if (name && ROUTE_COMPONENT_PATTERNS.test(name)) {
337
+ hasRouteExport = true;
338
+ }
339
+ }
340
+ }
341
+ });
342
+ return hasRouteExport;
343
+ }
344
+ // ── Strategy assignment ─────────────────────────────────────────────────
345
+ /**
346
+ * Assign a prefetch strategy based on heuristics.
347
+ * This is the "80% programmatic" path — the LLM can override these
348
+ * via the lockfile for the remaining 20% that needs judgment.
349
+ */
350
+ function assignStrategy(jsxPosition, isConditional, isRouteComponent, profile) {
351
+ // Conditionally rendered components should load on interaction
352
+ // (they might never be shown at all)
353
+ if (isConditional)
354
+ return 'interaction';
355
+ // In a route component, position determines fold prediction
356
+ if (isRouteComponent) {
357
+ // Position 1 is borderline — use viewport so it loads as user scrolls
358
+ if (jsxPosition === 1)
359
+ return 'viewport';
360
+ // Position 2+ is likely below fold
361
+ if (jsxPosition >= BELOW_FOLD_POSITION)
362
+ return 'viewport';
363
+ }
364
+ // If we have a profile with effects, the component probably needs
365
+ // to initialize something — prefetch on idle so it's ready
366
+ if (profile?.hasEffects)
367
+ return 'idle';
368
+ // Default: viewport-based loading
369
+ return 'viewport';
370
+ }
371
+ // ── Suspense grouping ───────────────────────────────────────────────────
372
+ /**
373
+ * Assign suspense groups to adjacent lazy components that share a parent.
374
+ *
375
+ * Without LLM input, the heuristic is simple:
376
+ * Adjacent lazy components that are siblings in the same JSX parent
377
+ * get grouped into one Suspense boundary. Non-adjacent siblings get
378
+ * separate boundaries.
379
+ *
380
+ * The LLM can later optimize this by grouping components that share
381
+ * state or are always used together.
382
+ */
383
+ function assignSuspenseGroups(candidates, ast) {
384
+ if (candidates.length <= 1)
385
+ return;
386
+ // Build a map of JSX parent → ordered children info
387
+ const parentGroups = new Map();
388
+ // For each candidate, find its parent JSX element
389
+ for (const candidate of candidates) {
390
+ for (const usage of candidate.jsxUsages) {
391
+ const parentKey = findJSXParentKey(ast, usage.start, usage.end);
392
+ if (!parentKey)
393
+ continue;
394
+ let group = parentGroups.get(parentKey);
395
+ if (!group) {
396
+ group = [];
397
+ parentGroups.set(parentKey, group);
398
+ }
399
+ group.push(candidate);
400
+ }
401
+ }
402
+ // For each parent, group adjacent candidates
403
+ let groupCounter = 0;
404
+ for (const [, group] of parentGroups) {
405
+ if (group.length < 2)
406
+ continue;
407
+ // Sort by JSX position
408
+ const sorted = [...group].sort((a, b) => a.jsxPosition - b.jsxPosition);
409
+ // Find runs of adjacent positions
410
+ let runStart = 0;
411
+ for (let i = 1; i <= sorted.length; i++) {
412
+ const isEnd = i === sorted.length;
413
+ const isBreak = !isEnd && sorted[i].jsxPosition !== sorted[i - 1].jsxPosition + 1;
414
+ if (isEnd || isBreak) {
415
+ // Run from runStart to i-1
416
+ const runLength = i - runStart;
417
+ if (runLength >= 2) {
418
+ const groupId = `group_${groupCounter++}`;
419
+ for (let j = runStart; j < i; j++) {
420
+ sorted[j].suspenseGroup = groupId;
421
+ }
422
+ }
423
+ runStart = i;
424
+ }
425
+ }
426
+ }
427
+ }
428
+ /**
429
+ * Find a stable key for the parent JSX element containing a child at [start, end].
430
+ * Returns a string like "parentStart:parentEnd" or null if not found.
431
+ */
432
+ function findJSXParentKey(ast, childStart, childEnd) {
433
+ let parentKey = null;
434
+ walkWithParentNode(ast, null, (node, parent) => {
435
+ if (parentKey)
436
+ return; // already found
437
+ const nStart = node.start;
438
+ const nEnd = node.end;
439
+ if (nStart !== childStart || nEnd !== childEnd)
440
+ return;
441
+ if (parent) {
442
+ const pStart = parent.start;
443
+ const pEnd = parent.end;
444
+ if (pStart != null && pEnd != null) {
445
+ parentKey = `${pStart}:${pEnd}`;
446
+ }
447
+ }
448
+ });
449
+ return parentKey;
450
+ }
451
+ // ── Reason builder ──────────────────────────────────────────────────────
452
+ function buildReason(position, isConditional, strategy, profile) {
453
+ const parts = [];
454
+ if (isConditional) {
455
+ parts.push('conditionally rendered');
456
+ }
457
+ if (position >= BELOW_FOLD_POSITION) {
458
+ parts.push(`position ${position} in render tree (likely below fold)`);
459
+ }
460
+ if (profile) {
461
+ const details = [];
462
+ if (profile.handlerCount > 0)
463
+ details.push(`${profile.handlerCount} handlers`);
464
+ if (profile.hasEffects)
465
+ details.push('has effects');
466
+ if (profile.estimatedSize > 0)
467
+ details.push(`~${(profile.estimatedSize / 1024).toFixed(1)}KB`);
468
+ if (details.length > 0)
469
+ parts.push(details.join(', '));
470
+ }
471
+ parts.push(`strategy: ${strategy}`);
472
+ return parts.join(' — ');
473
+ }
474
+ // ── Helpers ─────────────────────────────────────────────────────────────
475
+ function hasSignificantJSCost(profile) {
476
+ // A component is worth lazifying if it has meaningful JS to defer
477
+ if (profile.estimatedSize >= MIN_JS_COST_BYTES)
478
+ return true;
479
+ if (profile.handlerCount > 0)
480
+ return true;
481
+ if (profile.hasEffects)
482
+ return true;
483
+ if (profile.hasState)
484
+ return true;
485
+ return false;
486
+ }
487
+ function isPascalCase(name) {
488
+ return /^[A-Z][a-zA-Z0-9]*$/.test(name);
489
+ }
490
+ function isRelativeImport(source) {
491
+ return source.startsWith('./') || source.startsWith('../');
492
+ }
493
+ /**
494
+ * Resolve a component profile from the profiles map.
495
+ *
496
+ * Plugin.ts stores profiles keyed by absolute resolved file path (e.g.,
497
+ * `/app/components/PaymentForm.tsx`), but import sources are relative
498
+ * (e.g., `./PaymentForm`). This tries common extensions to find a match.
499
+ */
500
+ const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', ''];
501
+ function resolveComponentProfile(importSource, moduleFilePath, profiles) {
502
+ if (!profiles || profiles.size === 0)
503
+ return undefined;
504
+ // Direct lookup (works when import source is already absolute/exact)
505
+ if (profiles.has(importSource))
506
+ return profiles.get(importSource);
507
+ // Resolve relative to the importing module's directory
508
+ const dir = dirname(moduleFilePath);
509
+ for (const ext of RESOLVE_EXTENSIONS) {
510
+ const resolved = resolve(dir, importSource + ext);
511
+ const profile = profiles.get(resolved);
512
+ if (profile)
513
+ return profile;
514
+ }
515
+ return undefined;
516
+ }
517
+ /**
518
+ * Check if a module exports at least one React component.
519
+ *
520
+ * Entry points (e.g., main.tsx) typically call ReactDOM.render() at the
521
+ * module level but don't export components. Lazy detection only applies
522
+ * to modules that export components — otherwise we'd incorrectly try to
523
+ * wrap top-level JSX (like `<App />`) in Suspense.
524
+ *
525
+ * Checks for:
526
+ * - `export default function Foo() { ... }` (FunctionDeclaration)
527
+ * - `export default () => ...` (ArrowFunctionExpression / FunctionExpression)
528
+ * - `export function Foo() { ... }` (named export FunctionDeclaration)
529
+ * - `export const Foo = () => ...` (named export with init)
530
+ * - `export { Foo }` (named re-exports — conservative: assume component)
531
+ */
532
+ function hasExportedComponent(ast) {
533
+ let found = false;
534
+ walkNode(ast, (node) => {
535
+ if (found)
536
+ return;
537
+ if (node.type === 'ExportDefaultDeclaration') {
538
+ const decl = node.declaration;
539
+ if (!decl)
540
+ return;
541
+ // export default function Foo() {}
542
+ if (decl.type === 'FunctionDeclaration') {
543
+ found = true;
544
+ return;
545
+ }
546
+ // export default () => ... / export default function() {}
547
+ if (decl.type === 'ArrowFunctionExpression' || decl.type === 'FunctionExpression') {
548
+ found = true;
549
+ return;
550
+ }
551
+ // export default Foo (identifier — could be component)
552
+ if (decl.type === 'Identifier') {
553
+ found = true;
554
+ return;
555
+ }
556
+ }
557
+ if (node.type === 'ExportNamedDeclaration') {
558
+ const decl = node.declaration;
559
+ const specifiers = node.specifiers;
560
+ if (decl) {
561
+ // export function Foo() {}
562
+ if (decl.type === 'FunctionDeclaration') {
563
+ found = true;
564
+ return;
565
+ }
566
+ // export const Foo = () => ... or export const Foo = function() {}
567
+ if (decl.type === 'VariableDeclaration') {
568
+ const declarations = decl.declarations;
569
+ if (declarations) {
570
+ for (const d of declarations) {
571
+ const vd = d;
572
+ // Check if the name is PascalCase (component convention)
573
+ if (vd.id?.type === 'Identifier') {
574
+ const name = vd.id.name;
575
+ if (isPascalCase(name)) {
576
+ found = true;
577
+ return;
578
+ }
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+ // export { Foo } or export { Foo as Bar } — conservative: if any PascalCase name, assume component
585
+ if (specifiers && specifiers.length > 0) {
586
+ for (const spec of specifiers) {
587
+ const s = spec;
588
+ const exportedName = s.exported?.name ?? s.local?.name;
589
+ if (exportedName && isPascalCase(exportedName)) {
590
+ found = true;
591
+ return;
592
+ }
593
+ }
594
+ }
595
+ }
596
+ });
597
+ return found;
598
+ }
599
+ // ── AST walkers ─────────────────────────────────────────────────────────
600
+ /**
601
+ * Keys that are NOT AST children — skip to avoid circular references from
602
+ * eslint-scope annotations and addASTMetadata patches.
603
+ */
604
+ const SKIP_KEYS = new Set(['type', 'range', 'loc', 'start', 'end', 'parent', 'scope', 'raw', 'trailingComments', 'leadingComments', 'innerComments']);
605
+ /**
606
+ * Walk AST tracking JSX-relevant context: sibling position and conditionality.
607
+ */
608
+ function walkWithContext(node, callback, context = { siblingIndex: 0, isConditional: false }) {
609
+ if (!node || typeof node !== 'object')
610
+ return;
611
+ if (Array.isArray(node)) {
612
+ // When iterating an array of JSX children, track sibling index
613
+ // Count only JSXElement siblings (skip text, expressions, whitespace)
614
+ let elementIndex = 0;
615
+ for (const child of node) {
616
+ const childObj = child;
617
+ const isJSXElement = childObj?.type === 'JSXElement' || childObj?.type === 'JSXFragment';
618
+ walkWithContext(child, callback, {
619
+ ...context,
620
+ siblingIndex: isJSXElement ? elementIndex : context.siblingIndex,
621
+ });
622
+ if (isJSXElement)
623
+ elementIndex++;
624
+ }
625
+ return;
626
+ }
627
+ const obj = node;
628
+ if (typeof obj.type !== 'string')
629
+ return;
630
+ const asNode = obj;
631
+ callback(asNode, context);
632
+ // Propagate conditionality into children of conditional expressions
633
+ const isConditionalNode = obj.type === 'ConditionalExpression' ||
634
+ (obj.type === 'LogicalExpression' && (obj.operator === '&&' || obj.operator === '||'));
635
+ for (const key of Object.keys(obj)) {
636
+ if (SKIP_KEYS.has(key))
637
+ continue;
638
+ const childContext = {
639
+ siblingIndex: context.siblingIndex,
640
+ isConditional: context.isConditional || isConditionalNode,
641
+ };
642
+ // For JSXElement.children, reset sibling counting
643
+ if (key === 'children' && (obj.type === 'JSXElement' || obj.type === 'JSXFragment')) {
644
+ childContext.siblingIndex = 0;
645
+ }
646
+ walkWithContext(obj[key], callback, childContext);
647
+ }
648
+ }
649
+ function walkNode(node, callback) {
650
+ if (!node || typeof node !== 'object')
651
+ return;
652
+ if (Array.isArray(node)) {
653
+ for (const child of node)
654
+ walkNode(child, callback);
655
+ return;
656
+ }
657
+ const obj = node;
658
+ if (typeof obj.type !== 'string')
659
+ return;
660
+ callback(obj);
661
+ for (const key of Object.keys(obj)) {
662
+ if (SKIP_KEYS.has(key))
663
+ continue;
664
+ walkNode(obj[key], callback);
665
+ }
666
+ }
667
+ function walkWithParentNode(node, parent, callback) {
668
+ if (!node || typeof node !== 'object')
669
+ return;
670
+ if (Array.isArray(node)) {
671
+ for (const child of node)
672
+ walkWithParentNode(child, parent, callback);
673
+ return;
674
+ }
675
+ const obj = node;
676
+ if (typeof obj.type !== 'string')
677
+ return;
678
+ const asNode = obj;
679
+ callback(asNode, parent);
680
+ for (const key of Object.keys(obj)) {
681
+ if (SKIP_KEYS.has(key))
682
+ continue;
683
+ walkWithParentNode(obj[key], asNode, callback);
684
+ }
685
+ }
686
+ //# sourceMappingURL=lazy.js.map