@pyreon/compiler 0.19.0 → 0.21.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 (28) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/index.js +418 -18
  3. package/lib/types/index.d.ts +92 -1
  4. package/package.json +13 -12
  5. package/src/index.ts +2 -1
  6. package/src/jsx.ts +669 -17
  7. package/src/tests/backend-parity-r7-r9.test.ts +91 -0
  8. package/src/tests/backend-prop-derived-callback-divergence.test.ts +74 -0
  9. package/src/tests/collapse-bail-census.test.ts +245 -0
  10. package/src/tests/collapse-key-source-hygiene.test.ts +88 -0
  11. package/src/tests/element-valued-const-child.test.ts +61 -0
  12. package/src/tests/falsy-child-characterization.test.ts +48 -0
  13. package/src/tests/malformed-input-resilience.test.ts +50 -0
  14. package/src/tests/partial-collapse-detector.test.ts +121 -0
  15. package/src/tests/partial-collapse-emit.test.ts +104 -0
  16. package/src/tests/partial-collapse-robustness.test.ts +53 -0
  17. package/src/tests/prop-derived-shadow.test.ts +96 -0
  18. package/src/tests/pure-call-reactive-args.test.ts +50 -0
  19. package/src/tests/r13-callback-stmt-equivalence.test.ts +58 -0
  20. package/src/tests/r14-ssr-mode-parity.test.ts +51 -0
  21. package/src/tests/r15-elemconst-propderived.test.ts +47 -0
  22. package/src/tests/r19-defer-inline-robust.test.ts +54 -0
  23. package/src/tests/r20-backend-equivalence-sweep.test.ts +50 -0
  24. package/src/tests/rocketstyle-collapse.test.ts +208 -0
  25. package/src/tests/signal-autocall-shadow.test.ts +86 -0
  26. package/src/tests/sourcemap-fidelity.test.ts +77 -0
  27. package/src/tests/static-text-baking.test.ts +64 -0
  28. package/src/tests/transform-state-isolation.test.ts +49 -0
package/src/jsx.ts CHANGED
@@ -28,10 +28,28 @@
28
28
  * Implementation: Rust native binary (napi-rs) when available, JS fallback via oxc-parser.
29
29
  */
30
30
 
31
+ import MagicString from 'magic-string'
31
32
  import { parseSync } from 'oxc-parser'
32
33
  import { REACT_EVENT_REMAP } from './event-names'
33
34
  import { loadNativeBinding } from './load-native'
34
35
 
36
+ /**
37
+ * V3 source map shape returned by the JS backend. Structurally exactly
38
+ * magic-string's `SourceMap` (a valid V3 map plus `.toString()`/`.toUrl()`),
39
+ * declared locally so `TransformResult` carries no hard type dependency on
40
+ * magic-string's exported types.
41
+ */
42
+ export interface GeneratedSourceMap {
43
+ version: number
44
+ file?: string
45
+ sources: string[]
46
+ sourcesContent?: (string | null)[]
47
+ names: string[]
48
+ mappings: string
49
+ toString(): string
50
+ toUrl(): string
51
+ }
52
+
35
53
  // ─── Native binary auto-detection ────────────────────────────────────────────
36
54
  // Two-path resolution: in-tree binary first (dev mode), then per-platform
37
55
  // npm package (production install via optionalDependencies). Falls through
