@pyreon/compiler 0.18.0 → 0.20.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/lib/analysis/index.js.html +1 -1
- package/lib/index.js +2081 -1262
- package/lib/types/index.d.ts +310 -125
- package/package.json +14 -12
- package/src/defer-inline.ts +397 -157
- package/src/index.ts +14 -2
- package/src/jsx.ts +784 -19
- package/src/load-native.ts +1 -0
- package/src/manifest.ts +280 -0
- package/src/pyreon-intercept.ts +164 -0
- package/src/react-intercept.ts +59 -0
- package/src/reactivity-lens.ts +190 -0
- package/src/tests/backend-parity-r7-r9.test.ts +91 -0
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
- package/src/tests/collapse-bail-census.test.ts +245 -0
- package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
- package/src/tests/defer-inline.test.ts +209 -21
- package/src/tests/detector-tag-consistency.test.ts +2 -0
- package/src/tests/element-valued-const-child.test.ts +61 -0
- package/src/tests/falsy-child-characterization.test.ts +48 -0
- package/src/tests/malformed-input-resilience.test.ts +50 -0
- package/src/tests/manifest-snapshot.test.ts +55 -0
- package/src/tests/native-equivalence.test.ts +104 -3
- package/src/tests/partial-collapse-detector.test.ts +121 -0
- package/src/tests/partial-collapse-emit.test.ts +104 -0
- package/src/tests/partial-collapse-robustness.test.ts +53 -0
- package/src/tests/prop-derived-shadow.test.ts +96 -0
- package/src/tests/pure-call-reactive-args.test.ts +50 -0
- package/src/tests/pyreon-intercept.test.ts +189 -0
- package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
- package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
- package/src/tests/r15-elemconst-propderived.test.ts +47 -0
- package/src/tests/r19-defer-inline-robust.test.ts +54 -0
- package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
- package/src/tests/react-intercept.test.ts +50 -2
- package/src/tests/reactivity-lens.test.ts +170 -0
- package/src/tests/rocketstyle-collapse.test.ts +208 -0
- package/src/tests/signal-autocall-shadow.test.ts +86 -0
- package/src/tests/sourcemap-fidelity.test.ts +77 -0
- package/src/tests/static-text-baking.test.ts +64 -0
- package/src/tests/transform-state-isolation.test.ts +49 -0
package/src/defer-inline.ts
CHANGED
|
@@ -4,35 +4,38 @@
|
|
|
4
4
|
* Rewrites:
|
|
5
5
|
*
|
|
6
6
|
* import { Modal } from './Modal'
|
|
7
|
-
* <Defer when={open()}><Modal /></Defer>
|
|
7
|
+
* <Defer when={open()}><Modal title="hi" /></Defer>
|
|
8
8
|
*
|
|
9
9
|
* into:
|
|
10
10
|
*
|
|
11
|
-
* <Defer when={open()} chunk={() => import('./Modal').then(
|
|
12
|
-
* {
|
|
11
|
+
* <Defer when={open()} chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}>
|
|
12
|
+
* {(__C) => <__C title="hi" />}
|
|
13
13
|
* </Defer>
|
|
14
14
|
*
|
|
15
15
|
* The static `import { Modal } from './Modal'` is removed when `Modal` is
|
|
16
16
|
* referenced ONLY inside the Defer subtree — otherwise Rolldown would
|
|
17
17
|
* bundle the module statically and the dynamic import becomes a no-op.
|
|
18
18
|
*
|
|
19
|
-
* Scope
|
|
20
|
-
* -
|
|
21
|
-
* - Children: exactly ONE JSXElement,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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.
|
|
26
32
|
* - Triggers (`when={...}`, `on="visible"`, `on="idle"`) pass through.
|
|
27
33
|
* - Other props on `<Defer>` (e.g. `fallback`) pass through.
|
|
28
34
|
*
|
|
29
35
|
* The transform is intentionally conservative — anything unusual leaves
|
|
30
|
-
* the source unchanged + emits a warning.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* Pipeline: this runs BEFORE `transformJSX()` in the vite plugin. The
|
|
34
|
-
* output is still JSX — `transformJSX` then converts it to `h()` /
|
|
35
|
-
* `_tpl()` calls as usual.
|
|
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.
|
|
36
39
|
*/
|
|
37
40
|
|
|
38
41
|
import { parseSync } from 'oxc-parser'
|
|
@@ -44,7 +47,6 @@ export interface DeferInlineWarning {
|
|
|
44
47
|
code:
|
|
45
48
|
| 'defer-inline/multiple-children'
|
|
46
49
|
| 'defer-inline/non-component-child'
|
|
47
|
-
| 'defer-inline/child-has-props'
|
|
48
50
|
| 'defer-inline/import-not-found'
|
|
49
51
|
| 'defer-inline/import-used-elsewhere'
|
|
50
52
|
| 'defer-inline/unsupported-import-shape'
|
|
@@ -73,11 +75,6 @@ interface Edit {
|
|
|
73
75
|
replacement: string
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
/**
|
|
77
|
-
* Detect the language for `parseSync`. `oxc-parser` infers from filename
|
|
78
|
-
* by extension — we mirror the same logic for the few extensions we
|
|
79
|
-
* support so the parser is invoked correctly.
|
|
80
|
-
*/
|
|
81
78
|
function getLang(filename: string): 'ts' | 'tsx' | 'js' | 'jsx' {
|
|
82
79
|
if (filename.endsWith('.tsx')) return 'tsx'
|
|
83
80
|
if (filename.endsWith('.jsx')) return 'jsx'
|
|
@@ -87,9 +84,9 @@ function getLang(filename: string): 'ts' | 'tsx' | 'js' | 'jsx' {
|
|
|
87
84
|
|
|
88
85
|
/**
|
|
89
86
|
* Returns the JSX tag name as a string when the opening element's name
|
|
90
|
-
* is a simple identifier (
|
|
91
|
-
*
|
|
92
|
-
* (
|
|
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.
|
|
93
90
|
*/
|
|
94
91
|
function getJsxName(node: Node): string | null {
|
|
95
92
|
const open = node.openingElement as Node | undefined
|
|
@@ -100,32 +97,122 @@ function getJsxName(node: Node): string | null {
|
|
|
100
97
|
}
|
|
101
98
|
|
|
102
99
|
/**
|
|
103
|
-
* `<
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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`.
|
|
108
130
|
*/
|
|
109
|
-
|
|
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 {
|
|
110
161
|
if (node.type !== 'JSXElement') return null
|
|
111
|
-
const tag = getJsxName(node)
|
|
112
|
-
if (!tag || !/^[A-Z]/.test(tag)) return null
|
|
113
162
|
const open = node.openingElement as Node
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
return null
|
|
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
|
+
}
|
|
120
184
|
}
|
|
121
|
-
|
|
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
|
|
122
213
|
}
|
|
123
214
|
|
|
124
|
-
/**
|
|
125
|
-
* Filter whitespace-only JSXText nodes; return remaining children. JSX
|
|
126
|
-
* source like `<Defer>\n <Modal />\n</Defer>` has 3 children at the AST
|
|
127
|
-
* level: text, element, text. The text nodes are formatting noise.
|
|
128
|
-
*/
|
|
215
|
+
/** Filter whitespace-only JSXText nodes (formatting noise between JSX elements). */
|
|
129
216
|
function nonWhitespaceChildren(node: Node): Node[] {
|
|
130
217
|
const children = (node.children as Node[] | undefined) ?? []
|
|
131
218
|
return children.filter(
|
|
@@ -133,26 +220,17 @@ function nonWhitespaceChildren(node: Node): Node[] {
|
|
|
133
220
|
)
|
|
134
221
|
}
|
|
135
222
|
|
|
136
|
-
/**
|
|
137
|
-
* `<Defer chunk={...} ...>` qualifies for the inline transform when:
|
|
138
|
-
* - The opening name is `Defer`.
|
|
139
|
-
* - No attribute named `chunk` (otherwise user is using the explicit form).
|
|
140
|
-
* - Exactly ONE non-whitespace child that is a bare component reference.
|
|
141
|
-
*/
|
|
142
223
|
interface DeferMatch {
|
|
143
|
-
/** The <Defer> JSXElement node. */
|
|
144
224
|
node: Node
|
|
145
|
-
/** The single child component element. */
|
|
146
225
|
child: Node
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
/** Position where to insert the `chunk` attribute (just after `<Defer`). */
|
|
226
|
+
childAnalysis: ChildAnalysis
|
|
227
|
+
/** Position where to insert the `chunk` attribute (just before the opening tag's `>`). */
|
|
150
228
|
insertChunkAt: number
|
|
151
|
-
/** Range covering the
|
|
229
|
+
/** Range covering the inline children inside Defer's open / close tags. */
|
|
152
230
|
childrenRange: { start: number; end: number }
|
|
153
231
|
}
|
|
154
232
|
|
|
155
|
-
function findDeferMatches(program: Node): DeferMatch[] {
|
|
233
|
+
function findDeferMatches(program: Node, warnings: DeferInlineWarning[], code: string): DeferMatch[] {
|
|
156
234
|
const matches: DeferMatch[] = []
|
|
157
235
|
|
|
158
236
|
const walk = (node: Node | null | undefined): void => {
|
|
@@ -169,31 +247,50 @@ function findDeferMatches(program: Node): DeferMatch[] {
|
|
|
169
247
|
)
|
|
170
248
|
if (!hasChunk) {
|
|
171
249
|
const live = nonWhitespaceChildren(node)
|
|
172
|
-
if (live.length
|
|
173
|
-
|
|
174
|
-
|
|
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) {
|
|
175
264
|
const close = node.closingElement as Node | undefined
|
|
176
265
|
matches.push({
|
|
177
266
|
node,
|
|
178
267
|
child: live[0]!,
|
|
179
|
-
|
|
180
|
-
// Insert chunk attribute right after the opening tag name.
|
|
181
|
-
// `<Defer when={x}>` — we want to insert just before the `>`
|
|
182
|
-
// (or `/>` if self-closing, though Defer is never self-closing
|
|
183
|
-
// when it has inline children). Use the closing `>` of the
|
|
184
|
-
// opening tag — that's `open.end - 1` for `<Defer ...>` form.
|
|
268
|
+
childAnalysis: analysis,
|
|
185
269
|
insertChunkAt: (open.end as number) - 1,
|
|
186
270
|
childrenRange: {
|
|
187
271
|
start: open.end as number,
|
|
188
272
|
end: (close?.start as number) ?? (node.end as number),
|
|
189
273
|
},
|
|
190
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
|
+
})
|
|
191
286
|
}
|
|
192
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.
|
|
193
291
|
}
|
|
194
292
|
}
|
|
195
293
|
|
|
196
|
-
// Recurse — JSX children, prop expressions, statements, etc.
|
|
197
294
|
for (const key in node) {
|
|
198
295
|
if (key === 'parent') continue
|
|
199
296
|
const v = node[key]
|
|
@@ -210,109 +307,181 @@ function findDeferMatches(program: Node): DeferMatch[] {
|
|
|
210
307
|
}
|
|
211
308
|
|
|
212
309
|
/**
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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).
|
|
216
315
|
*/
|
|
217
316
|
interface ImportInfo {
|
|
218
|
-
/**
|
|
219
|
-
|
|
220
|
-
/** The
|
|
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). */
|
|
221
322
|
source: string
|
|
222
|
-
/**
|
|
223
|
-
|
|
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
|
|
224
337
|
}
|
|
225
338
|
|
|
226
|
-
|
|
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 {
|
|
227
348
|
const body = (program.body as Node[] | undefined) ?? []
|
|
228
349
|
for (const stmt of body) {
|
|
229
350
|
if (stmt.type !== 'ImportDeclaration') continue
|
|
230
351
|
const specifiers = (stmt.specifiers as Node[] | undefined) ?? []
|
|
231
352
|
for (const spec of specifiers) {
|
|
232
353
|
if (spec.type === 'ImportDefaultSpecifier') {
|
|
233
|
-
const
|
|
234
|
-
if (
|
|
354
|
+
const lname = (spec.local as Node).name as string
|
|
355
|
+
if (lname === localName) {
|
|
235
356
|
return {
|
|
236
|
-
|
|
357
|
+
declaration: stmt,
|
|
358
|
+
specifier: spec,
|
|
237
359
|
source: (stmt.source as Node).value as string,
|
|
238
360
|
kind: 'default',
|
|
361
|
+
importedName: 'default',
|
|
239
362
|
}
|
|
240
363
|
}
|
|
241
364
|
} else if (spec.type === 'ImportSpecifier') {
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (local === name && imported !== undefined && imported === local) {
|
|
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.
|
|
248
370
|
return {
|
|
249
|
-
|
|
371
|
+
declaration: stmt,
|
|
372
|
+
specifier: spec,
|
|
250
373
|
source: (stmt.source as Node).value as string,
|
|
251
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
|
|
252
390
|
}
|
|
253
391
|
}
|
|
254
392
|
}
|
|
255
|
-
// ImportNamespaceSpecifier (`import * as M`) — not handled in v1.
|
|
256
393
|
}
|
|
257
394
|
}
|
|
258
395
|
return null
|
|
259
396
|
}
|
|
260
397
|
|
|
261
398
|
/**
|
|
262
|
-
* Count references to `
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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).
|
|
265
405
|
*/
|
|
266
|
-
function countReferencesOutside(
|
|
406
|
+
function countReferencesOutside(
|
|
407
|
+
program: Node,
|
|
408
|
+
localName: string,
|
|
409
|
+
skipSubtree: Node,
|
|
410
|
+
skipDeclaration: Node,
|
|
411
|
+
): number {
|
|
267
412
|
let count = 0
|
|
268
413
|
const skipStart = skipSubtree.start as number
|
|
269
414
|
const skipEnd = skipSubtree.end as number
|
|
415
|
+
const declStart = skipDeclaration.start as number
|
|
416
|
+
const declEnd = skipDeclaration.end as number
|
|
270
417
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const countInNode = (node: Node): void => {
|
|
418
|
+
const inSkip = (s: number, e: number): boolean =>
|
|
419
|
+
(s >= skipStart && e <= skipEnd) || (s >= declStart && e <= declEnd)
|
|
420
|
+
|
|
421
|
+
const visit = (node: Node): void => {
|
|
276
422
|
if (!node || typeof node !== 'object') return
|
|
277
423
|
const ns = node.start as number | undefined
|
|
278
424
|
const ne = node.end as number | undefined
|
|
279
|
-
if (typeof ns === 'number' && typeof ne === 'number' && ns
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (node.type === 'Identifier' && (node.name as string) === name) count++
|
|
283
|
-
if (node.type === 'JSXIdentifier' && (node.name as string) === name) count++
|
|
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++
|
|
284
428
|
for (const key in node) {
|
|
285
429
|
if (key === 'parent') continue
|
|
286
430
|
const v = node[key]
|
|
287
431
|
if (Array.isArray(v)) {
|
|
288
|
-
for (const item of v)
|
|
432
|
+
for (const item of v) visit(item as Node)
|
|
289
433
|
} else if (v && typeof v === 'object' && typeof (v as Node).type === 'string') {
|
|
290
|
-
|
|
434
|
+
visit(v as Node)
|
|
291
435
|
}
|
|
292
436
|
}
|
|
293
437
|
}
|
|
294
438
|
const body = (program.body as Node[] | undefined) ?? []
|
|
295
|
-
for (const stmt of body)
|
|
296
|
-
if (stmt.type === 'ImportDeclaration') continue
|
|
297
|
-
countInNode(stmt)
|
|
298
|
-
}
|
|
439
|
+
for (const stmt of body) visit(stmt)
|
|
299
440
|
return count
|
|
300
441
|
}
|
|
301
442
|
|
|
302
|
-
/**
|
|
303
|
-
|
|
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 {
|
|
304
456
|
if (kind === 'default') {
|
|
305
457
|
return ` chunk={() => import('${source}')}`
|
|
306
458
|
}
|
|
307
|
-
|
|
308
|
-
return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${name} }))}`
|
|
459
|
+
return ` chunk={() => import('${source}').then((__m) => ({ default: __m.${exportName} }))}`
|
|
309
460
|
}
|
|
310
461
|
|
|
311
462
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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.
|
|
315
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
|
+
|
|
316
485
|
function applyEdits(source: string, edits: Edit[]): string {
|
|
317
486
|
const sorted = [...edits].sort((a, b) => b.start - a.start)
|
|
318
487
|
let out = source
|
|
@@ -323,29 +492,60 @@ function applyEdits(source: string, edits: Edit[]): string {
|
|
|
323
492
|
}
|
|
324
493
|
|
|
325
494
|
/**
|
|
326
|
-
*
|
|
327
|
-
*
|
|
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.
|
|
328
503
|
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
* - The file fails to parse (syntax error — let downstream handle).
|
|
332
|
-
* - No `<Defer>` matches the inline-eligible shape.
|
|
333
|
-
*
|
|
334
|
-
* Per-Defer skips with a warning:
|
|
335
|
-
* - Multiple children → user must use render-prop form
|
|
336
|
-
* - Child has props → user must use render-prop form
|
|
337
|
-
* - Child name isn't imported → can't resolve the chunk source
|
|
338
|
-
* - Child binding is used outside the Defer subtree → can't remove
|
|
339
|
-
* the static import (dynamic import would be a no-op via Rolldown's
|
|
340
|
-
* same-module dedup)
|
|
504
|
+
* Case (1) is the simple v1 path; cases (2) and (3) are the v2
|
|
505
|
+
* multi-specifier handling.
|
|
341
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
|
+
|
|
342
542
|
export function transformDeferInline(
|
|
343
543
|
code: string,
|
|
344
544
|
filename = 'input.tsx',
|
|
345
545
|
): DeferInlineResult {
|
|
346
546
|
const warnings: DeferInlineWarning[] = []
|
|
347
547
|
|
|
348
|
-
// Fast path — skip
|
|
548
|
+
// Fast path — skip parse entirely when no `Defer` mention.
|
|
349
549
|
if (!code.includes('Defer')) {
|
|
350
550
|
return { code, changed: false, warnings }
|
|
351
551
|
}
|
|
@@ -358,23 +558,30 @@ export function transformDeferInline(
|
|
|
358
558
|
})
|
|
359
559
|
program = result.program as Node
|
|
360
560
|
} catch {
|
|
361
|
-
// Parse failure — leave to the downstream transformJSX which reports
|
|
362
|
-
// its own diagnostics.
|
|
363
561
|
return { code, changed: false, warnings }
|
|
364
562
|
}
|
|
365
563
|
|
|
366
|
-
const matches = findDeferMatches(program)
|
|
564
|
+
const matches = findDeferMatches(program, warnings, code)
|
|
367
565
|
if (matches.length === 0) return { code, changed: false, warnings }
|
|
368
566
|
|
|
369
567
|
const edits: Edit[] = []
|
|
370
568
|
let changed = false
|
|
371
569
|
|
|
372
570
|
for (const m of matches) {
|
|
373
|
-
|
|
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)
|
|
374
581
|
if (!importInfo) {
|
|
375
582
|
const loc = getLoc(code, (m.child.start as number) ?? 0)
|
|
376
583
|
warnings.push({
|
|
377
|
-
message: `<Defer>'s inline child <${
|
|
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.`,
|
|
378
585
|
line: loc.line,
|
|
379
586
|
column: loc.column,
|
|
380
587
|
code: 'defer-inline/import-not-found',
|
|
@@ -382,11 +589,43 @@ export function transformDeferInline(
|
|
|
382
589
|
continue
|
|
383
590
|
}
|
|
384
591
|
|
|
385
|
-
|
|
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
|
+
)
|
|
386
625
|
if (outsideUses > 0) {
|
|
387
626
|
const loc = getLoc(code, (m.node.start as number) ?? 0)
|
|
388
627
|
warnings.push({
|
|
389
|
-
message: `<Defer>'s inline child <${
|
|
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.`,
|
|
390
629
|
line: loc.line,
|
|
391
630
|
column: loc.column,
|
|
392
631
|
code: 'defer-inline/import-used-elsewhere',
|
|
@@ -394,35 +633,37 @@ export function transformDeferInline(
|
|
|
394
633
|
continue
|
|
395
634
|
}
|
|
396
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
|
+
|
|
397
645
|
// 1. Insert chunk attribute just before the opening tag's `>`.
|
|
398
646
|
edits.push({
|
|
399
647
|
start: m.insertChunkAt,
|
|
400
648
|
end: m.insertChunkAt,
|
|
401
|
-
replacement: buildChunkAttr(importInfo.source, importInfo.kind,
|
|
649
|
+
replacement: buildChunkAttr(importInfo.source, importInfo.kind, exportName),
|
|
402
650
|
})
|
|
403
651
|
|
|
404
|
-
// 2. Replace the children
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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.
|
|
409
657
|
edits.push({
|
|
410
658
|
start: m.childrenRange.start,
|
|
411
659
|
end: m.childrenRange.end,
|
|
412
|
-
replacement:
|
|
660
|
+
replacement: buildRenderPropBody(code, m.childAnalysis, m.childrenRange),
|
|
413
661
|
})
|
|
414
662
|
|
|
415
|
-
// 3. Remove the static import
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
let impEnd = importInfo.node.end as number
|
|
420
|
-
if (code[impEnd] === '\n') impEnd += 1
|
|
421
|
-
edits.push({
|
|
422
|
-
start: impStart,
|
|
423
|
-
end: impEnd,
|
|
424
|
-
replacement: '',
|
|
425
|
-
})
|
|
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))
|
|
426
667
|
|
|
427
668
|
changed = true
|
|
428
669
|
}
|
|
@@ -432,12 +673,11 @@ export function transformDeferInline(
|
|
|
432
673
|
return { code: applyEdits(code, edits), changed: true, warnings }
|
|
433
674
|
}
|
|
434
675
|
|
|
435
|
-
/** Resolve a byte offset into 1-based line + 0-based column. */
|
|
436
676
|
function getLoc(code: string, offset: number): { line: number; column: number } {
|
|
437
677
|
let line = 1
|
|
438
678
|
let lastNl = -1
|
|
439
679
|
for (let i = 0; i < offset && i < code.length; i++) {
|
|
440
|
-
if (code.charCodeAt(i) === 10
|
|
680
|
+
if (code.charCodeAt(i) === 10) {
|
|
441
681
|
line++
|
|
442
682
|
lastNl = i
|
|
443
683
|
}
|