@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
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
@@ -39,7 +57,13 @@ import { loadNativeBinding } from './load-native'
39
57
  // environment, WASM runtime like StackBlitz, missing per-platform package).
40
58
  //
41
59
  // See `load-native.ts` for the resolution logic.
42
- type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
60
+ type NativeTransformFn = (
61
+ code: string,
62
+ filename: string,
63
+ ssr: boolean,
64
+ knownSignals: string[] | null,
65
+ reactivityLens: boolean,
66
+ ) => TransformResult
43
67
  const nativeBinding = loadNativeBinding(import.meta.url)
44
68
  const nativeTransformJsx: NativeTransformFn | null = nativeBinding
45
69
  ? (nativeBinding.transformJsx as NativeTransformFn)
@@ -60,6 +84,41 @@ export interface CompilerWarning {
60
84
  | 'circular-prop-derived'
61
85
  }
62
86
 
87
+ /**
88
+ * Reactivity-lens kinds. Each is a RECORD of a codegen decision the compiler
89
+ * already made — never an approximation. Positive claims (`reactive*`) are
90
+ * emitted ONLY where the compiler provably wrapped/tracked the span; absence
91
+ * of a span is "not asserted", never an implicit static claim. `static-text`
92
+ * is the high-precision negative: the literal `else` branch of the
93
+ * reactive-vs-static text decision (the "this `{x}` is baked once / dead"
94
+ * footgun signal when the author expected reactivity).
95
+ */
96
+ export type ReactivityKind =
97
+ | 'reactive' // expression re-evaluates on signal change (_bind/_bindText/`() =>` wrap)
98
+ | 'reactive-prop' // component prop tracked into the child (_rp(() => …))
99
+ | 'reactive-attr' // DOM attribute re-applied on signal change
100
+ | 'static-text' // text expression baked once into the DOM, never re-renders
101
+ | 'hoisted-static' // JSX hoisted to module scope, never re-evaluated
102
+
103
+ export interface ReactivitySpan {
104
+ /** Source byte offset (start) of the spanned expression in the INPUT. */
105
+ start: number
106
+ /** Source byte offset (end). */
107
+ end: number
108
+ /** 1-based start line. */
109
+ line: number
110
+ /** 0-based start column. */
111
+ column: number
112
+ /** 1-based end line. */
113
+ endLine: number
114
+ /** 0-based end column. */
115
+ endColumn: number
116
+ /** Which codegen decision this span records. */
117
+ kind: ReactivityKind
118
+ /** Human-readable, editor-facing one-liner explaining the decision. */
119
+ detail: string
120
+ }
121
+
63
122
  export interface TransformResult {
64
123
  /** Transformed source code (JSX preserved, only expression containers modified) */
65
124
  code: string
@@ -67,6 +126,22 @@ export interface TransformResult {
67
126
  usesTemplates?: boolean
68
127
  /** Compiler warnings for common mistakes */
69
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
138
+ /**
139
+ * Reactivity-lens spans — populated ONLY when `TransformOptions.reactivityLens`
140
+ * is `true`. Additive: codegen output is byte-identical whether or not this is
141
+ * collected. Each span is a faithful record of a reactivity decision the
142
+ * compiler made for that source range. See {@link ReactivitySpan}.
143
+ */
144
+ reactivityLens?: ReactivitySpan[]
70
145
  }
71
146
 
72
147
  // Props that should never be wrapped in a reactive getter
@@ -103,6 +178,73 @@ export interface TransformOptions {
103
178
  * // {count} in JSX → {() => count()}
104
179
  */
105
180
  knownSignals?: string[]
181
+
182
+ /**
183
+ * Collect the {@link ReactivitySpan} sidecar (`TransformResult.reactivityLens`).
184
+ * Default `false`. Purely additive — the emitted `code` is byte-identical
185
+ * whether this is on or off (asserted by the compiler equivalence tests).
186
+ * The lens records reactivity decisions the compiler ALREADY makes for
187
+ * codegen; it never runs a second analysis pass.
188
+ */
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)
106
248
  }
