@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.
Files changed (64) hide show
  1. package/package.json +11 -13
  2. package/src/defer-inline.ts +0 -686
  3. package/src/event-names.ts +0 -65
  4. package/src/index.ts +0 -61
  5. package/src/island-audit.ts +0 -675
  6. package/src/jsx.ts +0 -2792
  7. package/src/load-native.ts +0 -156
  8. package/src/lpih.ts +0 -270
  9. package/src/manifest.ts +0 -280
  10. package/src/project-scanner.ts +0 -214
  11. package/src/pyreon-intercept.ts +0 -1029
  12. package/src/react-intercept.ts +0 -1217
  13. package/src/reactivity-lens.ts +0 -190
  14. package/src/ssg-audit.ts +0 -513
  15. package/src/test-audit.ts +0 -435
  16. package/src/tests/backend-parity-r7-r9.test.ts +0 -91
  17. package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
  18. package/src/tests/collapse-bail-census.test.ts +0 -330
  19. package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
  20. package/src/tests/component-child-no-wrap.test.ts +0 -204
  21. package/src/tests/defer-inline.test.ts +0 -387
  22. package/src/tests/depth-stress.test.ts +0 -16
  23. package/src/tests/detector-tag-consistency.test.ts +0 -101
  24. package/src/tests/dynamic-collapse-detector.test.ts +0 -164
  25. package/src/tests/dynamic-collapse-emit.test.ts +0 -192
  26. package/src/tests/dynamic-collapse-scan.test.ts +0 -111
  27. package/src/tests/element-valued-const-child.test.ts +0 -61
  28. package/src/tests/falsy-child-characterization.test.ts +0 -48
  29. package/src/tests/island-audit.test.ts +0 -524
  30. package/src/tests/jsx.test.ts +0 -2908
  31. package/src/tests/load-native.test.ts +0 -53
  32. package/src/tests/lpih.test.ts +0 -404
  33. package/src/tests/malformed-input-resilience.test.ts +0 -50
  34. package/src/tests/manifest-snapshot.test.ts +0 -55
  35. package/src/tests/native-equivalence.test.ts +0 -924
  36. package/src/tests/partial-collapse-detector.test.ts +0 -121
  37. package/src/tests/partial-collapse-emit.test.ts +0 -104
  38. package/src/tests/partial-collapse-robustness.test.ts +0 -53
  39. package/src/tests/project-scanner.test.ts +0 -269
  40. package/src/tests/prop-derived-shadow.test.ts +0 -96
  41. package/src/tests/pure-call-reactive-args.test.ts +0 -50
  42. package/src/tests/pyreon-intercept.test.ts +0 -816
  43. package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
  44. package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
  45. package/src/tests/r15-elemconst-propderived.test.ts +0 -47
  46. package/src/tests/r19-defer-inline-robust.test.ts +0 -54
  47. package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
  48. package/src/tests/react-intercept.test.ts +0 -1104
  49. package/src/tests/reactivity-lens.test.ts +0 -170
  50. package/src/tests/rocketstyle-collapse.test.ts +0 -208
  51. package/src/tests/runtime/control-flow.test.ts +0 -159
  52. package/src/tests/runtime/dom-properties.test.ts +0 -138
  53. package/src/tests/runtime/events.test.ts +0 -301
  54. package/src/tests/runtime/harness.ts +0 -94
  55. package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
  56. package/src/tests/runtime/reactive-props.test.ts +0 -81
  57. package/src/tests/runtime/signals.test.ts +0 -129
  58. package/src/tests/runtime/whitespace.test.ts +0 -106
  59. package/src/tests/signal-autocall-shadow.test.ts +0 -86
  60. package/src/tests/sourcemap-fidelity.test.ts +0 -77
  61. package/src/tests/ssg-audit.test.ts +0 -402
  62. package/src/tests/static-text-baking.test.ts +0 -64
  63. package/src/tests/test-audit.test.ts +0 -549
  64. package/src/tests/transform-state-isolation.test.ts +0 -49
@@ -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
- }