phantom-build 0.1.1 → 0.2.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/dist/classify/index.d.ts +21 -1
- package/dist/classify/index.d.ts.map +1 -1
- package/dist/classify/index.js +19 -4
- package/dist/classify/index.js.map +1 -1
- package/dist/classify/ssr-boundary.d.ts +11 -0
- package/dist/classify/ssr-boundary.d.ts.map +1 -0
- package/dist/classify/ssr-boundary.js +760 -0
- package/dist/classify/ssr-boundary.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +96 -3
- package/dist/plugin.js.map +1 -1
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { walkNode } from './index.js';
|
|
2
|
+
import { isBrowserGlobal, isAmbiguousGlobal } from './browser-globals.js';
|
|
3
|
+
import { CLIENT_ONLY_HOOKS } from './react-patterns.js';
|
|
4
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
5
|
+
/**
|
|
6
|
+
* Classify all components in a module for SSR safety.
|
|
7
|
+
*
|
|
8
|
+
* Uses intermediate results from the classification pipeline to avoid
|
|
9
|
+
* redundant AST walks. The classification context provides taint results,
|
|
10
|
+
* hook contexts, and event handler contexts already computed.
|
|
11
|
+
*/
|
|
12
|
+
export function classifyModuleSSR(analyzed, sourceCode, context) {
|
|
13
|
+
// Step 1: Check for top-level browser access
|
|
14
|
+
const topLevelResult = detectTopLevelBrowserAccess(analyzed);
|
|
15
|
+
// Step 2: Find component functions
|
|
16
|
+
const components = identifyComponents(analyzed);
|
|
17
|
+
// Step 3: Detect typeof window guards across the module
|
|
18
|
+
const guardedSpans = detectTypeofWindowGuards(analyzed.ast);
|
|
19
|
+
// Step 4: Classify each component
|
|
20
|
+
const results = [];
|
|
21
|
+
for (const comp of components) {
|
|
22
|
+
const renderPath = analyzeRenderPath(comp, analyzed, context, guardedSpans, sourceCode);
|
|
23
|
+
const result = classifyComponent(comp.name, renderPath, topLevelResult.hasTopLevelBrowserAccess);
|
|
24
|
+
results.push(result);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
components: results,
|
|
28
|
+
hasTopLevelBrowserAccess: topLevelResult.hasTopLevelBrowserAccess,
|
|
29
|
+
topLevelBrowserAPIs: topLevelResult.topLevelBrowserAPIs,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// ── Component Identification ──────────────────────────────────────────
|
|
33
|
+
/**
|
|
34
|
+
* Identify React component functions in the module.
|
|
35
|
+
*
|
|
36
|
+
* A function is considered a component if:
|
|
37
|
+
* 1. It has a PascalCase name (starts with uppercase)
|
|
38
|
+
* 2. Its body contains JSX
|
|
39
|
+
* 3. It's exported (or could be — we're generous here)
|
|
40
|
+
*/
|
|
41
|
+
function identifyComponents(analyzed) {
|
|
42
|
+
const components = [];
|
|
43
|
+
const functionNodes = buildFunctionNodeMap(analyzed);
|
|
44
|
+
for (const fn of analyzed.functions) {
|
|
45
|
+
// Must have a PascalCase name
|
|
46
|
+
if (fn.name === '<anonymous>' || !/^[A-Z]/.test(fn.name))
|
|
47
|
+
continue;
|
|
48
|
+
// Must contain JSX in its body
|
|
49
|
+
const node = functionNodes.get(`${fn.span.start}:${fn.span.end}`);
|
|
50
|
+
if (!node || !containsJSX(node))
|
|
51
|
+
continue;
|
|
52
|
+
components.push(fn);
|
|
53
|
+
}
|
|
54
|
+
return components;
|
|
55
|
+
}
|
|
56
|
+
// ── Render Path Analysis ──────────────────────────────────────────────
|
|
57
|
+
/**
|
|
58
|
+
* Analyze the "render path" of a component — the code that runs during
|
|
59
|
+
* renderToString() on the server.
|
|
60
|
+
*
|
|
61
|
+
* The render path EXCLUDES:
|
|
62
|
+
* - useEffect/useLayoutEffect callback bodies (never run during SSR)
|
|
63
|
+
* - Event handler functions (never run during SSR)
|
|
64
|
+
* - Code inside `typeof window !== 'undefined'` guards
|
|
65
|
+
*
|
|
66
|
+
* The render path INCLUDES:
|
|
67
|
+
* - Top-level component body statements
|
|
68
|
+
* - useMemo/useCallback callback bodies (these DO run during SSR)
|
|
69
|
+
* - Conditional logic affecting JSX
|
|
70
|
+
*/
|
|
71
|
+
function analyzeRenderPath(component, analyzed, context, guardedSpans, _sourceCode) {
|
|
72
|
+
const { taintResults, hookContexts, eventHandlerContexts } = context;
|
|
73
|
+
const renderGlobals = [];
|
|
74
|
+
const browserAPIs = [];
|
|
75
|
+
const ambiguousAPIs = [];
|
|
76
|
+
const hooks = [];
|
|
77
|
+
let hasWindowGuards = false;
|
|
78
|
+
let hasEventHandlers = false;
|
|
79
|
+
// Collect hooks used by this component by checking child functions
|
|
80
|
+
for (const fn of analyzed.functions) {
|
|
81
|
+
// Only consider functions nested inside this component
|
|
82
|
+
if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const hookName = hookContexts.get(fn);
|
|
86
|
+
if (hookName && !hooks.includes(hookName)) {
|
|
87
|
+
hooks.push(hookName);
|
|
88
|
+
}
|
|
89
|
+
if (eventHandlerContexts.has(fn)) {
|
|
90
|
+
hasEventHandlers = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Also detect hooks from direct calls in the component body (useState, useRef, etc.)
|
|
94
|
+
const componentNode = findFunctionNode(analyzed.ast, component.span);
|
|
95
|
+
if (componentNode) {
|
|
96
|
+
collectDirectHookCalls(componentNode, hooks);
|
|
97
|
+
}
|
|
98
|
+
// Now analyze globals in the render path.
|
|
99
|
+
//
|
|
100
|
+
// IMPORTANT: We do NOT use component.globals as a starting point because
|
|
101
|
+
// eslint-scope's "through" references include globals from ALL nested scopes
|
|
102
|
+
// (useEffect callbacks, event handlers, etc.). Instead, we build the render
|
|
103
|
+
// path globals bottom-up: only include globals from nested functions that
|
|
104
|
+
// ARE in the render path.
|
|
105
|
+
// Step A: Identify which nested functions are excluded from the render path
|
|
106
|
+
const excludedSpans = new Set();
|
|
107
|
+
for (const fn of analyzed.functions) {
|
|
108
|
+
if (fn === component)
|
|
109
|
+
continue;
|
|
110
|
+
if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const hookName = hookContexts.get(fn);
|
|
114
|
+
if (hookName && CLIENT_ONLY_HOOKS.has(hookName)) {
|
|
115
|
+
excludedSpans.add(`${fn.span.start}:${fn.span.end}`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (eventHandlerContexts.has(fn)) {
|
|
119
|
+
excludedSpans.add(`${fn.span.start}:${fn.span.end}`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const spanKey = `${fn.span.start}:${fn.span.end}`;
|
|
123
|
+
if (guardedSpans.has(spanKey)) {
|
|
124
|
+
hasWindowGuards = true;
|
|
125
|
+
excludedSpans.add(spanKey);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Step B: Collect render path globals — only from non-excluded functions
|
|
130
|
+
const renderPathGlobals = new Set();
|
|
131
|
+
for (const fn of analyzed.functions) {
|
|
132
|
+
if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const spanKey = `${fn.span.start}:${fn.span.end}`;
|
|
136
|
+
// Skip excluded functions
|
|
137
|
+
if (fn !== component && excludedSpans.has(spanKey))
|
|
138
|
+
continue;
|
|
139
|
+
// Skip functions nested inside an excluded function
|
|
140
|
+
if (fn !== component && isInsideExcludedSpan(fn.span.start, fn.span.end, excludedSpans))
|
|
141
|
+
continue;
|
|
142
|
+
// For the component itself, we can't use its globals directly because
|
|
143
|
+
// they include through-references from nested scopes. Instead, only
|
|
144
|
+
// include the component's globals if they DON'T come from excluded children.
|
|
145
|
+
if (fn === component) {
|
|
146
|
+
// The component's "own" globals (not from nested functions) are those
|
|
147
|
+
// that appear in its globals list but aren't exclusively from excluded children.
|
|
148
|
+
// We compute this by: component.globals MINUS globals that ONLY appear in excluded children.
|
|
149
|
+
const excludedOnlyGlobals = collectGlobalsOnlyInExcluded(component, analyzed.functions, excludedSpans);
|
|
150
|
+
for (const g of component.globals) {
|
|
151
|
+
if (!excludedOnlyGlobals.has(g)) {
|
|
152
|
+
renderPathGlobals.add(g);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
for (const g of fn.globals) {
|
|
158
|
+
renderPathGlobals.add(g);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Step C: Remove guarded globals and typeof-checked globals
|
|
163
|
+
if (componentNode && guardedSpans.size > 0) {
|
|
164
|
+
const guardedGlobals = collectGuardedGlobals(componentNode, guardedSpans);
|
|
165
|
+
if (guardedGlobals.size > 0) {
|
|
166
|
+
hasWindowGuards = true;
|
|
167
|
+
for (const g of guardedGlobals) {
|
|
168
|
+
renderPathGlobals.delete(g);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Step C2: Handle `typeof <global>` — the identifier in `typeof window !== 'undefined'`
|
|
173
|
+
// is safe (typeof never throws) and should not count as browser API usage.
|
|
174
|
+
// If a global is only referenced via typeof checks and inside guards, remove it.
|
|
175
|
+
if (componentNode) {
|
|
176
|
+
for (const guardGlobal of TYPEOF_GUARD_GLOBALS) {
|
|
177
|
+
if (renderPathGlobals.has(guardGlobal)) {
|
|
178
|
+
const allUsages = countGlobalUsages(componentNode, guardGlobal);
|
|
179
|
+
const typeofUsages = countTypeofUsages(componentNode, guardGlobal);
|
|
180
|
+
if (allUsages > 0 && allUsages === typeofUsages) {
|
|
181
|
+
// All references are typeof checks — safe to remove
|
|
182
|
+
renderPathGlobals.delete(guardGlobal);
|
|
183
|
+
hasWindowGuards = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// Step D: Classify the render path globals
|
|
189
|
+
for (const g of renderPathGlobals) {
|
|
190
|
+
renderGlobals.push(g);
|
|
191
|
+
if (isBrowserGlobal(g)) {
|
|
192
|
+
browserAPIs.push(g);
|
|
193
|
+
}
|
|
194
|
+
else if (isAmbiguousGlobal(g)) {
|
|
195
|
+
ambiguousAPIs.push(g);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
renderGlobals,
|
|
200
|
+
browserAPIs,
|
|
201
|
+
ambiguousAPIs,
|
|
202
|
+
hooks,
|
|
203
|
+
hasWindowGuards,
|
|
204
|
+
hasEventHandlers,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// ── typeof window Guard Detection ─────────────────────────────────────
|
|
208
|
+
/**
|
|
209
|
+
* Detect `typeof window` guard patterns in the AST.
|
|
210
|
+
*
|
|
211
|
+
* Supported patterns (for window, document, navigator, self):
|
|
212
|
+
* - `if (typeof window !== 'undefined') { ... }`
|
|
213
|
+
* - `if (typeof document === 'undefined') { ... } else { ... }`
|
|
214
|
+
* - `typeof navigator !== 'undefined' && expr` (short-circuit)
|
|
215
|
+
* - `const isClient = typeof window !== 'undefined'`
|
|
216
|
+
* - Early return: `if (typeof window === 'undefined') return ...` (code after is browser-only)
|
|
217
|
+
* - Early return: `if (typeof document !== 'undefined') return ...` (code after is SSR-safe)
|
|
218
|
+
*
|
|
219
|
+
* Returns a Set of span keys ("start:end") for AST nodes that are
|
|
220
|
+
* inside a browser-only guard (the consequent of the check).
|
|
221
|
+
*/
|
|
222
|
+
function detectTypeofWindowGuards(ast) {
|
|
223
|
+
const guardedSpans = new Set();
|
|
224
|
+
walkNode(ast, (node) => {
|
|
225
|
+
// Pattern 1: if (typeof window !== 'undefined') { ... }
|
|
226
|
+
if (node.type === 'IfStatement') {
|
|
227
|
+
const ifNode = node;
|
|
228
|
+
if (isTypeofWindowCheck(ifNode.test, '!==')) {
|
|
229
|
+
// The consequent is browser-only code
|
|
230
|
+
addSpansFromNode(ifNode.consequent, guardedSpans);
|
|
231
|
+
}
|
|
232
|
+
else if (isTypeofWindowCheck(ifNode.test, '===')) {
|
|
233
|
+
// Inverted: typeof window === 'undefined' → else branch is browser code
|
|
234
|
+
if (ifNode.alternate) {
|
|
235
|
+
addSpansFromNode(ifNode.alternate, guardedSpans);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// Pattern 2: typeof window !== 'undefined' && expr (short-circuit)
|
|
240
|
+
if (node.type === 'LogicalExpression') {
|
|
241
|
+
const logical = node;
|
|
242
|
+
if (logical.operator === '&&' && isTypeofWindowCheck(logical.left, '!==')) {
|
|
243
|
+
addSpansFromNode(logical.right, guardedSpans);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
// Pattern 3 (Mini-CFG): Early return guards
|
|
248
|
+
// Detect `if (typeof window === 'undefined') { return ... }` WITHOUT an else,
|
|
249
|
+
// which makes all sibling statements after the if unreachable on the client.
|
|
250
|
+
// And the inverse: `if (typeof window !== 'undefined') { return ... }` WITHOUT else,
|
|
251
|
+
// which makes sibling statements after the if unreachable on the server (browser-only).
|
|
252
|
+
detectEarlyReturnGuards(ast, guardedSpans);
|
|
253
|
+
return guardedSpans;
|
|
254
|
+
}
|
|
255
|
+
// ── Mini-CFG: Early Return Guard Detection ────────────────────────────
|
|
256
|
+
/**
|
|
257
|
+
* Detect early-return guard patterns in function bodies.
|
|
258
|
+
*
|
|
259
|
+
* This is a targeted mini-CFG analysis that handles the common pattern:
|
|
260
|
+
*
|
|
261
|
+
* function Component() {
|
|
262
|
+
* if (typeof window === 'undefined') {
|
|
263
|
+
* return <div>Server fallback</div>;
|
|
264
|
+
* }
|
|
265
|
+
* // Everything here is browser-only (unreachable during SSR)
|
|
266
|
+
* const width = window.innerWidth;
|
|
267
|
+
* return <div>{width}</div>;
|
|
268
|
+
* }
|
|
269
|
+
*
|
|
270
|
+
* And the inverse:
|
|
271
|
+
*
|
|
272
|
+
* function Component() {
|
|
273
|
+
* if (typeof window !== 'undefined') {
|
|
274
|
+
* return <div>{window.innerWidth}</div>;
|
|
275
|
+
* }
|
|
276
|
+
* // Everything here is SSR-only (unreachable on client)
|
|
277
|
+
* return <div>Server content</div>;
|
|
278
|
+
* }
|
|
279
|
+
*
|
|
280
|
+
* For each function body (BlockStatement), walks statements in order.
|
|
281
|
+
* When an if-statement with a typeof window check has a return in its
|
|
282
|
+
* consequent and no else branch, all subsequent sibling statements are
|
|
283
|
+
* marked as guarded (browser-only or SSR-only depending on the check direction).
|
|
284
|
+
*/
|
|
285
|
+
function detectEarlyReturnGuards(ast, guardedSpans) {
|
|
286
|
+
// Find all function bodies (BlockStatements that are function bodies)
|
|
287
|
+
walkNode(ast, (node) => {
|
|
288
|
+
if (node.type === 'FunctionDeclaration' ||
|
|
289
|
+
node.type === 'FunctionExpression' ||
|
|
290
|
+
node.type === 'ArrowFunctionExpression') {
|
|
291
|
+
const fn = node;
|
|
292
|
+
if (fn.body.type === 'BlockStatement') {
|
|
293
|
+
detectEarlyReturnInBlock(fn.body, guardedSpans);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Walk a block statement's direct children looking for early return guards.
|
|
300
|
+
*/
|
|
301
|
+
function detectEarlyReturnInBlock(block, guardedSpans) {
|
|
302
|
+
const body = block.body;
|
|
303
|
+
if (!body || !Array.isArray(body))
|
|
304
|
+
return;
|
|
305
|
+
for (let i = 0; i < body.length; i++) {
|
|
306
|
+
const stmt = body[i];
|
|
307
|
+
if (stmt.type !== 'IfStatement')
|
|
308
|
+
continue;
|
|
309
|
+
const ifStmt = stmt;
|
|
310
|
+
// Only handle if-without-else (early return pattern)
|
|
311
|
+
if (ifStmt.alternate)
|
|
312
|
+
continue;
|
|
313
|
+
// Check if the consequent contains a return statement at the top level
|
|
314
|
+
if (!blockContainsReturn(ifStmt.consequent))
|
|
315
|
+
continue;
|
|
316
|
+
// Determine what kind of guard this is
|
|
317
|
+
const isServerCheck = isTypeofWindowCheck(ifStmt.test, '===');
|
|
318
|
+
const isClientCheck = isTypeofWindowCheck(ifStmt.test, '!==');
|
|
319
|
+
if (!isServerCheck && !isClientCheck)
|
|
320
|
+
continue;
|
|
321
|
+
// All subsequent sibling statements are unreachable for one side:
|
|
322
|
+
// - typeof window === 'undefined' + return → code after is browser-only (guarded)
|
|
323
|
+
// - typeof window !== 'undefined' + return → code after is SSR-only (safe, not guarded)
|
|
324
|
+
//
|
|
325
|
+
// For SSR boundary detection, we only care about marking browser-only code as guarded.
|
|
326
|
+
// When the check is `=== 'undefined'` (server check), code after return is client-only.
|
|
327
|
+
if (isServerCheck) {
|
|
328
|
+
for (let j = i + 1; j < body.length; j++) {
|
|
329
|
+
addSpansFromNode(body[j], guardedSpans);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// When the check is `!== 'undefined'` (client check) with a return,
|
|
333
|
+
// code after is server-only (safe). We don't need to guard it, but we DO
|
|
334
|
+
// need to mark the consequent (the return branch itself) as guarded since
|
|
335
|
+
// it's browser-only code.
|
|
336
|
+
// Note: The consequent is already guarded by the Pattern 1 detection above.
|
|
337
|
+
// Early return found — no need to check further statements in this block
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Check if a statement or block contains a return statement at its top level.
|
|
343
|
+
* Handles both bare return and block with return.
|
|
344
|
+
*/
|
|
345
|
+
function blockContainsReturn(node) {
|
|
346
|
+
if (node.type === 'ReturnStatement')
|
|
347
|
+
return true;
|
|
348
|
+
if (node.type === 'BlockStatement') {
|
|
349
|
+
const body = node.body;
|
|
350
|
+
if (!body)
|
|
351
|
+
return false;
|
|
352
|
+
return body.some((stmt) => stmt.type === 'ReturnStatement');
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Check if a node is a `typeof window !== 'undefined'` or `typeof window === 'undefined'` test.
|
|
358
|
+
*/
|
|
359
|
+
function isTypeofWindowCheck(node, operator) {
|
|
360
|
+
if (node.type !== 'BinaryExpression')
|
|
361
|
+
return false;
|
|
362
|
+
const binary = node;
|
|
363
|
+
if (binary.operator !== operator && binary.operator !== operator.slice(0, 2)) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
// Check for typeof window on left
|
|
367
|
+
const left = binary.left;
|
|
368
|
+
const right = binary.right;
|
|
369
|
+
return ((isTypeofBrowserGlobal(left) && isUndefinedLiteral(right)) ||
|
|
370
|
+
(isUndefinedLiteral(left) && isTypeofBrowserGlobal(right)));
|
|
371
|
+
}
|
|
372
|
+
/** Browser globals commonly used in typeof SSR guards */
|
|
373
|
+
const TYPEOF_GUARD_GLOBALS = new Set(['window', 'document', 'navigator', 'self']);
|
|
374
|
+
function isTypeofBrowserGlobal(node) {
|
|
375
|
+
if (node.type !== 'UnaryExpression')
|
|
376
|
+
return false;
|
|
377
|
+
const unary = node;
|
|
378
|
+
if (unary.operator !== 'typeof')
|
|
379
|
+
return false;
|
|
380
|
+
return (unary.argument.type === 'Identifier' &&
|
|
381
|
+
TYPEOF_GUARD_GLOBALS.has(unary.argument.name));
|
|
382
|
+
}
|
|
383
|
+
function isUndefinedLiteral(node) {
|
|
384
|
+
if (node.type === 'Literal') {
|
|
385
|
+
return node.value === 'undefined';
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Add all function-level spans within a node to the guarded set.
|
|
391
|
+
*/
|
|
392
|
+
function addSpansFromNode(node, spans) {
|
|
393
|
+
const start = node.start;
|
|
394
|
+
const end = node.end;
|
|
395
|
+
if (start != null && end != null) {
|
|
396
|
+
spans.add(`${start}:${end}`);
|
|
397
|
+
}
|
|
398
|
+
// Also add spans for any nested functions
|
|
399
|
+
walkNode(node, (child) => {
|
|
400
|
+
if (child.type === 'FunctionDeclaration' ||
|
|
401
|
+
child.type === 'FunctionExpression' ||
|
|
402
|
+
child.type === 'ArrowFunctionExpression') {
|
|
403
|
+
const cStart = child.start;
|
|
404
|
+
const cEnd = child.end;
|
|
405
|
+
if (cStart != null && cEnd != null) {
|
|
406
|
+
spans.add(`${cStart}:${cEnd}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
// ── Top-Level Browser Access Detection ────────────────────────────────
|
|
412
|
+
/**
|
|
413
|
+
* Detect browser API references at module scope (outside any function).
|
|
414
|
+
*
|
|
415
|
+
* If found, the entire module is ClientOnly regardless of component analysis.
|
|
416
|
+
* Example: `const width = window.innerWidth;` at top level.
|
|
417
|
+
*/
|
|
418
|
+
function detectTopLevelBrowserAccess(analyzed) {
|
|
419
|
+
const topLevelBrowserAPIs = [];
|
|
420
|
+
// Module-level globals are variables referenced outside any function scope.
|
|
421
|
+
// We check for globals that aren't captured by any function — they're at module scope.
|
|
422
|
+
// The AST body statements outside function declarations are module-scope.
|
|
423
|
+
// Walk top-level statements of the module
|
|
424
|
+
const body = analyzed.ast.body;
|
|
425
|
+
const functionSpans = new Set();
|
|
426
|
+
for (const fn of analyzed.functions) {
|
|
427
|
+
functionSpans.add(`${fn.span.start}:${fn.span.end}`);
|
|
428
|
+
}
|
|
429
|
+
// Collect identifiers at module scope (not inside any function).
|
|
430
|
+
// Skip identifiers that are the argument of a `typeof` expression,
|
|
431
|
+
// since `typeof window` is safe and doesn't actually access window.
|
|
432
|
+
const typeofArgPositions = new Set();
|
|
433
|
+
walkNode(body, (node) => {
|
|
434
|
+
if (node.type === 'UnaryExpression') {
|
|
435
|
+
const unary = node;
|
|
436
|
+
if (unary.operator === 'typeof' && unary.argument.type === 'Identifier') {
|
|
437
|
+
const argPos = unary.argument.start;
|
|
438
|
+
if (argPos != null)
|
|
439
|
+
typeofArgPositions.add(argPos);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
walkNode(body, (node) => {
|
|
444
|
+
if (node.type === 'Identifier') {
|
|
445
|
+
const name = node.name;
|
|
446
|
+
if (isBrowserGlobal(name)) {
|
|
447
|
+
const pos = node.start;
|
|
448
|
+
if (pos != null && !isInsideAnyFunction(pos, analyzed.functions)) {
|
|
449
|
+
// Skip identifiers inside typeof expressions (typeof window is safe)
|
|
450
|
+
if (typeofArgPositions.has(pos))
|
|
451
|
+
return;
|
|
452
|
+
if (!topLevelBrowserAPIs.includes(name)) {
|
|
453
|
+
topLevelBrowserAPIs.push(name);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
return {
|
|
460
|
+
hasTopLevelBrowserAccess: topLevelBrowserAPIs.length > 0,
|
|
461
|
+
topLevelBrowserAPIs,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function isInsideAnyFunction(pos, functions) {
|
|
465
|
+
return functions.some(fn => pos >= fn.span.start && pos <= fn.span.end);
|
|
466
|
+
}
|
|
467
|
+
// ── Component Classification ──────────────────────────────────────────
|
|
468
|
+
/**
|
|
469
|
+
* Classify a single component based on its render path analysis.
|
|
470
|
+
*/
|
|
471
|
+
function classifyComponent(name, renderPath, hasTopLevelBrowserAccess) {
|
|
472
|
+
let classification;
|
|
473
|
+
let confidence;
|
|
474
|
+
const reasons = [];
|
|
475
|
+
// Rule 1: Module has top-level browser access → entire module is ClientOnly
|
|
476
|
+
if (hasTopLevelBrowserAccess) {
|
|
477
|
+
classification = 'ClientOnly';
|
|
478
|
+
confidence = 1.0;
|
|
479
|
+
reasons.push('Module has top-level browser API access');
|
|
480
|
+
}
|
|
481
|
+
// Rule 2: Render path references browser-only APIs → ClientOnly
|
|
482
|
+
else if (renderPath.browserAPIs.length > 0) {
|
|
483
|
+
classification = 'ClientOnly';
|
|
484
|
+
confidence = 0.95;
|
|
485
|
+
reasons.push(`Render path references browser APIs: ${renderPath.browserAPIs.join(', ')}`);
|
|
486
|
+
}
|
|
487
|
+
// Rule 3: No hooks, no handlers, no guards, pure props→JSX → FullyStatic
|
|
488
|
+
else if (renderPath.hooks.length === 0 &&
|
|
489
|
+
!renderPath.hasEventHandlers &&
|
|
490
|
+
!renderPath.hasWindowGuards &&
|
|
491
|
+
renderPath.ambiguousAPIs.length === 0) {
|
|
492
|
+
classification = 'FullyStatic';
|
|
493
|
+
confidence = 0.9;
|
|
494
|
+
reasons.push('Pure props-to-JSX component with no hooks or handlers');
|
|
495
|
+
}
|
|
496
|
+
// Rule 4: Has hooks/state but render path is clean → SSRSafe
|
|
497
|
+
else if (renderPath.browserAPIs.length === 0) {
|
|
498
|
+
classification = 'SSRSafe';
|
|
499
|
+
confidence = renderPath.ambiguousAPIs.length > 0 ? 0.7 : 0.85;
|
|
500
|
+
reasons.push('Render path is free of browser APIs');
|
|
501
|
+
if (renderPath.hooks.length > 0) {
|
|
502
|
+
reasons.push(`Uses hooks: ${renderPath.hooks.join(', ')}`);
|
|
503
|
+
}
|
|
504
|
+
if (renderPath.hasEventHandlers) {
|
|
505
|
+
reasons.push('Has event handlers (excluded from render path)');
|
|
506
|
+
}
|
|
507
|
+
if (renderPath.hasWindowGuards) {
|
|
508
|
+
reasons.push('Browser access is guarded by typeof window checks');
|
|
509
|
+
}
|
|
510
|
+
if (renderPath.ambiguousAPIs.length > 0) {
|
|
511
|
+
reasons.push(`Cross-environment APIs in render: ${renderPath.ambiguousAPIs.join(', ')}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Fallback (shouldn't reach here given the rules above)
|
|
515
|
+
else {
|
|
516
|
+
classification = 'ClientOnly';
|
|
517
|
+
confidence = 0.5;
|
|
518
|
+
reasons.push('Could not determine SSR safety');
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
name,
|
|
522
|
+
classification,
|
|
523
|
+
confidence,
|
|
524
|
+
reasons,
|
|
525
|
+
renderPathBrowserAPIs: renderPath.browserAPIs,
|
|
526
|
+
hooks: renderPath.hooks,
|
|
527
|
+
hasWindowGuards: renderPath.hasWindowGuards,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
// ── Render Path Helpers ───────────────────────────────────────────────
|
|
531
|
+
/**
|
|
532
|
+
* Check if a span is nested inside any excluded span.
|
|
533
|
+
*/
|
|
534
|
+
function isInsideExcludedSpan(start, end, excludedSpans) {
|
|
535
|
+
for (const spanKey of excludedSpans) {
|
|
536
|
+
const [exStartStr, exEndStr] = spanKey.split(':');
|
|
537
|
+
const exStart = parseInt(exStartStr, 10);
|
|
538
|
+
const exEnd = parseInt(exEndStr, 10);
|
|
539
|
+
if (start >= exStart && end <= exEnd) {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Collect globals that ONLY appear in excluded child functions.
|
|
547
|
+
*
|
|
548
|
+
* A global is "excluded-only" if:
|
|
549
|
+
* - It appears in at least one excluded child function's globals
|
|
550
|
+
* - It does NOT appear in any non-excluded child function's globals
|
|
551
|
+
*
|
|
552
|
+
* These globals come from nested scopes (useEffect, event handlers) and
|
|
553
|
+
* should not count as render-path globals even though eslint-scope's
|
|
554
|
+
* through-references include them in the parent component's globals.
|
|
555
|
+
*/
|
|
556
|
+
function collectGlobalsOnlyInExcluded(component, functions, excludedSpans) {
|
|
557
|
+
const globalsInExcluded = new Set();
|
|
558
|
+
const globalsInRenderPath = new Set();
|
|
559
|
+
for (const fn of functions) {
|
|
560
|
+
if (fn === component)
|
|
561
|
+
continue;
|
|
562
|
+
if (fn.span.start < component.span.start || fn.span.end > component.span.end) {
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const spanKey = `${fn.span.start}:${fn.span.end}`;
|
|
566
|
+
const isExcluded = excludedSpans.has(spanKey) ||
|
|
567
|
+
isInsideExcludedSpan(fn.span.start, fn.span.end, excludedSpans);
|
|
568
|
+
for (const g of fn.globals) {
|
|
569
|
+
if (isExcluded) {
|
|
570
|
+
globalsInExcluded.add(g);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
globalsInRenderPath.add(g);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Return globals that are in excluded but NOT in render path
|
|
578
|
+
const excludedOnly = new Set();
|
|
579
|
+
for (const g of globalsInExcluded) {
|
|
580
|
+
if (!globalsInRenderPath.has(g)) {
|
|
581
|
+
excludedOnly.add(g);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return excludedOnly;
|
|
585
|
+
}
|
|
586
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
587
|
+
/**
|
|
588
|
+
* Build a map from span key to AST node for function nodes.
|
|
589
|
+
*/
|
|
590
|
+
function buildFunctionNodeMap(analyzed) {
|
|
591
|
+
const nodeMap = new Map();
|
|
592
|
+
walkNode(analyzed.ast, (node) => {
|
|
593
|
+
if (node.type === 'FunctionDeclaration' ||
|
|
594
|
+
node.type === 'FunctionExpression' ||
|
|
595
|
+
node.type === 'ArrowFunctionExpression') {
|
|
596
|
+
const start = node.start;
|
|
597
|
+
const end = node.end;
|
|
598
|
+
if (start != null && end != null) {
|
|
599
|
+
nodeMap.set(`${start}:${end}`, node);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
return nodeMap;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Check if an AST node contains any JSX elements or fragments.
|
|
607
|
+
*/
|
|
608
|
+
function containsJSX(node) {
|
|
609
|
+
let found = false;
|
|
610
|
+
walkNode(node, (n) => {
|
|
611
|
+
if (found)
|
|
612
|
+
return;
|
|
613
|
+
if (n.type.startsWith('JSX'))
|
|
614
|
+
found = true;
|
|
615
|
+
});
|
|
616
|
+
return found;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Find the AST node for a function by its span.
|
|
620
|
+
*/
|
|
621
|
+
function findFunctionNode(ast, span) {
|
|
622
|
+
let result = null;
|
|
623
|
+
walkNode(ast, (node) => {
|
|
624
|
+
if (result)
|
|
625
|
+
return;
|
|
626
|
+
if (node.type === 'FunctionDeclaration' ||
|
|
627
|
+
node.type === 'FunctionExpression' ||
|
|
628
|
+
node.type === 'ArrowFunctionExpression') {
|
|
629
|
+
const start = node.start;
|
|
630
|
+
const end = node.end;
|
|
631
|
+
if (start === span.start && end === span.end) {
|
|
632
|
+
result = node;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
return result;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Collect hook calls made directly in a component body (not inside nested functions).
|
|
640
|
+
* Detects patterns like: useState(), useRef(), useContext(), etc.
|
|
641
|
+
*
|
|
642
|
+
* Uses a shallow walk that stops at nested function boundaries to avoid
|
|
643
|
+
* attributing hooks from useEffect callbacks or helper functions to the
|
|
644
|
+
* component's direct hooks list.
|
|
645
|
+
*/
|
|
646
|
+
function collectDirectHookCalls(componentNode, hooks) {
|
|
647
|
+
walkNodeShallow(componentNode, (node) => {
|
|
648
|
+
if (node.type !== 'CallExpression')
|
|
649
|
+
return;
|
|
650
|
+
const call = node;
|
|
651
|
+
let hookName = null;
|
|
652
|
+
if (call.callee.type === 'Identifier') {
|
|
653
|
+
const name = call.callee.name;
|
|
654
|
+
if (name.startsWith('use'))
|
|
655
|
+
hookName = name;
|
|
656
|
+
}
|
|
657
|
+
else if (call.callee.type === 'MemberExpression') {
|
|
658
|
+
const prop = call.callee.property;
|
|
659
|
+
if (prop.type === 'Identifier') {
|
|
660
|
+
const name = prop.name;
|
|
661
|
+
if (name.startsWith('use'))
|
|
662
|
+
hookName = name;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (hookName && !hooks.includes(hookName)) {
|
|
666
|
+
hooks.push(hookName);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Walk an AST node recursively but stop at nested function boundaries.
|
|
672
|
+
* Does not recurse into FunctionDeclaration, FunctionExpression, or
|
|
673
|
+
* ArrowFunctionExpression children (other than the root node itself).
|
|
674
|
+
*/
|
|
675
|
+
function walkNodeShallow(node, callback, isRoot = true) {
|
|
676
|
+
if (!node || typeof node !== 'object')
|
|
677
|
+
return;
|
|
678
|
+
if (Array.isArray(node)) {
|
|
679
|
+
for (const child of node) {
|
|
680
|
+
walkNodeShallow(child, callback, false);
|
|
681
|
+
}
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const obj = node;
|
|
685
|
+
if (typeof obj.type !== 'string')
|
|
686
|
+
return;
|
|
687
|
+
// Stop at nested function boundaries (but visit the root node itself)
|
|
688
|
+
if (!isRoot) {
|
|
689
|
+
if (obj.type === 'FunctionDeclaration' ||
|
|
690
|
+
obj.type === 'FunctionExpression' ||
|
|
691
|
+
obj.type === 'ArrowFunctionExpression') {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
callback(obj);
|
|
696
|
+
for (const key of Object.keys(obj)) {
|
|
697
|
+
if (key === 'type')
|
|
698
|
+
continue;
|
|
699
|
+
walkNodeShallow(obj[key], callback, false);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Count total references to a specific identifier in a node.
|
|
704
|
+
*/
|
|
705
|
+
function countGlobalUsages(node, name) {
|
|
706
|
+
let count = 0;
|
|
707
|
+
walkNode(node, (n) => {
|
|
708
|
+
if (n.type === 'Identifier' && n.name === name) {
|
|
709
|
+
count++;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
return count;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Count how many times an identifier appears inside a `typeof` expression.
|
|
716
|
+
* `typeof window` is safe (never throws) and shouldn't count as API access.
|
|
717
|
+
*/
|
|
718
|
+
function countTypeofUsages(node, name) {
|
|
719
|
+
let count = 0;
|
|
720
|
+
walkNode(node, (n) => {
|
|
721
|
+
if (n.type === 'UnaryExpression') {
|
|
722
|
+
const unary = n;
|
|
723
|
+
if (unary.operator === 'typeof' &&
|
|
724
|
+
unary.argument.type === 'Identifier' &&
|
|
725
|
+
unary.argument.name === name) {
|
|
726
|
+
count++;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
return count;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Collect global identifiers that are inside guarded blocks.
|
|
734
|
+
* Used to filter out browser APIs that are safely guarded by typeof window checks.
|
|
735
|
+
*/
|
|
736
|
+
function collectGuardedGlobals(componentNode, guardedSpans) {
|
|
737
|
+
const guardedGlobals = new Set();
|
|
738
|
+
walkNode(componentNode, (node) => {
|
|
739
|
+
if (node.type !== 'Identifier')
|
|
740
|
+
return;
|
|
741
|
+
const name = node.name;
|
|
742
|
+
if (!isBrowserGlobal(name) && !isAmbiguousGlobal(name))
|
|
743
|
+
return;
|
|
744
|
+
const pos = node.start;
|
|
745
|
+
if (pos == null)
|
|
746
|
+
return;
|
|
747
|
+
// Check if this identifier is inside any guarded span
|
|
748
|
+
for (const spanKey of guardedSpans) {
|
|
749
|
+
const [startStr, endStr] = spanKey.split(':');
|
|
750
|
+
const start = parseInt(startStr, 10);
|
|
751
|
+
const end = parseInt(endStr, 10);
|
|
752
|
+
if (pos >= start && pos <= end) {
|
|
753
|
+
guardedGlobals.add(name);
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
return guardedGlobals;
|
|
759
|
+
}
|
|
760
|
+
//# sourceMappingURL=ssr-boundary.js.map
|