@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.
Files changed (41) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +2081 -1262
  3. package/lib/types/index.d.ts +310 -125
  4. package/package.json +14 -12
  5. package/src/defer-inline.ts +397 -157
  6. package/src/index.ts +14 -2
  7. package/src/jsx.ts +784 -19
  8. package/src/load-native.ts +1 -0
  9. package/src/manifest.ts +280 -0
  10. package/src/pyreon-intercept.ts +164 -0
  11. package/src/react-intercept.ts +59 -0
  12. package/src/reactivity-lens.ts +190 -0
  13. package/src/tests/backend-parity-r7-r9.test.ts +91 -0
  14. package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
  15. package/src/tests/collapse-bail-census.test.ts +245 -0
  16. package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
  17. package/src/tests/defer-inline.test.ts +209 -21
  18. package/src/tests/detector-tag-consistency.test.ts +2 -0
  19. package/src/tests/element-valued-const-child.test.ts +61 -0
  20. package/src/tests/falsy-child-characterization.test.ts +48 -0
  21. package/src/tests/malformed-input-resilience.test.ts +50 -0
  22. package/src/tests/manifest-snapshot.test.ts +55 -0
  23. package/src/tests/native-equivalence.test.ts +104 -3
  24. package/src/tests/partial-collapse-detector.test.ts +121 -0
  25. package/src/tests/partial-collapse-emit.test.ts +104 -0
  26. package/src/tests/partial-collapse-robustness.test.ts +53 -0
  27. package/src/tests/prop-derived-shadow.test.ts +96 -0
  28. package/src/tests/pure-call-reactive-args.test.ts +50 -0
  29. package/src/tests/pyreon-intercept.test.ts +189 -0
  30. package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
  31. package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
  32. package/src/tests/r15-elemconst-propderived.test.ts +47 -0
  33. package/src/tests/r19-defer-inline-robust.test.ts +54 -0
  34. package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
  35. package/src/tests/react-intercept.test.ts +50 -2
  36. package/src/tests/reactivity-lens.test.ts +170 -0
  37. package/src/tests/rocketstyle-collapse.test.ts +208 -0
  38. package/src/tests/signal-autocall-shadow.test.ts +86 -0
  39. package/src/tests/sourcemap-fidelity.test.ts +77 -0
  40. package/src/tests/static-text-baking.test.ts +64 -0
  41. package/src/tests/transform-state-isolation.test.ts +49 -0