107
249
 
108
250
  // ─── oxc ESTree helpers ───────────────────────────────────────────────────────
@@ -176,6 +318,215 @@ function jsxChildren(node: N): N[] {
176
318
  return node.children ?? []
177
319
  }
178
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
+
179
530
  // ─── Main transform ─────────────────────────────────────────────────────────
180
531
 
181
532
  export function transformJSX(
@@ -183,13 +534,25 @@ export function transformJSX(
183
534
  filename = 'input.tsx',
184
535
  options: TransformOptions = {},
185
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
+
186
543
  // Try Rust native binary first (3.7-8.2x faster).
187
544
  // Per-call try/catch: if the native binary panics on an edge case
188
545
  // (bad UTF-8, unexpected AST shape), fall back gracefully instead
189
546
  // of crashing the Vite dev server.
190
547
  if (nativeTransformJsx) {
191
548
  try {
192
- return nativeTransformJsx(code, filename, options.ssr === true, options.knownSignals ?? null)
549
+ return nativeTransformJsx(
550
+ code,
551
+ filename,
552
+ options.ssr === true,
553
+ options.knownSignals ?? null,
554
+ options.reactivityLens === true,
555
+ )
193
556
  } catch {
194
557
  // Native transform failed — fall through to JS implementation
195
558
  }
@@ -227,6 +590,25 @@ export function transformJSX_JS(
227
590
  warnings.push({ message, line, column, code: warnCode })
228
591
  }
229
592
 
593
+ // ── Reactivity lens (opt-in, additive — never affects `result`) ───────────
594
+ const collectLens = options.reactivityLens === true
595
+ const reactivityLens: ReactivitySpan[] = []
596
+ function lens(start: number, end: number, kind: ReactivityKind, detail: string): void {
597
+ if (!collectLens) return
598
+ const a = locate(start)
599
+ const b = locate(end)
600
+ reactivityLens.push({
601
+ start,
602
+ end,
603
+ line: a.line,
604
+ column: a.column,
605
+ endLine: b.line,
606
+ endColumn: b.column,
607
+ kind,
608
+ detail,
609
+ })
610
+ }
611
+
230
612
  // ── Parent + children maps (built once, eliminates repeated Object.keys) ──
231
613
  const parentMap = new WeakMap<object, N>()
232
614
  const childrenMap = new WeakMap<object, N[]>()
@@ -280,6 +662,109 @@ export function transformJSX_JS(
280
662
  let needsApplyPropsImportGlobal = false
281
663
  let needsMountSlotImportGlobal = false
282
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
+
283
768
  function maybeHoist(node: N): string | null {
284
769
  if (
285
770
  (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
@@ -288,6 +773,12 @@ export function transformJSX_JS(
288
773
  const name = `_$h${hoistIdx++}`
289
774
  const text = code.slice(node.start as number, node.end as number)
290
775
  hoists.push({ name, text })
776
+ lens(
777
+ node.start as number,
778
+ node.end as number,
779
+ 'hoisted-static',
780
+ 'static — hoisted once to module scope, never re-evaluated',
781
+ )
291
782
  return name
292
783
  }
293
784
  return null
@@ -301,6 +792,7 @@ export function transformJSX_JS(
301
792
  ? `() => (${sliced})`
302
793
  : `() => ${sliced}`
303
794
  replacements.push({ start, end, text })
795
+ lens(start, end, 'reactive', 'live — re-evaluates whenever its signals change')
304
796
  }
305
797
 
306
798
  function hoistOrWrap(expr: N): void {
@@ -411,6 +903,7 @@ export function transformJSX_JS(
411
903
  const inner = expr.type === 'ObjectExpression' ? `(${sliced})` : sliced
412
904
  replacements.push({ start, end, text: `_rp(() => ${inner})` })
413
905
  needsRpImport = true
906
+ lens(start, end, 'reactive-prop', 'live prop — signal reads here are tracked into the component')
414
907
  }
415
908
  } else {
416
909
  hoistOrWrap(expr)
@@ -435,6 +928,15 @@ export function transformJSX_JS(
435
928
  // ── Prop-derived variable tracking (collected during the single walk) ─────
436
929
  const propsNames = new Set<string>()
437
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>()
438
940
 
439
941
  // ── Signal variable tracking (for auto-call in JSX) ──────────────────────
440
942
  // Tracks `const x = signal(...)` declarations. In JSX expressions, bare
@@ -541,6 +1043,18 @@ export function transformJSX_JS(
541
1043
  }
542
1044
  }
543
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
+ }
544
1058
  if (node.kind !== 'const') continue
545
1059
  if (callbackDepth > 0) continue
546
1060
  if (decl.id?.type === 'Identifier' && decl.init) {
@@ -617,13 +1131,99 @@ export function transformJSX_JS(
617
1131
  const endOffset = baseOffset + text.length
618
1132
  const idents: { start: number; end: number; name: string }[] = []
619
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
+
620
1215
  // Walk the AST to find identifiers in the span, passing parent context
621
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.
622
1218
  function findIdents(node: N, parent: N | null): void {
623
1219
  const nodeStart = node.start as number
624
1220
  const nodeEnd = node.end as number
625
1221
  if (nodeStart >= endOffset || nodeEnd <= baseOffset) return
626
- 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
+ ) {
627
1227
  if (parent) {
628
1228
  if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) { /* skip */ }
629
1229
  else if (parent.type === 'VariableDeclarator' && parent.id === node) { /* skip */ }
@@ -636,7 +1236,12 @@ export function transformJSX_JS(
636
1236
  idents.push({ start: nodeStart, end: nodeEnd, name: node.name })
637
1237
  }
638
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)
639
1243
  forEachChildFast(node, (child) => findIdents(child, node))
1244
+ for (const n of introduced) shadowed.delete(n)
640
1245
  }
641
1246
  findIdents(program, null)
642
1247
 
@@ -767,6 +1372,11 @@ export function transformJSX_JS(
767
1372
 
768
1373
  // ── JSX processing (was pass 3) ──
769
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
+ }
770
1380
  if (!isSelfClosing(node) && tryTemplateEmit(node)) {
771
1381
  // Template emitted — don't recurse into this subtree (JSXElement is never a function)
772
1382
  return
@@ -803,22 +1413,35 @@ export function transformJSX_JS(
803
1413
 
804
1414
  walkNode(program)
805
1415
 
806
- if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
1416
+ if (replacements.length === 0 && hoists.length === 0) {
1417
+ return collectLens ? { code, warnings, reactivityLens } : { code, warnings }
1418
+ }
807
1419
 
808
1420
  replacements.sort((a, b) => a.start - b.start)
809
- const outParts: string[] = []
810
- 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)
811
1430
  for (const r of replacements) {
812
- outParts.push(code.slice(outPos, r.start))
813
- outParts.push(r.text)
814
- 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)
815
1433
  }
816
- outParts.push(code.slice(outPos))
817
- 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 = ''
818
1442
 
819
1443
  if (hoists.length > 0) {
820
- const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
821
- output = preamble + output
1444
+ preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('') + preamble
822
1445
  }
823
1446
 
824
1447
  if (needsTplImport) {
@@ -830,19 +1453,53 @@ export function transformJSX_JS(
830
1453
  const reactivityImports = needsBindImportGlobal
831
1454
  ? `\nimport { _bind } from "@pyreon/reactivity";`
832
1455
  : ''
833
- output =
1456
+ preamble =
834
1457
  `import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
835
- output
1458
+ preamble
836
1459
  }
837
1460
 
838
1461
  if (needsRpImport || needsWrapSpreadImport) {
839
1462
  const coreImports: string[] = []
840
1463
  if (needsRpImport) coreImports.push('_rp')
841
1464
  if (needsWrapSpreadImport) coreImports.push('_wrapSpread')
842
- output = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + output
1465
+ preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
843
1466
  }
844
1467
 
845
- return { code: output, usesTemplates: needsTplImport, warnings }
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
1489
+ }
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
+
1500
+ return collectLens
1501
+ ? { code: output, usesTemplates: needsTplImport, warnings, map, reactivityLens }
1502
+ : { code: output, usesTemplates: needsTplImport, warnings, map }
846
1503
 
847
1504
  // ── Template emission helpers ─────────────────────────────────────────────
848
1505
 
@@ -1017,6 +1674,12 @@ export function transformJSX_JS(
1017
1674
  bindLines.push(attrSetter(htmlAttrName, varName, expr))
1018
1675
  return
1019
1676
  }
1677
+ lens(
1678
+ exprNode.start as number,
1679
+ exprNode.end as number,
1680
+ 'reactive-attr',
1681
+ `live attribute — \`${htmlAttrName}\` re-applies whenever its signals change`,
1682
+ )
1020
1683
  const directRef = tryDirectSignalRef(exprNode)
1021
1684
  if (directRef) {
1022
1685
  needsBindDirectImport = true
@@ -1214,16 +1877,36 @@ export function transformJSX_JS(
1214
1877
  }
1215
1878
  const needsPlaceholder = useMixed || useMultiExpr
1216
1879
  const { expr, isReactive } = unwrapAccessor(child.expression)
1217
- 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) {
1218
1888
  needsMountSlotImport = true
1219
1889
  const placeholder = `${parentRef}.childNodes[${childNodeIdx}]`
1220
1890
  const d = nextDisp()
1221
1891
  bindLines.push(`const ${d} = _mountSlot(${expr}, ${parentRef}, ${placeholder})`)
1222
1892
  return '<!>'
1223
1893
  }
1894
+ const cx = child.expression
1224
1895
  if (isReactive) {
1896
+ lens(
1897
+ cx.start as number,
1898
+ cx.end as number,
1899
+ 'reactive',
1900
+ 'live — this text re-renders whenever its signals change',
1901
+ )
1225
1902
  return emitReactiveTextChild(expr, child.expression, varName, parentRef, childNodeIdx, needsPlaceholder)
1226
1903
  }
1904
+ lens(
1905
+ cx.start as number,
1906
+ cx.end as number,
1907
+ 'static-text',
1908
+ 'baked once into the DOM — never re-renders (no signal read here)',
1909
+ )
1227
1910
  return emitStaticTextChild(expr, varName, parentRef, childNodeIdx, needsPlaceholder)
1228
1911
  }
1229
1912
 
@@ -1360,14 +2043,95 @@ export function transformJSX_JS(
1360
2043
 
1361
2044
  /** Auto-insert () after signal variable references in the expression source.
1362
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
+
1363
2115
  function autoCallSignals(text: string, expr: N): string {
1364
2116
  const start = expr.start as number
1365
2117
  // Collect signal identifier positions that need auto-calling
1366
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>()
1367
2124
 
1368
2125
  function findSignalIdents(node: N): void {
1369
2126
  if ((node.start as number) >= start + text.length || (node.end as number) <= start) return
1370
- 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)) {
1371
2135
  const parent = findParent(node)
1372
2136
  // Skip property name positions (obj.name)
1373
2137
  if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
@@ -1405,6 +2169,7 @@ export function transformJSX_JS(
1405
2169
  idents.push({ start: node.start as number, end: node.end as number })
1406
2170
  }
1407
2171
  forEachChildFast(node, findSignalIdents)
2172
+ for (const n of introduced) shadowed.delete(n)
1408
2173
  }
1409
2174
  findSignalIdents(expr)
1410
2175