@@ -108,6 +126,15 @@ export interface TransformResult {
108
126
  usesTemplates?: boolean
109
127
  /** Compiler warnings for common mistakes */
110
128
  warnings: CompilerWarning[]
129
+ /**
130
+ * Source map (V3) for the transform — present on the JS backend whenever a
131
+ * transformation actually occurred. `undefined` when nothing changed (the
132
+ * emitted code is byte-identical to the input, so no remapping is needed)
133
+ * and on the native backend (a Rust-side map is a scoped follow-up). The
134
+ * object is magic-string's `SourceMap`: it is a valid V3 map AND has
135
+ * `.toString()` / `.toUrl()`, so Vite/Rollup consume it directly.
136
+ */
137
+ map?: GeneratedSourceMap
111
138
  /**
112
139
  * Reactivity-lens spans — populated ONLY when `TransformOptions.reactivityLens`
113
140
  * is `true`. Additive: codegen output is byte-identical whether or not this is
@@ -160,6 +187,64 @@ export interface TransformOptions {
160
187
  * codegen; it never runs a second analysis pass.
161
188
  */
162
189
  reactivityLens?: boolean
190
+
191
+ /**
192
+ * P0 — compile-time rocketstyle wrapper collapse. OFF unless the Vite
193
+ * plugin supplies this (opt-in `pyreon({ collapse: true })`). The plugin
194
+ * scans the module's imports for collapsible component candidates,
195
+ * SSR-resolves each literal-prop call site once (real component, light
196
+ * + dark), and passes the resolved `sites` map keyed by
197
+ * {@link rocketstyleCollapseKey}. The compiler only DETECTS the
198
+ * collapsible shape (bail catalogue — every dimension prop a string
199
+ * literal, no spread, static-text children) and EMITS the collapsed
200
+ * `_rsCollapse` call + the once-per-module rule injection; it never
201
+ * runs the rocketstyle chain itself (RFC decision 2).
202
+ */
203
+ collapseRocketstyle?: {
204
+ /** Component names imported into this module that MAY collapse. */
205
+ candidates: Set<string>
206
+ /** key → resolved emission data (absent ⇒ bail, keep normal mount). */
207
+ sites: Map<
208
+ string,
209
+ {
210
+ templateHtml: string
211
+ lightClass: string
212
+ darkClass: string
213
+ rules: string[]
214
+ ruleKey: string
215
+ }
216
+ >
217
+ /** Live mode accessor to thread for dual-emit (RFC decision 1). */
218
+ mode: { name: string; source: string }
219
+ /** Module specifier for `_rsCollapse`. Default `@pyreon/runtime-dom`. */
220
+ runtimeDomSource?: string
221
+ /** Module specifier for the styler `sheet`. Default `@pyreon/styler`. */
222
+ stylerSource?: string
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Canonical key for a collapsible rocketstyle call site. The Vite plugin
228
+ * computes this when it resolves a site; the compiler recomputes the
229
+ * IDENTICAL key from the JSX node to look the resolution up. Stable
230
+ * ordering of props so attribute order in source doesn't change the key.
231
+ */
232
+ export function rocketstyleCollapseKey(
233
+ componentName: string,
234
+ props: Record<string, string>,
235
+ childrenText: string,
236
+ ): string {
237
+ const propStr = Object.keys(props)
238
+ .sort()
239
+ .map((k) => `${k}=${props[k]}`)
240
+ .join('\u0001')
241
+ const src = `${componentName}\u0000${propStr}\u0000${childrenText}`
242
+ let h = 2166136261
243
+ for (let i = 0; i < src.length; i++) {
244
+ h ^= src.charCodeAt(i)
245
+ h = Math.imul(h, 16777619)
246
+ }
247
+ return (h >>> 0).toString(36)
163
248
  }
164
249
 
165
250
  // ─── oxc ESTree helpers ───────────────────────────────────────────────────────
@@ -233,6 +318,215 @@ function jsxChildren(node: N): N[] {
233
318
  return node.children ?? []
234
319
  }
235
320
 
321
+ /**
322
+ * A collapsible call site found by {@link scanCollapsibleSites}.
323
+ * `componentName` is the LOCAL JSX tag (post-import-alias) — it MUST be
324
+ * what `rocketstyleCollapseKey` is computed from on BOTH sides so the
325
+ * plugin's resolved `sites` map keys match the compiler's lookups.
326
+ */
327
+ export interface CollapsibleSite {
328
+ /** Local JSX tag name (the key + the compiler's detection use this). */
329
+ componentName: string
330
+ /** Module specifier the component was imported from (for the resolver). */
331
+ source: string
332
+ /** Imported binding name at `source` (may differ from local if aliased). */
333
+ importedName: string
334
+ /** Literal string-valued props (the only shape the slice collapses). */
335
+ props: Record<string, string>
336
+ /** Static text children (trimmed; empty ⇒ none). */
337
+ childrenText: string
338
+ /** `rocketstyleCollapseKey(componentName, props, childrenText)`. */
339
+ key: string
340
+ }
341
+
342
+ /**
343
+ * Build a `localName → { imported, source }` table from a module's
344
+ * import declarations. Only named imports (`import { X as Y }`) are
345
+ * relevant — the collapsible components are always named exports.
346
+ */
347
+ function collectImportTable(program: N): Map<string, { imported: string; source: string }> {
348
+ const table = new Map<string, { imported: string; source: string }>()
349
+ for (const stmt of program.body ?? []) {
350
+ if (stmt.type !== 'ImportDeclaration') continue
351
+ const source = stmt.source?.value
352
+ if (typeof source !== 'string') continue
353
+ for (const spec of stmt.specifiers ?? []) {
354
+ if (spec.type !== 'ImportSpecifier') continue
355
+ const local = spec.local?.name
356
+ const imported = spec.imported?.name ?? local
357
+ if (typeof local === 'string') table.set(local, { imported, source })
358
+ }
359
+ }
360
+ return table
361
+ }
362
+
363
+ /**
364
+ * Pure detector — finds every collapsible rocketstyle call site in a
365
+ * module. Used by `@pyreon/vite-plugin` to know which (component, props,
366
+ * text) tuples to SSR-resolve. The bail catalogue here MUST stay
367
+ * byte-identical to `tryRocketstyleCollapse`'s (RFC decision 3): a
368
+ * candidate PascalCase tag whose import source is in `collapsibleSources`,
369
+ * every attr a plain string literal (no spread, no `{expr}`, no boolean
370
+ * attr), children empty or static text only. A consistency test asserts
371
+ * the keys this produces equal the keys the compiler looks up.
372
+ */
373
+ export function scanCollapsibleSites(
374
+ code: string,
375
+ filename: string,
376
+ collapsibleSources: Set<string>,
377
+ ): CollapsibleSite[] {
378
+ let program: N
379
+ try {
380
+ program = parseSync(filename, code, { sourceType: 'module', lang: getLang(filename) }).program
381
+ } catch {
382
+ return []
383
+ }
384
+ const imports = collectImportTable(program)
385
+ const out: CollapsibleSite[] = []
386
+ const visit = (node: N): void => {
387
+ if (!node || typeof node !== 'object') return
388
+ if (node.type === 'JSXElement') {
389
+ const tag = jsxTagName(node)
390
+ const imp = tag ? imports.get(tag) : undefined
391
+ if (
392
+ tag &&
393
+ tag.charAt(0) !== tag.charAt(0).toLowerCase() &&
394
+ imp &&
395
+ collapsibleSources.has(imp.source)
396
+ ) {
397
+ const site = detectCollapsibleShape(node, tag)
398
+ if (site) {
399
+ out.push({
400
+ componentName: tag,
401
+ source: imp.source,
402
+ importedName: imp.imported,
403
+ props: site.props,
404
+ childrenText: site.childrenText,
405
+ key: rocketstyleCollapseKey(tag, site.props, site.childrenText),
406
+ })
407
+ }
408
+ }
409
+ }
410
+ for (const k in node) {
411
+ const v = node[k]
412
+ if (Array.isArray(v)) for (const c of v) visit(c)
413
+ else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
414
+ }
415
+ }
416
+ visit(program)
417
+ return out
418
+ }
419
+
420
+ /**
421
+ * The shared bail catalogue — every attr a string literal (no spread, no
422
+ * `{expr}`, no boolean attr), children empty or static text. Returns the
423
+ * extracted {props, childrenText} or null (bail). `tryRocketstyleCollapse`
424
+ * inlines the identical checks; a consistency test locks them together.
425
+ */
426
+ function detectCollapsibleShape(
427
+ node: N,
428
+ _tag: string,
429
+ ): { props: Record<string, string>; childrenText: string } | null {
430
+ const props: Record<string, string> = {}
431
+ for (const attr of jsxAttrs(node)) {
432
+ if (attr.type !== 'JSXAttribute') return null // spread → bail
433
+ const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
434
+ if (!nm) return null
435
+ const v = attr.value
436
+ if (!v) return null // boolean attr → bail
437
+ const isStr =
438
+ v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
439
+ if (!isStr) return null // `{expr}` / dynamic → bail
440
+ props[nm] = String(v.value)
441
+ }
442
+ let childrenText = ''
443
+ for (const c of jsxChildren(node)) {
444
+ if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
445
+ else return null // element / expression child → bail
446
+ }
447
+ return { props, childrenText: childrenText.trim() }
448
+ }
449
+
450
+ /** A residual event handler peeled off a partially-collapsible site. */
451
+ export interface CollapsibleHandler {
452
+ /** JSX attribute name, e.g. `onClick`. */
453
+ name: string
454
+ /** Source span of the handler expression (the `{...}` contents). */
455
+ exprStart: number
456
+ exprEnd: number
457
+ }
458
+
459
+ /**
460
+ * Partial-collapse detector — PR 1 of the partial-collapse spec
461
+ * (`.claude/plans/open-work-2026-q3.md` → #1). The `on*`-handler-only
462
+ * subset the bail-reason census measured at 7.8% of all
463
+ * `@pyreon/ui-components` call sites (`collapse-bail-census.test.ts`).
464
+ *
465
+ * It is the EXACT `detectCollapsibleShape` bail catalogue with ONE
466
+ * relaxation: a `{expr}`-valued attribute whose name matches `on[A-Z]…`
467
+ * (an event handler) does NOT bail — it is peeled into `handlers[]`
468
+ * instead. Handlers are orthogonal to the SSR-resolved styler class (an
469
+ * event binding never changes rendered CSS), so the literal-prop subset
470
+ * still feeds the UNCHANGED `rocketstyleCollapseKey` and the resolver's
471
+ * pre-resolved `templateHtml` / `lightClass` / `darkClass` are
472
+ * byte-identical to a full-collapse site's. The collapsed runtime node
473
+ * just re-attaches the residual handlers (PR 2 — `_rsCollapseH`).
474
+ *
475
+ * Every OTHER non-literal shape still bails (spread, non-handler
476
+ * `{expr}` prop, boolean attr, element/expression child) — conservative
477
+ * by construction, exactly like the full detector. Returns `null` when
478
+ * there are ZERO handlers so the full-collapse path stays byte-unchanged
479
+ * and the two detectors never both claim the same site (full-collapse
480
+ * sites have no handlers; partial sites have ≥1). A consistency test
481
+ * will lock this catalogue against the plugin scan in PR 3, mirroring
482
+ * the existing `detectCollapsibleShape` ↔ `scanCollapsibleSites`
483
+ * invariant — keys cannot drift.
484
+ */
485
+ export function detectPartialCollapsibleShape(
486
+ node: N,
487
+ _tag: string,
488
+ ): { props: Record<string, string>; childrenText: string; handlers: CollapsibleHandler[] } | null {
489
+ const props: Record<string, string> = {}
490
+ const handlers: CollapsibleHandler[] = []
491
+ for (const attr of jsxAttrs(node)) {
492
+ if (attr.type !== 'JSXAttribute') return null // spread → bail
493
+ const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
494
+ if (!nm) return null
495
+ const v = attr.value
496
+ if (!v) return null // boolean attr → bail
497
+ const isStr =
498
+ v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
499
+ if (isStr) {
500
+ props[nm] = String(v.value)
501
+ continue
502
+ }
503
+ // Non-literal: ONLY an `on[A-Z]…` handler in a `{expr}` container is
504
+ // peelable. Everything else (non-handler dynamic prop, shorthand
505
+ // `onClick` without a container, etc.) is a hard bail — same
506
+ // conservatism as the full detector.
507
+ if (
508
+ /^on[A-Z]/.test(nm) &&
509
+ v.type === 'JSXExpressionContainer' &&
510
+ v.expression &&
511
+ typeof v.expression.start === 'number' &&
512
+ typeof v.expression.end === 'number'
513
+ ) {
514
+ handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
515
+ continue
516
+ }
517
+ return null // `{expr}` non-handler / dynamic → bail
518
+ }
519
+ let childrenText = ''
520
+ for (const c of jsxChildren(node)) {
521
+ if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
522
+ else return null // element / expression child → bail
523
+ }
524
+ // Zero handlers ⇒ this is the FULL-collapse shape; defer to
525
+ // `detectCollapsibleShape` so the existing path stays byte-unchanged.
526
+ if (handlers.length === 0) return null
527
+ return { props, childrenText: childrenText.trim(), handlers }
528
+ }
529
+
236
530
  // ─── Main transform ─────────────────────────────────────────────────────────
237
531
 
238
532
  export function transformJSX(
@@ -240,6 +534,12 @@ export function transformJSX(
240
534
  filename = 'input.tsx',
241
535
  options: TransformOptions = {},
242
536
  ): TransformResult {
537
+ // `collapseRocketstyle` emission lives only in the JS path (the Rust
538
+ // binary doesn't implement it and isn't passed the option). Force the
539
+ // JS path when collapse is requested so it isn't silently skipped —
540
+ // same pattern as `analyzeReactivity` forcing `transformJSX_JS`.
541
+ if (options.collapseRocketstyle) return transformJSX_JS(code, filename, options)
542
+
243
543
  // Try Rust native binary first (3.7-8.2x faster).
244
544
  // Per-call try/catch: if the native binary panics on an edge case
245
545
  // (bad UTF-8, unexpected AST shape), fall back gracefully instead
@@ -362,6 +662,109 @@ export function transformJSX_JS(
362
662
  let needsApplyPropsImportGlobal = false
363
663
  let needsMountSlotImportGlobal = false
364
664
 
665
+ // ── P0 rocketstyle-collapse state ─────────────────────────────────────────
666
+ let needsCollapse = false
667
+ let needsCollapseH = false
668
+ const collapseRuleKeys = new Set<string>()
669
+ const collapseRules: Array<{ ruleKey: string; rules: string[] }> = []
670
+
671
+ /**
672
+ * Detect + collapse a literal-prop rocketstyle call site. Conservative
673
+ * bail catalogue (RFC decision 3): PascalCase candidate, every attr a
674
+ * StringLiteral (no spread, no `{expr}`, no boolean attr), children
675
+ * empty or a single static JSXText. The plugin must already have
676
+ * SSR-resolved this exact (component, props, text) tuple — an absent
677
+ * `sites` entry is a hard bail (covers resolver-bailed shapes,
678
+ * cross-package-without-data, anything uncertain). Emits ONE
679
+ * `_rsCollapse(tpl, light, dark, () => mode()==='dark')` (dual-emit)
680
+ * plus a once-per-module idempotent `injectRules`. A false negative is
681
+ * correct-but-slow; a false positive is wrong output — so every
682
+ * uncertain signal returns false.
683
+ */
684
+ function tryRocketstyleCollapse(node: N): boolean {
685
+ const cfg = options.collapseRocketstyle
686
+ if (!cfg) return false
687
+ const tag = jsxTagName(node)
688
+ if (!tag || tag.charAt(0) === tag.charAt(0).toLowerCase()) return false
689
+ if (!cfg.candidates.has(tag)) return false
690
+ // Shared bail catalogue — IDENTICAL to scanCollapsibleSites (the
691
+ // plugin scans with the same predicate, so its resolved `sites`
692
+ // keys match these lookups exactly; no drift possible).
693
+ const shape = detectCollapsibleShape(node, tag)
694
+ if (!shape) return tryPartialCollapse(node, tag) // PR 3: on*-handler-only fallback
695
+ const { props, childrenText } = shape
696
+ const key = rocketstyleCollapseKey(tag, props, childrenText)
697
+ const site = cfg.sites.get(key)
698
+ if (!site) return false // not resolved → keep normal rocketstyle mount
699
+ const call =
700
+ `__rsCollapse(${JSON.stringify(site.templateHtml)}, ` +
701
+ `${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
702
+ `() => __pyrMode() === "dark")`
703
+ const start = node.start as number
704
+ const end = node.end as number
705
+ const parent = findParent(node)
706
+ const needsBraces =
707
+ parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
708
+ replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
709
+ needsCollapse = true
710
+ if (!collapseRuleKeys.has(site.ruleKey)) {
711
+ collapseRuleKeys.add(site.ruleKey)
712
+ collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
713
+ }
714
+ return true
715
+ }
716
+
717
+ /**
718
+ * PR 3 of the partial-collapse build (open-work #1). The `on*`-handler-
719
+ * only fallback `tryRocketstyleCollapse` defers to when the FULL
720
+ * `detectCollapsibleShape` bails. Identical site-resolution contract as
721
+ * the full path — handlers are orthogonal to the SSR-resolved styler
722
+ * class, so the literal-prop subset feeds the UNCHANGED
723
+ * `rocketstyleCollapseKey` and the resolver's pre-resolved
724
+ * `templateHtml`/`lightClass`/`darkClass` are byte-identical to a
725
+ * full-collapse site's. The ONLY difference vs the full emit is
726
+ * `__rsCollapseH(...)` with a handlers object literal built from the
727
+ * sliced source spans `detectPartialCollapsibleShape` (PR 1) returned;
728
+ * the runtime helper (`_rsCollapseH`, PR 2 / #681) re-attaches them
729
+ * through the canonical event path. Same conservative discipline: an
730
+ * unresolved key, the option absent, or any non-handler non-literal
731
+ * shape ⇒ keep the normal mount (return false).
732
+ */
733
+ function tryPartialCollapse(node: N, tag: string): boolean {
734
+ const cfg = options.collapseRocketstyle
735
+ if (!cfg) return false
736
+ const partial = detectPartialCollapsibleShape(node, tag)
737
+ if (!partial) return false
738
+ const { props, childrenText, handlers } = partial
739
+ const key = rocketstyleCollapseKey(tag, props, childrenText)
740
+ const site = cfg.sites.get(key)
741
+ if (!site) return false // not resolved → keep normal rocketstyle mount
742
+ // `{ "onClick": (<sliced expr>), … }` — each handler expression is
743
+ // re-emitted verbatim from its source span (paren-wrapped so an
744
+ // arrow / sequence expr stays a single argument).
745
+ const handlerObj =
746
+ `{ ${handlers
747
+ .map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
748
+ .join(', ')} }`
749
+ const call =
750
+ `__rsCollapseH(${JSON.stringify(site.templateHtml)}, ` +
751
+ `${JSON.stringify(site.lightClass)}, ${JSON.stringify(site.darkClass)}, ` +
752
+ `() => __pyrMode() === "dark", ${handlerObj})`
753
+ const start = node.start as number
754
+ const end = node.end as number
755
+ const parent = findParent(node)
756
+ const needsBraces =
757
+ parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
758
+ replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
759
+ needsCollapse = true
760
+ needsCollapseH = true
761
+ if (!collapseRuleKeys.has(site.ruleKey)) {
762
+ collapseRuleKeys.add(site.ruleKey)
763
+ collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
764
+ }
765
+ return true
766
+ }
767
+
365
768
  function maybeHoist(node: N): string | null {
366
769
  if (
367
770
  (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
@@ -525,6 +928,15 @@ export function transformJSX_JS(
525
928
  // ── Prop-derived variable tracking (collected during the single walk) ─────
526
929
  const propsNames = new Set<string>()
527
930
  const propDerivedVars = new Map<string, { start: number; end: number }>()
931
+ // Round 9 fix: names of const/let bindings whose initializer is a JSX
932
+ // element (`const x = <El/>`). A bare `{x}` child of such a binding must be
933
+ // MOUNTED, not text-coerced — pre-fix it emitted `createTextNode(x)` which
934
+ // stringifies the NativeItem to "[object Object]". Routing through
935
+ // `_mountSlot` (the general child-insert `props.children` already uses) is
936
+ // safe even if a same-named binding is later shadowed by a string/number:
937
+ // `_mountSlot` renders those correctly too — the only cost of imprecision
938
+ // is skipping the createTextNode fast path, never a correctness regression.
939
+ const elementVars = new Set<string>()
528
940
 
529
941
  // ── Signal variable tracking (for auto-call in JSX) ──────────────────────
530
942
  // Tracks `const x = signal(...)` declarations. In JSX expressions, bare
@@ -631,6 +1043,18 @@ export function transformJSX_JS(
631
1043
  }
632
1044
  }
633
1045
  }
1046
+ // Round 9: track element-valued bindings (`const`/`let`, any depth) so
1047
+ // a bare `{x}` child routes to _mountSlot instead of createTextNode.
1048
+ // Tight: only a DIRECT JSX element/fragment initializer (optionally
1049
+ // parenthesized) — conditionals/calls go the existing reactive/text
1050
+ // paths and must not be reclassified here.
1051
+ if ((node.kind === 'const' || node.kind === 'let') && decl.id?.type === 'Identifier' && decl.init) {
1052
+ let initNode = decl.init
1053
+ while (initNode?.type === 'ParenthesizedExpression') initNode = initNode.expression
1054
+ if (initNode?.type === 'JSXElement' || initNode?.type === 'JSXFragment') {
1055
+ elementVars.add(decl.id.name)
1056
+ }
1057
+ }
634
1058
  if (node.kind !== 'const') continue
635
1059
  if (callbackDepth > 0) continue
636
1060
  if (decl.id?.type === 'Identifier' && decl.init) {
@@ -707,13 +1131,99 @@ export function transformJSX_JS(
707
1131
  const endOffset = baseOffset + text.length
708
1132
  const idents: { start: number; end: number; name: string }[] = []
709
1133
 
1134
+ // ── Scope-aware shadow tracking ──────────────────────────────────────────
1135
+ // Prop-derived consts are only ever COLLECTED at component top level
1136
+ // (callbackDepth === 0), so ANY same-named binding in a deeper lexical
1137
+ // scope necessarily shadows it. Substituting a shadowed reference (or a
1138
+ // binding occurrence) miscompiles idiomatic code — e.g.
1139
+ // `const a = props.x; items.map(a => <li>{a}</li>)` would rewrite the
1140
+ // arrow PARAMETER `a` into `(props.x)` (invalid `(props.x) =>`) and the
1141
+ // body `{a}` (the map item) into `props.x`. The signal-auto-call pass is
1142
+ // already scope-aware via `shadowedSignals`; this mirrors that discipline
1143
+ // for the prop-derived inlining pass.
1144
+ const shadowed = new Set<string>()
1145
+
1146
+ /** Collect identifier names bound by a pattern (params / declarators). */
1147
+ function patternBindingNames(p: N, out: string[]): void {
1148
+ if (!p) return
1149
+ switch (p.type) {
1150
+ case 'Identifier':
1151
+ out.push(p.name)
1152
+ break
1153
+ case 'ObjectPattern':
1154
+ for (const pr of p.properties ?? []) {
1155
+ if (pr.type === 'RestElement') patternBindingNames(pr.argument, out)
1156
+ else patternBindingNames(pr.value ?? pr.key, out)
1157
+ }
1158
+ break
1159
+ case 'ArrayPattern':
1160
+ for (const el of p.elements ?? []) patternBindingNames(el, out)
1161
+ break
1162
+ case 'AssignmentPattern':
1163
+ patternBindingNames(p.left, out)
1164
+ break
1165
+ case 'RestElement':
1166
+ patternBindingNames(p.argument, out)
1167
+ break
1168
+ }
1169
+ }
1170
+
1171
+ /**
1172
+ * Prop-derived names bound by `node` FOR ITS OWN SUBTREE (block-accurate
1173
+ * lexical scoping). Excludes the prop-derived const's own defining
1174
+ * declaration (matched by init span) so the binding we inline FROM is
1175
+ * never mistaken for a shadow of itself.
1176
+ */
1177
+ function scopeBoundPropDerived(node: N): string[] {
1178
+ const out: string[] = []
1179
+ const t = node.type
1180
+ const declNames = (declNode: N): void => {
1181
+ for (const d of declNode.declarations ?? []) {
1182
+ // The prop-derived defining declaration is NOT a shadow.
1183
+ if (d.id?.type === 'Identifier' && propDerivedVars.has(d.id.name)) {
1184
+ const span = propDerivedVars.get(d.id.name)!
1185
+ if (d.init && (d.init.start as number) === span.start) continue
1186
+ }
1187
+ patternBindingNames(d.id, out)
1188
+ }
1189
+ }
1190
+ if (
1191
+ t === 'ArrowFunctionExpression' ||
1192
+ t === 'FunctionExpression' ||
1193
+ t === 'FunctionDeclaration'
1194
+ ) {
1195
+ for (const p of node.params ?? []) patternBindingNames(p, out)
1196
+ } else if (t === 'CatchClause') {
1197
+ patternBindingNames(node.param, out)
1198
+ } else if (t === 'ForStatement') {
1199
+ if (node.init?.type === 'VariableDeclaration') declNames(node.init)
1200
+ } else if (t === 'ForInStatement' || t === 'ForOfStatement') {
1201
+ if (node.left?.type === 'VariableDeclaration') declNames(node.left)
1202
+ } else if (t === 'BlockStatement' || t === 'Program' || t === 'StaticBlock') {
1203
+ const stmts = node.body ?? node.statements
1204
+ if (Array.isArray(stmts)) {
1205
+ for (const s of stmts) {
1206
+ if (s.type === 'VariableDeclaration') declNames(s)
1207
+ else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
1208
+ else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
1209
+ }
1210
+ }
1211
+ }
1212
+ return out.filter((n) => propDerivedVars.has(n))
1213
+ }
1214
+
710
1215
  // Walk the AST to find identifiers in the span, passing parent context
711
1216
  // to skip non-reference positions (property names, declarations, etc.)
1217
+ // and a lexical shadow set so a same-named inner binding is never inlined.
712
1218
  function findIdents(node: N, parent: N | null): void {
713
1219
  const nodeStart = node.start as number
714
1220
  const nodeEnd = node.end as number
715
1221
  if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
716
- if (node.type === 'Identifier' && propDerivedVars.has(node.name)) {
1222
+ if (
1223
+ node.type === 'Identifier' &&
1224
+ propDerivedVars.has(node.name) &&
1225
+ !shadowed.has(node.name)
1226
+ ) {
717
1227
  if (parent) {
718
1228
  if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
719
1229
  else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
@@ -726,7 +1236,12 @@ export function transformJSX_JS(
726
1236
  idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
727
1237
  }
728
1238
  }
1239
+ // Names this node binds for its subtree shadow the top-level prop-derived
1240
+ // const within that subtree (and the binding occurrence itself).
1241
+ const introduced = scopeBoundPropDerived(node).filter((n) => !shadowed.has(n))
1242
+ for (const n of introduced) shadowed.add(n)
729
1243
  forEachChildFast(node, (child) => findIdents(child, node))
1244
+ for (const n of introduced) shadowed.delete(n)
730
1245
  }
731
1246
  findIdents(program, null)
732
1247
 
@@ -857,6 +1372,11 @@ export function transformJSX_JS(
857
1372
 
858
1373
  // ── JSX processing (was pass 3) ──
859
1374
  if (node.type === 'JSXElement') {
1375
+ if (tryRocketstyleCollapse(node)) {
1376
+ // Collapsed to _rsCollapse — children are baked into the SSR-
1377
+ // resolved template; do not recurse into the subtree.
1378
+ return
1379
+ }
860
1380
  if (!isSelfClosing(node) && tryTemplateEmit(node)) {
861
1381
  // Template emitted — don't recurse into this subtree (JSXElement is never a function)
862
1382
  return
@@ -898,19 +1418,30 @@ export function transformJSX_JS(
898
1418
  }
899
1419
 
900
1420
  replacements.sort((a, b) => a.start - b.start)
901
- const outParts: string[] = []
902
- let outPos = 0
1421
+ // R12 fix: apply the disjoint, sorted {start,end,text} edits through
1422
+ // MagicString instead of manual slice/join. `toString()` is byte-identical
1423
+ // to the old concatenation (the full 1200-test suite + native-equivalence
1424
+ // assert exact emitted strings), but `generateMap()` now yields a correct
1425
+ // V3 source map — the previous transform emitted none AND shifted line
1426
+ // counts (template emission expands one-line JSX into a multi-line _tpl
1427
+ // factory), so every stack frame / breakpoint in a Pyreon component
1428
+ // mislocated app-wide.
1429
+ const s = new MagicString(code)
903
1430
  for (const r of replacements) {
904
- outParts.push(code.slice(outPos, r.start))
905
- outParts.push(r.text)
906
- outPos = r.end
1431
+ if (r.start === r.end) s.appendLeft(r.start, r.text)
1432
+ else s.update(r.start, r.end, r.text)
907
1433
  }
908
- outParts.push(code.slice(outPos))
909
- let output = outParts.join('')
1434
+
1435
+ // Build the generated preamble (hoists + auto-imports + collapse prologue)
1436
+ // in the SAME final top-to-bottom order the previous chained `X + output`
1437
+ // produced, then `prepend` it ONCE. magic-string's prepend shifts every
1438
+ // source mapping down by the preamble's line count, so original positions
1439
+ // resolve to the correct OUTPUT lines despite the inserted preamble — the
1440
+ // exact line-shift R12 measured. Innermost (closest to code) first.
1441
+ let preamble = ''
910
1442
 
911
1443
  if (hoists.length > 0) {
912
- const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
913
- output = preamble + output
1444
+ preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('') + preamble
914
1445
  }
915
1446
 
916
1447
  if (needsTplImport) {
@@ -922,21 +1453,53 @@ export function transformJSX_JS(
922
1453
  const reactivityImports = needsBindImportGlobal
923
1454
  ? `\nimport { _bind } from "@pyreon/reactivity";`
924
1455
  : ''
925
- output =
1456
+ preamble =
926
1457
  `import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
927
- output
1458
+ preamble
928
1459
  }
929
1460
 
930
1461
  if (needsRpImport || needsWrapSpreadImport) {
931
1462
  const coreImports: string[] = []
932
1463
  if (needsRpImport) coreImports.push('_rp')
933
1464
  if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
934
- output = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + output
1465
+ preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
1466
+ }
1467
+
1468
+ if (needsCollapse) {
1469
+ const cfg = options.collapseRocketstyle!
1470
+ const rd = cfg.runtimeDomSource ?? '@pyreon/runtime-dom'
1471
+ const st = cfg.stylerSource ?? '@pyreon/styler'
1472
+ // One idempotent injectRules per distinct rule bundle — keyed by the
1473
+ // resolver's FNV so a re-eval (HMR) or another module's identical
1474
+ // bundle is a no-op (styler dedupes by key). Runs at module-eval,
1475
+ // before any collapsed site mounts, so the sheet is populated
1476
+ // without a prior runtime mount of the real component.
1477
+ const inj = collapseRules
1478
+ .map(
1479
+ (r) =>
1480
+ `__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`,
1481
+ )
1482
+ .join('')
1483
+ preamble =
1484
+ `import { _rsCollapse as __rsCollapse${needsCollapseH ? ', _rsCollapseH as __rsCollapseH' : ''} } from "${rd}";\n` +
1485
+ `import { sheet as __rsSheet } from "${st}";\n` +
1486
+ `import { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n` +
1487
+ `${inj}\n` +
1488
+ preamble
935
1489
  }
936
1490
 
1491
+ if (preamble) s.prepend(preamble)
1492
+
1493
+ const output = s.toString()
1494
+ const map = s.generateMap({
1495
+ source: filename,
1496
+ includeContent: true,
1497
+ hires: true,
1498
+ }) as unknown as GeneratedSourceMap
1499
+
937
1500
  return collectLens
938
- ? { code: output, usesTemplates: needsTplImport, warnings, reactivityLens }
939
- : { code: output, usesTemplates: needsTplImport, warnings }
1501
+ ? { code: output, usesTemplates: needsTplImport, warnings, map, reactivityLens }
1502
+ : { code: output, usesTemplates: needsTplImport, warnings, map }
940
1503
 
941
1504
  // ── Template emission helpers ─────────────────────────────────────────────
942
1505
 
@@ -1314,7 +1877,14 @@ export function transformJSX_JS(
1314
1877
  }
1315
1878
  const needsPlaceholder = useMixed || useMultiExpr
1316
1879
  const { expr, isReactive } = unwrapAccessor(child.expression)
1317
- if (isChildrenExpression(child.expression, expr)) {
1880
+ // Round 9 fix: a bare `{el}` where `el` is an element-valued binding
1881
+ // (`const el = <X/>`) must be MOUNTED via _mountSlot, not text-coerced
1882
+ // via createTextNode (which stringifies the NativeItem). Same emission
1883
+ // as the children-slot path; _mountSlot handles every child type.
1884
+ const isElementValuedIdent =
1885
+ (child.expression?.type === 'Identifier' && elementVars.has(child.expression.name)) ||
1886
+ (!isReactive && /^[A-Za-z_$][\w$]*$/.test(expr) && elementVars.has(expr))
1887
+ if (isChildrenExpression(child.expression, expr) || isElementValuedIdent) {
1318
1888
  needsMountSlotImport = true
1319
1889
  const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
1320
1890
  const d = nextDisp()
@@ -1473,14 +2043,95 @@ export function transformJSX_JS(
1473
2043
 
1474
2044
  /** Auto-insert () after signal variable references in the expression source.
1475
2045
  * Uses the AST to find exact Identifier positions — never scans raw text. */
2046
+ // Recursively collect identifier names bound by a pattern (params /
2047
+ // declarators). Self-contained twin of resolveIdentifiersInText's
2048
+ // `patternBindingNames` (different closure scope; kept local to avoid a
2049
+ // risky shared-helper hoist).
2050
+ function sigPatternNames(p: N, out: string[]): void {
2051
+ if (!p) return
2052
+ switch (p.type) {
2053
+ case 'Identifier':
2054
+ out.push(p.name)
2055
+ break
2056
+ case 'ObjectPattern':
2057
+ for (const pr of p.properties ?? []) {
2058
+ if (pr.type === 'RestElement') sigPatternNames(pr.argument, out)
2059
+ else sigPatternNames(pr.value ?? pr.key, out)
2060
+ }
2061
+ break
2062
+ case 'ArrayPattern':
2063
+ for (const el of p.elements ?? []) sigPatternNames(el, out)
2064
+ break
2065
+ case 'AssignmentPattern':
2066
+ sigPatternNames(p.left, out)
2067
+ break
2068
+ case 'RestElement':
2069
+ sigPatternNames(p.argument, out)
2070
+ break
2071
+ }
2072
+ }
2073
+
2074
+ // Signal names a scope-introducing node binds FOR ITS OWN SUBTREE
2075
+ // (block-accurate lexical scoping). Mirrors scopeBoundPropDerived but
2076
+ // against `signalVars` — a same-named inner binding (callback param,
2077
+ // nested const, catch/loop var) shadows the signal and must NOT be
2078
+ // auto-called (doing so emits `paramValue()` → runtime TypeError).
2079
+ function scopeBoundSignals(node: N): string[] {
2080
+ const out: string[] = []
2081
+ const t = node.type
2082
+ const declNames = (declNode: N): void => {
2083
+ for (const d of declNode.declarations ?? []) {
2084
+ // A `const x = signal(...)` re-declaration is itself a signal, not a
2085
+ // shadow — leave it for the normal signalVars path.
2086
+ if (d.id?.type === 'Identifier' && d.init && isSignalCall(d.init)) continue
2087
+ sigPatternNames(d.id, out)
2088
+ }
2089
+ }
2090
+ if (
2091
+ t === 'ArrowFunctionExpression' ||
2092
+ t === 'FunctionExpression' ||
2093
+ t === 'FunctionDeclaration'
2094
+ ) {
2095
+ for (const p of node.params ?? []) sigPatternNames(p, out)
2096
+ } else if (t === 'CatchClause') {
2097
+ sigPatternNames(node.param, out)
2098
+ } else if (t === 'ForStatement') {
2099
+ if (node.init?.type === 'VariableDeclaration') declNames(node.init)
2100
+ } else if (t === 'ForInStatement' || t === 'ForOfStatement') {
2101
+ if (node.left?.type === 'VariableDeclaration') declNames(node.left)
2102
+ } else if (t === 'BlockStatement' || t === 'StaticBlock') {
2103
+ const stmts = node.body ?? node.statements
2104
+ if (Array.isArray(stmts)) {
2105
+ for (const s of stmts) {
2106
+ if (s.type === 'VariableDeclaration') declNames(s)
2107
+ else if (s.type === 'FunctionDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
2108
+ else if (s.type === 'ClassDeclaration' && s.id?.type === 'Identifier') out.push(s.id.name)
2109
+ }
2110
+ }
2111
+ }
2112
+ return out.filter((n) => signalVars.has(n))
2113
+ }
2114
+
1476
2115
  function autoCallSignals(text: string, expr: N): string {
1477
2116
  const start = expr.start as number
1478
2117
  // Collect signal identifier positions that need auto-calling
1479
2118
  const idents: { start: number; end: number }[] = []
2119
+ // Local lexical shadow set — a signal-named binding introduced INSIDE
2120
+ // the rewritten expression (callback param, nested const, …) is NOT the
2121
+ // signal and must not get `()` (R11: scope-blind rewrite emitted
2122
+ // `({x}) => <li>{x()}</li>` → `1()` runtime crash).
2123
+ const shadowed = new Set<string>()
1480
2124
 
1481
2125
  function findSignalIdents(node: N): void {
1482
2126
  if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
1483
- if (node.type === 'Identifier' && isActiveSignal(node.name)) {
2127
+ const introduced: string[] = []
2128
+ for (const n of scopeBoundSignals(node)) {
2129
+ if (!shadowed.has(n)) {
2130
+ shadowed.add(n)
2131
+ introduced.push(n)
2132
+ }
2133
+ }
2134
+ if (node.type === 'Identifier' && isActiveSignal(node.name) && !shadowed.has(node.name)) {
1484
2135
  const parent = findParent(node)
1485
2136
  // Skip property name positions (obj.name)
1486
2137
  if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
@@ -1518,6 +2169,7 @@ export function transformJSX_JS(
1518
2169
  idents.push({ start: node.start as number, end: node.end as number })
1519
2170
  }
1520
2171
  forEachChildFast(node, findSignalIdents)
2172
+ for (const n of introduced) shadowed.delete(n)
1521
2173
  }
1522
2174
  findSignalIdents(expr)
1523
2175