@@ -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(m => ({ default: m.Modal }))}>
12
- * {C => <C />}
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 of v1 (this file):
20
- * - Single Defer element per file (no nested handling — bail otherwise).
21
- * - Children: exactly ONE JSXElement, self-closing, capitalised name
22
- * (component reference), no props. Props or multiple children leave
23
- * the Defer untransformed (user must use the explicit `chunk` form).
24
- * - Imports: named OR default. Namespace imports (`import * as Mod`)
25
- * and destructured-renamed (`{ X as Y }`) not handled in v1.
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. v2 follow-ups can relax these
31
- * constraints with closure-capture handling, namespace imports, etc.
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 (the only shape we recognise as a "named JSX
91
- * element"). Member-expression names (`<obj.X />`) and namespaced names
92
- * (`<svg:rect />`) return null — the caller treats those as non-matches.
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
- * `<Tag />` qualifies as a "bare component reference child" when:
104
- * - It's a JSXElement (not text, fragment, or expression container).
105
- * - The opening name is a capitalised JSXIdentifier (component).
106
- * - It has no attributes (no props passed).
107
- * - It's self-closing OR has zero non-whitespace children.
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
- function isBareComponentChild(node: Node): { name: string } | null {
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 attrs = (open.attributes as Node[] | undefined) ?? []
115
- if (attrs.length > 0) return null
116
- const children = (node.children as Node[] | undefined) ?? []
117
- for (const child of children) {
118
- if (child.type === 'JSXText' && /^\s*$/.test(child.value as string)) continue
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
- return { name: tag }
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
- /** Component identifier name (e.g. 'Modal'). */
148
- childName: string
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 child JSX element + surrounding whitespace inside Defer's open/close tags. */
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 === 1) {
173
- const childInfo = isBareComponentChild(live[0]!)
174
- if (childInfo) {
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
- childName: childInfo.name,
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
- * Find ImportDeclarations matching a target identifier and classify them.
214
- * Returns null when the binding can't be resolved or the import shape
215
- * isn't one we handle (namespace, renamed destructure).
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
- /** The `ImportDeclaration` AST node. */
219
- node: Node
220
- /** The module source string (without quotes). */
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
- /** 'default' or 'named' — controls how the rewrite resolves the chunk. */
223
- kind: 'default' | 'named'
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
- function findImportFor(program: Node, name: string): ImportInfo | null {
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 local = (spec.local as Node).name as string
234
- if (local === name) {
354
+ const lname = (spec.local as Node).name as string
355
+ if (lname === localName) {
235
356
  return {
236
- node: stmt,
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 local = (spec.local as Node).name as string
243
- const imported = (spec.imported as Node | undefined)?.name as string | undefined
244
- // Only handle the un-renamed case: `import { Modal } from ...`.
245
- // `{ Modal as M }` skip (would need to know the original export
246
- // name for the chunk-resolution path; v1 bails).
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
- node: stmt,
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 `name` outside the given JSXElement subtree. The
263
- * static import can only be safely removed if the binding is used
264
- * EXCLUSIVELY inside that subtree.
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(program: Node, name: string, skipSubtree: Node): number {
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
- // Walk every statement except ImportDeclarations (we don't want the
272
- // import specifier itself to count as a usage). Within each statement
273
- // walk recursively, skipping any subtree whose byte range falls
274
- // entirely inside the Defer being rewritten.
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 >= skipStart && ne <= skipEnd) {
280
- return
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) countInNode(item as Node)
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
- countInNode(v as Node)
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
- /** Build the chunk={...} attribute string for a default or named import. */
303
- function buildChunkAttr(source: string, kind: 'default' | 'named', name: string): string {
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
- // Named: re-wrap so the chunk's `default` is the named export.
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
- * Apply edits to the source string. Edits MUST be non-overlapping; we
313
- * sort by start descending and splice each into the source so earlier
314
- * positions stay valid as we work backwards.
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
- * Main entry. Returns the (possibly transformed) source plus the list
327
- * of warnings for cases the transform deliberately skipped.
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
- * Bails (returns input unchanged with `changed: false`) when:
330
- * - No `<Defer>` JSX element appears in the file (fast path).
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 the parse entirely when the file has no Defer mention.
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
- const importInfo = findImportFor(program, m.childName)
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 <${m.childName} /> isn't imported — can't resolve a chunk source. Use the explicit \`chunk\` prop, or import ${m.childName} from a module.`,
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
- const outsideUses = countReferencesOutside(program, m.childName, m.node)
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 <${m.childName} /> 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.`,
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, m.childName),
649
+ replacement: buildChunkAttr(importInfo.source, importInfo.kind, exportName),
402
650
  })
403
651
 
404
- // 2. Replace the children (the bare `<Modal />`) with a render-prop
405
- // that invokes the loaded component. Preserve surrounding
406
- // whitespace by replacing only the JSX text region inside Defer's
407
- // open/close tags. Use a non-letter identifier for the render-prop
408
- // binding (`__C`) to avoid clashing with anything in scope.
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: `{(__C) => <__C />}`,
660
+ replacement: buildRenderPropBody(code, m.childAnalysis, m.childrenRange),
413
661
  })
414
662
 
415
- // 3. Remove the static import. Replace the entire ImportDeclaration
416
- // range with an empty string. Includes the trailing newline if
417
- // present so we don't leave a blank line.
418
- const impStart = importInfo.node.start as number
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 /* \n */) {
680
+ if (code.charCodeAt(i) === 10) {
441
681
  line++
442
682
  lastNl = i
443
683
  }