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.
- package/README.md +378 -0
- package/dist/analyzer.d.ts +11 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +330 -0
- package/dist/analyzer.js.map +1 -0
- package/dist/ast-compat.d.ts +11 -0
- package/dist/ast-compat.d.ts.map +1 -0
- package/dist/ast-compat.js +84 -0
- package/dist/ast-compat.js.map +1 -0
- package/dist/classify/boundary.d.ts +30 -0
- package/dist/classify/boundary.d.ts.map +1 -0
- package/dist/classify/boundary.js +145 -0
- package/dist/classify/boundary.js.map +1 -0
- package/dist/classify/browser-globals.d.ts +29 -0
- package/dist/classify/browser-globals.d.ts.map +1 -0
- package/dist/classify/browser-globals.js +197 -0
- package/dist/classify/browser-globals.js.map +1 -0
- package/dist/classify/index.d.ts +14 -0
- package/dist/classify/index.d.ts.map +1 -0
- package/dist/classify/index.js +294 -0
- package/dist/classify/index.js.map +1 -0
- package/dist/classify/lazy-llm.d.ts +122 -0
- package/dist/classify/lazy-llm.d.ts.map +1 -0
- package/dist/classify/lazy-llm.js +142 -0
- package/dist/classify/lazy-llm.js.map +1 -0
- package/dist/classify/lazy.d.ts +23 -0
- package/dist/classify/lazy.d.ts.map +1 -0
- package/dist/classify/lazy.js +686 -0
- package/dist/classify/lazy.js.map +1 -0
- package/dist/classify/llm-client.d.ts +59 -0
- package/dist/classify/llm-client.d.ts.map +1 -0
- package/dist/classify/llm-client.js +193 -0
- package/dist/classify/llm-client.js.map +1 -0
- package/dist/classify/purity.d.ts +21 -0
- package/dist/classify/purity.d.ts.map +1 -0
- package/dist/classify/purity.js +47 -0
- package/dist/classify/purity.js.map +1 -0
- package/dist/classify/react-patterns.d.ts +15 -0
- package/dist/classify/react-patterns.d.ts.map +1 -0
- package/dist/classify/react-patterns.js +82 -0
- package/dist/classify/react-patterns.js.map +1 -0
- package/dist/classify/taint.d.ts +32 -0
- package/dist/classify/taint.d.ts.map +1 -0
- package/dist/classify/taint.js +68 -0
- package/dist/classify/taint.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +109 -0
- package/dist/cli.js.map +1 -0
- package/dist/extract/chunk-module.d.ts +20 -0
- package/dist/extract/chunk-module.d.ts.map +1 -0
- package/dist/extract/chunk-module.js +163 -0
- package/dist/extract/chunk-module.js.map +1 -0
- package/dist/extract/client-stub.d.ts +25 -0
- package/dist/extract/client-stub.d.ts.map +1 -0
- package/dist/extract/client-stub.js +233 -0
- package/dist/extract/client-stub.js.map +1 -0
- package/dist/extract/import-resolver.d.ts +20 -0
- package/dist/extract/import-resolver.d.ts.map +1 -0
- package/dist/extract/import-resolver.js +51 -0
- package/dist/extract/import-resolver.js.map +1 -0
- package/dist/extract/index.d.ts +20 -0
- package/dist/extract/index.d.ts.map +1 -0
- package/dist/extract/index.js +105 -0
- package/dist/extract/index.js.map +1 -0
- package/dist/extract/lazy-transform.d.ts +14 -0
- package/dist/extract/lazy-transform.d.ts.map +1 -0
- package/dist/extract/lazy-transform.js +473 -0
- package/dist/extract/lazy-transform.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.d.ts +7 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +535 -0
- package/dist/plugin.js.map +1 -0
- package/dist/runtime/index.d.ts +28 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +73 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/vite.d.ts +3 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +3 -0
- package/dist/vite.js.map +1 -0
- package/dist/webpack.d.ts +3 -0
- package/dist/webpack.d.ts.map +1 -0
- package/dist/webpack.js +3 -0
- package/dist/webpack.js.map +1 -0
- 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
|