@pyreon/compiler 0.24.5 → 0.24.6
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/package.json +11 -13
- package/src/defer-inline.ts +0 -686
- package/src/event-names.ts +0 -65
- package/src/index.ts +0 -61
- package/src/island-audit.ts +0 -675
- package/src/jsx.ts +0 -2792
- package/src/load-native.ts +0 -156
- package/src/lpih.ts +0 -270
- package/src/manifest.ts +0 -280
- package/src/project-scanner.ts +0 -214
- package/src/pyreon-intercept.ts +0 -1029
- package/src/react-intercept.ts +0 -1217
- package/src/reactivity-lens.ts +0 -190
- package/src/ssg-audit.ts +0 -513
- package/src/test-audit.ts +0 -435
- package/src/tests/backend-parity-r7-r9.test.ts +0 -91
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
- package/src/tests/collapse-bail-census.test.ts +0 -330
- package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
- package/src/tests/component-child-no-wrap.test.ts +0 -204
- package/src/tests/defer-inline.test.ts +0 -387
- package/src/tests/depth-stress.test.ts +0 -16
- package/src/tests/detector-tag-consistency.test.ts +0 -101
- package/src/tests/dynamic-collapse-detector.test.ts +0 -164
- package/src/tests/dynamic-collapse-emit.test.ts +0 -192
- package/src/tests/dynamic-collapse-scan.test.ts +0 -111
- package/src/tests/element-valued-const-child.test.ts +0 -61
- package/src/tests/falsy-child-characterization.test.ts +0 -48
- package/src/tests/island-audit.test.ts +0 -524
- package/src/tests/jsx.test.ts +0 -2908
- package/src/tests/load-native.test.ts +0 -53
- package/src/tests/lpih.test.ts +0 -404
- package/src/tests/malformed-input-resilience.test.ts +0 -50
- package/src/tests/manifest-snapshot.test.ts +0 -55
- package/src/tests/native-equivalence.test.ts +0 -924
- package/src/tests/partial-collapse-detector.test.ts +0 -121
- package/src/tests/partial-collapse-emit.test.ts +0 -104
- package/src/tests/partial-collapse-robustness.test.ts +0 -53
- package/src/tests/project-scanner.test.ts +0 -269
- package/src/tests/prop-derived-shadow.test.ts +0 -96
- package/src/tests/pure-call-reactive-args.test.ts +0 -50
- package/src/tests/pyreon-intercept.test.ts +0 -816
- package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
- package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
- package/src/tests/r15-elemconst-propderived.test.ts +0 -47
- package/src/tests/r19-defer-inline-robust.test.ts +0 -54
- package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
- package/src/tests/react-intercept.test.ts +0 -1104
- package/src/tests/reactivity-lens.test.ts +0 -170
- package/src/tests/rocketstyle-collapse.test.ts +0 -208
- package/src/tests/runtime/control-flow.test.ts +0 -159
- package/src/tests/runtime/dom-properties.test.ts +0 -138
- package/src/tests/runtime/events.test.ts +0 -301
- package/src/tests/runtime/harness.ts +0 -94
- package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
- package/src/tests/runtime/reactive-props.test.ts +0 -81
- package/src/tests/runtime/signals.test.ts +0 -129
- package/src/tests/runtime/whitespace.test.ts +0 -106
- package/src/tests/signal-autocall-shadow.test.ts +0 -86
- package/src/tests/sourcemap-fidelity.test.ts +0 -77
- package/src/tests/ssg-audit.test.ts +0 -402
- package/src/tests/static-text-baking.test.ts +0 -64
- package/src/tests/test-audit.test.ts +0 -549
- package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/defer-inline.ts
DELETED
|
@@ -1,686 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline-children transform for `<Defer>`.
|
|
3
|
-
*
|
|
4
|
-
* Rewrites:
|
|
5
|
-
*
|
|
6
|
-
* import { Modal } from './Modal'
|
|
7
|
-
* <Defer when={open()}><Modal title="hi" /></Defer>
|
|
8
|
-
*
|
|
9
|
-
* into:
|
|
10
|
-
*
|
|
11
|
-
* <Defer when={open()} chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}>
|
|
12
|
-
* {(__C) => <__C title="hi" />}
|
|
13
|
-
* </Defer>
|
|
14
|
-
*
|
|
15
|
-
* The static `import { Modal } from './Modal'` is removed when `Modal` is
|
|
16
|
-
* referenced ONLY inside the Defer subtree — otherwise Rolldown would
|
|
17
|
-
* bundle the module statically and the dynamic import becomes a no-op.
|
|
18
|
-
*
|
|
19
|
-
* Scope (v2 — post #587 + this PR):
|
|
20
|
-
* - Multiple Defer elements per file: each rewritten independently.
|
|
21
|
-
* - Children: exactly ONE JSXElement, capitalised name (component
|
|
22
|
-
* reference). Self-closing OR with children. **Props ARE allowed**
|
|
23
|
-
* (post-v2) and pass through unchanged into the render-prop body —
|
|
24
|
-
* closure capture works naturally because the render-prop arrow
|
|
25
|
-
* captures the surrounding lexical scope.
|
|
26
|
-
* - Multiple non-whitespace children → bail with a warning.
|
|
27
|
-
* User must use the explicit `chunk` form with a render-prop.
|
|
28
|
-
* - Imports: default, named, **renamed** (`{ X as Y }`). Namespace
|
|
29
|
-
* imports (`* as M` + `<M.X />`) NOT supported — bail with a warning.
|
|
30
|
-
* - **Multi-specifier imports** (`{ A, B } from './x'`): only the
|
|
31
|
-
* binding used in Defer is removed; siblings stay intact.
|
|
32
|
-
* - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
|
|
33
|
-
* - Other props on `<Defer>` (e.g. `fallback`) pass through.
|
|
34
|
-
*
|
|
35
|
-
* The transform is intentionally conservative — anything unusual leaves
|
|
36
|
-
* the source unchanged + emits a warning. Pipeline: runs BEFORE
|
|
37
|
-
* `transformJSX()` in the vite plugin. The output is still JSX —
|
|
38
|
-
* `transformJSX` then converts to runtime calls as usual.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
import { parseSync } from 'oxc-parser'
|
|
42
|
-
|
|
43
|
-
export interface DeferInlineWarning {
|
|
44
|
-
message: string
|
|
45
|
-
line: number
|
|
46
|
-
column: number
|
|
47
|
-
code:
|
|
48
|
-
| 'defer-inline/multiple-children'
|
|
49
|
-
| 'defer-inline/non-component-child'
|
|
50
|
-
| 'defer-inline/import-not-found'
|
|
51
|
-
| 'defer-inline/import-used-elsewhere'
|
|
52
|
-
| 'defer-inline/unsupported-import-shape'
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface DeferInlineResult {
|
|
56
|
-
/** Transformed source — same as input when no transform applied. */
|
|
57
|
-
code: string
|
|
58
|
-
/** True when at least one Defer JSX element was rewritten. */
|
|
59
|
-
changed: boolean
|
|
60
|
-
/** Soft warnings for cases the transform deliberately skipped. */
|
|
61
|
-
warnings: DeferInlineWarning[]
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
interface Node {
|
|
65
|
-
type: string
|
|
66
|
-
start: number
|
|
67
|
-
end: number
|
|
68
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
69
|
-
[key: string]: any
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
interface Edit {
|
|
73
|
-
start: number
|
|
74
|
-
end: number
|
|
75
|
-
replacement: string
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function getLang(filename: string): 'ts' | 'tsx' | 'js' | 'jsx' {
|
|
79
|
-
if (filename.endsWith('.tsx')) return 'tsx'
|
|
80
|
-
if (filename.endsWith('.jsx')) return 'jsx'
|
|
81
|
-
if (filename.endsWith('.ts')) return 'ts'
|
|
82
|
-
return 'js'
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Returns the JSX tag name as a string when the opening element's name
|
|
87
|
-
* is a simple identifier (`<Modal />`). Member-expression names
|
|
88
|
-
* (`<M.Modal />`) and namespaced names (`<svg:rect />`) return null —
|
|
89
|
-
* use `getJsxMemberName()` for the namespace-import case.
|
|
90
|
-
*/
|
|
91
|
-
function getJsxName(node: Node): string | null {
|
|
92
|
-
const open = node.openingElement as Node | undefined
|
|
93
|
-
if (!open) return null
|
|
94
|
-
const name = open.name as Node | undefined
|
|
95
|
-
if (!name || name.type !== 'JSXIdentifier') return null
|
|
96
|
-
return name.name as string
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* For `<M.Modal />` — returns `{ object: 'M', property: 'Modal' }` when
|
|
101
|
-
* the JSX name is a depth-1 JSXMemberExpression. Returns null for any
|
|
102
|
-
* other shape (deeper nesting like `<M.Sub.X />`, JSXNamespacedName,
|
|
103
|
-
* non-identifier). The depth-1 restriction keeps the rewrite simple:
|
|
104
|
-
* `M.Modal` is replaced with `__C` in the source.
|
|
105
|
-
*/
|
|
106
|
-
function getJsxMemberName(node: Node): { object: string; property: string } | null {
|
|
107
|
-
const open = node.openingElement as Node | undefined
|
|
108
|
-
if (!open) return null
|
|
109
|
-
const name = open.name as Node | undefined
|
|
110
|
-
if (!name || name.type !== 'JSXMemberExpression') return null
|
|
111
|
-
const obj = name.object as Node | undefined
|
|
112
|
-
const prop = name.property as Node | undefined
|
|
113
|
-
if (!obj || obj.type !== 'JSXIdentifier') return null
|
|
114
|
-
if (!prop || prop.type !== 'JSXIdentifier') return null
|
|
115
|
-
return { object: obj.name as string, property: prop.name as string }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Info needed to rewrite a JSX child element into a render-prop body.
|
|
120
|
-
*
|
|
121
|
-
* - `kind: 'identifier'` — `<Modal />`. `lookupName` = `'Modal'` (the
|
|
122
|
-
* JSX identifier that we look up in the file's imports).
|
|
123
|
-
* - `kind: 'member'` — `<M.Modal />`. `lookupName` = `'M'` (the
|
|
124
|
-
* namespace identifier we look up). `propertyName` = `'Modal'` (the
|
|
125
|
-
* export name to extract from the loaded chunk).
|
|
126
|
-
*
|
|
127
|
-
* `openNameRange` / `closeNameRange` span the WHOLE name expression —
|
|
128
|
-
* for identifiers, just the identifier itself; for member expressions,
|
|
129
|
-
* the whole `M.Modal`. The rewrite replaces this range with `__C`.
|
|
130
|
-
*/
|
|
131
|
-
interface ChildAnalysis {
|
|
132
|
-
kind: 'identifier' | 'member'
|
|
133
|
-
/** The identifier we look up in the file's import declarations. */
|
|
134
|
-
lookupName: string
|
|
135
|
-
/**
|
|
136
|
-
* For `kind: 'member'` — the property name (`Modal` in `<M.Modal />`).
|
|
137
|
-
* Used as the export name when building the chunk's `.then((__m) => ({
|
|
138
|
-
* default: __m.X }))` clause. Empty string for `kind: 'identifier'`
|
|
139
|
-
* (in that case `findImportFor` resolves the export name).
|
|
140
|
-
*/
|
|
141
|
-
propertyName: string
|
|
142
|
-
/** Source range of the FULL name expression in the opening tag. */
|
|
143
|
-
openNameRange: { start: number; end: number }
|
|
144
|
-
/** Source range in closing tag, null if self-closing. */
|
|
145
|
-
closeNameRange: { start: number; end: number } | null
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Verify a JSX child node is a single component-element we can rewrite.
|
|
150
|
-
* Handles two shapes:
|
|
151
|
-
* 1. `<Modal />` — identifier name, capitalised (component, not HTML).
|
|
152
|
-
* 2. `<M.Modal />` — depth-1 member expression with capitalised
|
|
153
|
-
* property name. The object (`M`) is the local binding to look up;
|
|
154
|
-
* the property (`Modal`) is the actual export to extract.
|
|
155
|
-
*
|
|
156
|
-
* Both shapes allow props (post-v2). Deeper nesting (`<M.Sub.X />`),
|
|
157
|
-
* JSXNamespacedName (`<svg:rect />`), and non-component lowercase names
|
|
158
|
-
* return null.
|
|
159
|
-
*/
|
|
160
|
-
function analyzeChildElement(node: Node): ChildAnalysis | null {
|
|
161
|
-
if (node.type !== 'JSXElement') return null
|
|
162
|
-
const open = node.openingElement as Node
|
|
163
|
-
const openName = open.name as Node
|
|
164
|
-
const close = node.closingElement as Node | undefined
|
|
165
|
-
|
|
166
|
-
const identName = getJsxName(node)
|
|
167
|
-
if (identName) {
|
|
168
|
-
if (!/^[A-Z]/.test(identName)) return null
|
|
169
|
-
return {
|
|
170
|
-
kind: 'identifier',
|
|
171
|
-
lookupName: identName,
|
|
172
|
-
propertyName: '',
|
|
173
|
-
openNameRange: {
|
|
174
|
-
start: openName.start as number,
|
|
175
|
-
end: openName.end as number,
|
|
176
|
-
},
|
|
177
|
-
closeNameRange: close
|
|
178
|
-
? {
|
|
179
|
-
start: (close.name as Node).start as number,
|
|
180
|
-
end: (close.name as Node).end as number,
|
|
181
|
-
}
|
|
182
|
-
: null,
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const memberName = getJsxMemberName(node)
|
|
187
|
-
if (memberName) {
|
|
188
|
-
// The PROPERTY must be capitalised (the actual component name). The
|
|
189
|
-
// object case is irrelevant — namespace bindings are conventionally
|
|
190
|
-
// any casing (`React.Fragment` has uppercase object; `lodash.map`
|
|
191
|
-
// has lowercase). Skip if property isn't a component.
|
|
192
|
-
if (!/^[A-Z]/.test(memberName.property)) return null
|
|
193
|
-
// The opening name node IS the JSXMemberExpression — its
|
|
194
|
-
// start..end span the whole `M.Modal` expression.
|
|
195
|
-
return {
|
|
196
|
-
kind: 'member',
|
|
197
|
-
lookupName: memberName.object,
|
|
198
|
-
propertyName: memberName.property,
|
|
199
|
-
openNameRange: {
|
|
200
|
-
start: openName.start as number,
|
|
201
|
-
end: openName.end as number,
|
|
202
|
-
},
|
|
203
|
-
closeNameRange: close
|
|
204
|
-
? {
|
|
205
|
-
start: (close.name as Node).start as number,
|
|
206
|
-
end: (close.name as Node).end as number,
|
|
207
|
-
}
|
|
208
|
-
: null,
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return null
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Filter whitespace-only JSXText nodes (formatting noise between JSX elements). */
|
|
216
|
-
function nonWhitespaceChildren(node: Node): Node[] {
|
|
217
|
-
const children = (node.children as Node[] | undefined) ?? []
|
|
218
|
-
return children.filter(
|
|
219
|
-
(c) => !(c.type === 'JSXText' && /^\s*$/.test(c.value as string)),
|
|
220
|
-
)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
interface DeferMatch {
|
|
224
|
-
node: Node
|
|
225
|
-
child: Node
|
|
226
|
-
childAnalysis: ChildAnalysis
|
|
227
|
-
/** Position where to insert the `chunk` attribute (just before the opening tag's `>`). */
|
|
228
|
-
insertChunkAt: number
|
|
229
|
-
/** Range covering the inline children inside Defer's open / close tags. */
|
|
230
|
-
childrenRange: { start: number; end: number }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function findDeferMatches(program: Node, warnings: DeferInlineWarning[], code: string): DeferMatch[] {
|
|
234
|
-
const matches: DeferMatch[] = []
|
|
235
|
-
|
|
236
|
-
const walk = (node: Node | null | undefined): void => {
|
|
237
|
-
if (!node || typeof node !== 'object') return
|
|
238
|
-
|
|
239
|
-
if (node.type === 'JSXElement' && getJsxName(node) === 'Defer') {
|
|
240
|
-
const open = node.openingElement as Node
|
|
241
|
-
const attrs = (open.attributes as Node[] | undefined) ?? []
|
|
242
|
-
const hasChunk = attrs.some(
|
|
243
|
-
(a) =>
|
|
244
|
-
a.type === 'JSXAttribute' &&
|
|
245
|
-
(a.name as Node | undefined)?.type === 'JSXIdentifier' &&
|
|
246
|
-
(a.name as Node).name === 'chunk',
|
|
247
|
-
)
|
|
248
|
-
if (!hasChunk) {
|
|
249
|
-
const live = nonWhitespaceChildren(node)
|
|
250
|
-
if (live.length > 1) {
|
|
251
|
-
// Multiple children — bail with a warning. v2 doesn't synthesize
|
|
252
|
-
// a wrapping module; user must use the explicit chunk + render-
|
|
253
|
-
// prop form to express multi-element inline content.
|
|
254
|
-
const loc = getLoc(code, (node.start as number) ?? 0)
|
|
255
|
-
warnings.push({
|
|
256
|
-
message: `<Defer> inline form requires exactly one component child (got ${live.length}). Wrap the children in a single component, or use the explicit \`chunk\` prop with a render-prop body.`,
|
|
257
|
-
line: loc.line,
|
|
258
|
-
column: loc.column,
|
|
259
|
-
code: 'defer-inline/multiple-children',
|
|
260
|
-
})
|
|
261
|
-
} else if (live.length === 1) {
|
|
262
|
-
const analysis = analyzeChildElement(live[0]!)
|
|
263
|
-
if (analysis) {
|
|
264
|
-
const close = node.closingElement as Node | undefined
|
|
265
|
-
matches.push({
|
|
266
|
-
node,
|
|
267
|
-
child: live[0]!,
|
|
268
|
-
childAnalysis: analysis,
|
|
269
|
-
insertChunkAt: (open.end as number) - 1,
|
|
270
|
-
childrenRange: {
|
|
271
|
-
start: open.end as number,
|
|
272
|
-
end: (close?.start as number) ?? (node.end as number),
|
|
273
|
-
},
|
|
274
|
-
})
|
|
275
|
-
} else {
|
|
276
|
-
// Single child but not a component element — bail with a
|
|
277
|
-
// warning. The user might've put an HTML tag, a fragment, or
|
|
278
|
-
// an expression container.
|
|
279
|
-
const loc = getLoc(code, ((live[0]!.start as number) ?? 0))
|
|
280
|
-
warnings.push({
|
|
281
|
-
message: `<Defer> inline form requires a single component-element child (capitalised JSX identifier). Use the explicit \`chunk\` prop for any other shape.`,
|
|
282
|
-
line: loc.line,
|
|
283
|
-
column: loc.column,
|
|
284
|
-
code: 'defer-inline/non-component-child',
|
|
285
|
-
})
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
// live.length === 0 — empty body, leave the Defer alone. Runtime
|
|
289
|
-
// will surface "missing chunk prop" if the user actually triggers
|
|
290
|
-
// it; that's the right user-facing diagnostic for this case.
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
for (const key in node) {
|
|
295
|
-
if (key === 'parent') continue
|
|
296
|
-
const v = node[key]
|
|
297
|
-
if (Array.isArray(v)) {
|
|
298
|
-
for (const item of v) walk(item as Node)
|
|
299
|
-
} else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
|
|
300
|
-
walk(v as Node)
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
walk(program)
|
|
306
|
-
return matches
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Info needed to rewrite a Defer match into the explicit chunk-prop form:
|
|
311
|
-
* the module source, the export name to extract, the AST node of the
|
|
312
|
-
* import declaration (for static-import removal), and the specifier node
|
|
313
|
-
* within that declaration (so multi-specifier imports only lose the
|
|
314
|
-
* one binding).
|
|
315
|
-
*/
|
|
316
|
-
interface ImportInfo {
|
|
317
|
-
/** ImportDeclaration AST node containing the specifier. */
|
|
318
|
-
declaration: Node
|
|
319
|
-
/** The specific specifier node (Default, Named, Namespace). */
|
|
320
|
-
specifier: Node
|
|
321
|
-
/** Module source string (without quotes). */
|
|
322
|
-
source: string
|
|
323
|
-
/**
|
|
324
|
-
* - `default` → `import('./x')`.
|
|
325
|
-
* - `named` → `.then((__m) => ({ default: __m.X }))` with X from `importedName`.
|
|
326
|
-
* - `namespace` → same as `named`, but X comes from the JSX member-expression
|
|
327
|
-
* property (`<M.Modal />` → `__m.Modal`), supplied by the caller.
|
|
328
|
-
*/
|
|
329
|
-
kind: 'default' | 'named' | 'namespace'
|
|
330
|
-
/**
|
|
331
|
-
* Export name on the SOURCE module's side. For `{ Modal as M }`,
|
|
332
|
-
* `localName='M'` (JSX side) but `importedName='Modal'` (chunk side).
|
|
333
|
-
* Unused for `default` imports. Empty for `namespace` (caller supplies
|
|
334
|
-
* via member-expression property).
|
|
335
|
-
*/
|
|
336
|
-
importedName: string
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Locate the import declaration that binds `localName`.
|
|
341
|
-
*
|
|
342
|
-
* For namespace imports (`import * as M`), `localName` is the namespace
|
|
343
|
-
* identifier (`M`). The caller's `propertyName` provides the actual
|
|
344
|
-
* export name to extract — `findImportFor` returns `importedName: ''`
|
|
345
|
-
* for the namespace case and the caller substitutes its own property.
|
|
346
|
-
*/
|
|
347
|
-
function findImportFor(program: Node, localName: string): ImportInfo | null {
|
|
348
|
-
const body = (program.body as Node[] | undefined) ?? []
|
|
349
|
-
for (const stmt of body) {
|
|
350
|
-
if (stmt.type !== 'ImportDeclaration') continue
|
|
351
|
-
const specifiers = (stmt.specifiers as Node[] | undefined) ?? []
|
|
352
|
-
for (const spec of specifiers) {
|
|
353
|
-
if (spec.type === 'ImportDefaultSpecifier') {
|
|
354
|
-
const lname = (spec.local as Node).name as string
|
|
355
|
-
if (lname === localName) {
|
|
356
|
-
return {
|
|
357
|
-
declaration: stmt,
|
|
358
|
-
specifier: spec,
|
|
359
|
-
source: (stmt.source as Node).value as string,
|
|
360
|
-
kind: 'default',
|
|
361
|
-
importedName: 'default',
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
} else if (spec.type === 'ImportSpecifier') {
|
|
365
|
-
const lname = (spec.local as Node).name as string
|
|
366
|
-
const iname = (spec.imported as Node | undefined)?.name as string | undefined
|
|
367
|
-
if (lname === localName && iname !== undefined) {
|
|
368
|
-
// Handles BOTH `{ Modal }` (lname === iname) AND `{ Modal as M }`
|
|
369
|
-
// (lname='M', iname='Modal'). v2 — was bailed in v1.
|
|
370
|
-
return {
|
|
371
|
-
declaration: stmt,
|
|
372
|
-
specifier: spec,
|
|
373
|
-
source: (stmt.source as Node).value as string,
|
|
374
|
-
kind: 'named',
|
|
375
|
-
importedName: iname,
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
379
|
-
// `import * as M from './mod'` — `local.name === 'M'`. Caller's
|
|
380
|
-
// `propertyName` supplies the actual export to extract (e.g.
|
|
381
|
-
// `Modal` from `<M.Modal />`).
|
|
382
|
-
const lname = (spec.local as Node).name as string
|
|
383
|
-
if (lname === localName) {
|
|
384
|
-
return {
|
|
385
|
-
declaration: stmt,
|
|
386
|
-
specifier: spec,
|
|
387
|
-
source: (stmt.source as Node).value as string,
|
|
388
|
-
kind: 'namespace',
|
|
389
|
-
importedName: '', // caller provides via propertyName
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return null
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* Count references to `localName` outside the given Defer subtree, AND
|
|
400
|
-
* outside the import declaration that defines it. The static import can
|
|
401
|
-
* only be safely removed when the local binding is used EXCLUSIVELY
|
|
402
|
-
* inside that Defer subtree — otherwise removing the import would break
|
|
403
|
-
* the other usage AND the dynamic import would be a no-op (Rolldown
|
|
404
|
-
* static-bundles the module on shared usage).
|
|
405
|
-
*/
|
|
406
|
-
function countReferencesOutside(
|
|
407
|
-
program: Node,
|
|
408
|
-
localName: string,
|
|
409
|
-
skipSubtree: Node,
|
|
410
|
-
skipDeclaration: Node,
|
|
411
|
-
): number {
|
|
412
|
-
let count = 0
|
|
413
|
-
const skipStart = skipSubtree.start as number
|
|
414
|
-
const skipEnd = skipSubtree.end as number
|
|
415
|
-
const declStart = skipDeclaration.start as number
|
|
416
|
-
const declEnd = skipDeclaration.end as number
|
|
417
|
-
|
|
418
|
-
const inSkip = (s: number, e: number): boolean =>
|
|
419
|
-
(s >= skipStart && e <= skipEnd) || (s >= declStart && e <= declEnd)
|
|
420
|
-
|
|
421
|
-
const visit = (node: Node): void => {
|
|
422
|
-
if (!node || typeof node !== 'object') return
|
|
423
|
-
const ns = node.start as number | undefined
|
|
424
|
-
const ne = node.end as number | undefined
|
|
425
|
-
if (typeof ns === 'number' && typeof ne === 'number' && inSkip(ns, ne)) return
|
|
426
|
-
if (node.type === 'Identifier' && (node.name as string) === localName) count++
|
|
427
|
-
if (node.type === 'JSXIdentifier' && (node.name as string) === localName) count++
|
|
428
|
-
for (const key in node) {
|
|
429
|
-
if (key === 'parent') continue
|
|
430
|
-
const v = node[key]
|
|
431
|
-
if (Array.isArray(v)) {
|
|
432
|
-
for (const item of v) visit(item as Node)
|
|
433
|
-
} else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
|
|
434
|
-
visit(v as Node)
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
const body = (program.body as Node[] | undefined) ?? []
|
|
439
|
-
for (const stmt of body) visit(stmt)
|
|
440
|
-
return count
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Build the chunk={...} attribute string.
|
|
445
|
-
*
|
|
446
|
-
* - `default` → `chunk={() => import('./x')}`. The default export IS the
|
|
447
|
-
* component; no re-wrapping needed.
|
|
448
|
-
* - `named` / `namespace` → `chunk={() => import('./x').then((__m) => ({
|
|
449
|
-
* default: __m.X }))}`. The `default` slot points at the named export
|
|
450
|
-
* (for `named`) or the member-expression property (for `namespace`).
|
|
451
|
-
*
|
|
452
|
-
* The caller picks `exportName` — for `named`, it's `info.importedName`;
|
|
453
|
-
* for `namespace`, it's the JSX member-expression property.
|
|
454
|
-
*/
|
|
455
|
-
function buildChunkAttr(source: string, kind: 'default' | 'named' | 'namespace', exportName: string): string {
|
|
456
|
-
if (kind === 'default') {
|
|
457
|
-
return ` chunk={() => import('${source}')}`
|
|
458
|
-
}
|
|
459
|
-
return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${exportName} }))}`
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Build the render-prop body from the original child JSX. Replaces the
|
|
464
|
-
* component name (in both opening and closing tags) with `__C` — every
|
|
465
|
-
* other character of the original source survives verbatim. Props /
|
|
466
|
-
* nested children / event handlers / closure captures all flow through
|
|
467
|
-
* unchanged. The render-prop arrow's lexical scope captures whatever
|
|
468
|
-
* was in scope at the call site.
|
|
469
|
-
*/
|
|
470
|
-
function buildRenderPropBody(code: string, analysis: ChildAnalysis, childRange: { start: number; end: number }): string {
|
|
471
|
-
const start = childRange.start
|
|
472
|
-
const end = childRange.end
|
|
473
|
-
let body = code.slice(start, end)
|
|
474
|
-
// Apply name replacements from END to START so positions stay valid
|
|
475
|
-
// as we splice. The opening tag's name always precedes the closing
|
|
476
|
-
// tag's name in source order.
|
|
477
|
-
if (analysis.closeNameRange) {
|
|
478
|
-
const r = analysis.closeNameRange
|
|
479
|
-
body = body.slice(0, r.start - start) + '__C' + body.slice(r.end - start)
|
|
480
|
-
}
|
|
481
|
-
body = body.slice(0, analysis.openNameRange.start - start) + '__C' + body.slice(analysis.openNameRange.end - start)
|
|
482
|
-
return `{(__C) => ${body}}`
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function applyEdits(source: string, edits: Edit[]): string {
|
|
486
|
-
const sorted = [...edits].sort((a, b) => b.start - a.start)
|
|
487
|
-
let out = source
|
|
488
|
-
for (const e of sorted) {
|
|
489
|
-
out = out.slice(0, e.start) + e.replacement + out.slice(e.end)
|
|
490
|
-
}
|
|
491
|
-
return out
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
/**
|
|
495
|
-
* Compute the edit that removes the import binding for the given match.
|
|
496
|
-
* Handles three shapes:
|
|
497
|
-
* 1. Single-specifier declaration → remove the entire ImportDeclaration
|
|
498
|
-
* (including its trailing newline).
|
|
499
|
-
* 2. Multi-specifier declaration where this is the FIRST specifier →
|
|
500
|
-
* remove the specifier + the comma + whitespace that follows it.
|
|
501
|
-
* 3. Multi-specifier declaration where this is a LATER specifier →
|
|
502
|
-
* remove the preceding comma + whitespace + the specifier.
|
|
503
|
-
*
|
|
504
|
-
* Case (1) is the simple v1 path; cases (2) and (3) are the v2
|
|
505
|
-
* multi-specifier handling.
|
|
506
|
-
*/
|
|
507
|
-
function buildImportRemovalEdit(code: string, info: ImportInfo): Edit {
|
|
508
|
-
const specifiers = (info.declaration.specifiers as Node[]) ?? []
|
|
509
|
-
if (specifiers.length === 1) {
|
|
510
|
-
// Whole declaration goes. Eat trailing newline so we don't leave a blank line.
|
|
511
|
-
const start = info.declaration.start as number
|
|
512
|
-
let end = info.declaration.end as number
|
|
513
|
-
if (code[end] === '\n') end += 1
|
|
514
|
-
return { start, end, replacement: '' }
|
|
515
|
-
}
|
|
516
|
-
// Multi-specifier — remove just this one binding.
|
|
517
|
-
const idx = specifiers.indexOf(info.specifier)
|
|
518
|
-
// Position of this specifier
|
|
519
|
-
const specStart = info.specifier.start as number
|
|
520
|
-
const specEnd = info.specifier.end as number
|
|
521
|
-
if (idx === 0) {
|
|
522
|
-
// First specifier: eat from spec start to whitespace + comma + the
|
|
523
|
-
// start of the next specifier. So `{ Modal, Other }` becomes `{ Other }`.
|
|
524
|
-
const next = specifiers[1]!
|
|
525
|
-
return {
|
|
526
|
-
start: specStart,
|
|
527
|
-
end: next.start as number,
|
|
528
|
-
replacement: '',
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
// Later specifier: eat from the END of the previous specifier (including
|
|
532
|
-
// the comma between them) up through this specifier's end. So `{ Other,
|
|
533
|
-
// Modal }` becomes `{ Other }`.
|
|
534
|
-
const prev = specifiers[idx - 1]!
|
|
535
|
-
return {
|
|
536
|
-
start: prev.end as number,
|
|
537
|
-
end: specEnd,
|
|
538
|
-
replacement: '',
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
export function transformDeferInline(
|
|
543
|
-
code: string,
|
|
544
|
-
filename = 'input.tsx',
|
|
545
|
-
): DeferInlineResult {
|
|
546
|
-
const warnings: DeferInlineWarning[] = []
|
|
547
|
-
|
|
548
|
-
// Fast path — skip parse entirely when no `Defer` mention.
|
|
549
|
-
if (!code.includes('Defer')) {
|
|
550
|
-
return { code, changed: false, warnings }
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
let program: Node
|
|
554
|
-
try {
|
|
555
|
-
const result = parseSync(filename, code, {
|
|
556
|
-
sourceType: 'module',
|
|
557
|
-
lang: getLang(filename),
|
|
558
|
-
})
|
|
559
|
-
program = result.program as Node
|
|
560
|
-
} catch {
|
|
561
|
-
return { code, changed: false, warnings }
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
const matches = findDeferMatches(program, warnings, code)
|
|
565
|
-
if (matches.length === 0) return { code, changed: false, warnings }
|
|
566
|
-
|
|
567
|
-
const edits: Edit[] = []
|
|
568
|
-
let changed = false
|
|
569
|
-
|
|
570
|
-
for (const m of matches) {
|
|
571
|
-
// For identifier children (`<Modal />`), the JSX-display name and
|
|
572
|
-
// import-lookup name are the same (`Modal`). For member-expression
|
|
573
|
-
// children (`<M.Modal />`), JSX-display is `M.Modal` but we look up
|
|
574
|
-
// the namespace binding `M` in imports.
|
|
575
|
-
const displayName =
|
|
576
|
-
m.childAnalysis.kind === 'member'
|
|
577
|
-
? `${m.childAnalysis.lookupName}.${m.childAnalysis.propertyName}`
|
|
578
|
-
: m.childAnalysis.lookupName
|
|
579
|
-
|
|
580
|
-
const importInfo = findImportFor(program, m.childAnalysis.lookupName)
|
|
581
|
-
if (!importInfo) {
|
|
582
|
-
const loc = getLoc(code, (m.child.start as number) ?? 0)
|
|
583
|
-
warnings.push({
|
|
584
|
-
message: `<Defer>'s inline child <${displayName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childAnalysis.lookupName} from a module.`,
|
|
585
|
-
line: loc.line,
|
|
586
|
-
column: loc.column,
|
|
587
|
-
code: 'defer-inline/import-not-found',
|
|
588
|
-
})
|
|
589
|
-
continue
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// Sanity check: if the JSX is a member expression but the import
|
|
593
|
-
// isn't a namespace import (e.g. `import M from './x'; <M.Modal />`),
|
|
594
|
-
// bail. The semantics are ambiguous — `M` is a default-export
|
|
595
|
-
// component, not a module bag, so `M.Modal` is a member access on
|
|
596
|
-
// the component itself. Out of scope for inline-Defer.
|
|
597
|
-
if (m.childAnalysis.kind === 'member' && importInfo.kind !== 'namespace') {
|
|
598
|
-
const loc = getLoc(code, (m.child.start as number) ?? 0)
|
|
599
|
-
warnings.push({
|
|
600
|
-
message: `<Defer>'s inline child <${displayName} /> uses a member expression but \`${m.childAnalysis.lookupName}\` isn't a namespace import. Inline form requires \`import * as ${m.childAnalysis.lookupName} from '...'\`. Use the explicit \`chunk\` prop for other shapes.`,
|
|
601
|
-
line: loc.line,
|
|
602
|
-
column: loc.column,
|
|
603
|
-
code: 'defer-inline/unsupported-import-shape',
|
|
604
|
-
})
|
|
605
|
-
continue
|
|
606
|
-
}
|
|
607
|
-
// Inverse: namespace import but identifier child (`import * as M;
|
|
608
|
-
// <Modal />`). The Modal identifier doesn't reference the
|
|
609
|
-
// namespace at all — leave to import-not-found which fires above.
|
|
610
|
-
if (m.childAnalysis.kind === 'identifier' && importInfo.kind === 'namespace') {
|
|
611
|
-
// Shouldn't be reachable — findImportFor only returns namespace
|
|
612
|
-
// when localName matches the namespace identifier. If we got
|
|
613
|
-
// here, the namespace was imported with the same name as a
|
|
614
|
-
// separate component (impossible — would be a JS scope error
|
|
615
|
-
// upstream). Defensive bail.
|
|
616
|
-
continue
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
const outsideUses = countReferencesOutside(
|
|
620
|
-
program,
|
|
621
|
-
m.childAnalysis.lookupName,
|
|
622
|
-
m.node,
|
|
623
|
-
importInfo.declaration,
|
|
624
|
-
)
|
|
625
|
-
if (outsideUses > 0) {
|
|
626
|
-
const loc = getLoc(code, (m.node.start as number) ?? 0)
|
|
627
|
-
warnings.push({
|
|
628
|
-
message: `<Defer>'s inline child <${displayName} /> is also referenced elsewhere in this file. Inline form requires the import to be used exclusively inside this Defer. Use the explicit \`chunk\` prop form to split despite shared usage.`,
|
|
629
|
-
line: loc.line,
|
|
630
|
-
column: loc.column,
|
|
631
|
-
code: 'defer-inline/import-used-elsewhere',
|
|
632
|
-
})
|
|
633
|
-
continue
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// For namespace imports, the export name to extract comes from the
|
|
637
|
-
// JSX member-expression property (`Modal` in `<M.Modal />`). For
|
|
638
|
-
// named imports it comes from `importInfo.importedName` (handles
|
|
639
|
-
// the renamed-import case). For default imports it's unused.
|
|
640
|
-
const exportName =
|
|
641
|
-
importInfo.kind === 'namespace'
|
|
642
|
-
? m.childAnalysis.propertyName
|
|
643
|
-
: importInfo.importedName
|
|
644
|
-
|
|
645
|
-
// 1. Insert chunk attribute just before the opening tag's `>`.
|
|
646
|
-
edits.push({
|
|
647
|
-
start: m.insertChunkAt,
|
|
648
|
-
end: m.insertChunkAt,
|
|
649
|
-
replacement: buildChunkAttr(importInfo.source, importInfo.kind, exportName),
|
|
650
|
-
})
|
|
651
|
-
|
|
652
|
-
// 2. Replace the inline children with a render-prop body. The body
|
|
653
|
-
// preserves the original child JSX verbatim except for the
|
|
654
|
-
// component name (replaced with `__C`) — props, nested children,
|
|
655
|
-
// closure captures all pass through to the render-prop arrow,
|
|
656
|
-
// which captures the surrounding lexical scope.
|
|
657
|
-
edits.push({
|
|
658
|
-
start: m.childrenRange.start,
|
|
659
|
-
end: m.childrenRange.end,
|
|
660
|
-
replacement: buildRenderPropBody(code, m.childAnalysis, m.childrenRange),
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
// 3. Remove the static import binding so Rolldown actually emits the
|
|
664
|
-
// dynamic chunk. Multi-specifier imports drop just the one
|
|
665
|
-
// binding, leaving siblings intact.
|
|
666
|
-
edits.push(buildImportRemovalEdit(code, importInfo))
|
|
667
|
-
|
|
668
|
-
changed = true
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (!changed) return { code, changed: false, warnings }
|
|
672
|
-
|
|
673
|
-
return { code: applyEdits(code, edits), changed: true, warnings }
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
function getLoc(code: string, offset: number): { line: number; column: number } {
|
|
677
|
-
let line = 1
|
|
678
|
-
let lastNl = -1
|
|
679
|
-
for (let i = 0; i < offset && i < code.length; i++) {
|
|
680
|
-
if (code.charCodeAt(i) === 10) {
|
|
681
|
-
line++
|
|
682
|
-
lastNl = i
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
return { line, column: offset - lastNl - 1 }
|
|
686
|
-
}
|