@pyreon/compiler 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ export type {
16
16
  ReactivityFindingKind,
17
17
  } from './reactivity-lens'
18
18
  export { analyzeReactivity, formatReactivityLens } from './reactivity-lens'
19
+ export type { LPIHFireDatum, LPIHMergeOptions } from './lpih'
20
+ export { firesToCreationSiteFindings, mergeFireDataIntoFindings } from './lpih'
19
21
  export type { ComponentInfo, IslandInfo, ProjectContext, RouteInfo } from './project-scanner'
20
22
  export { generateContext } from './project-scanner'
21
23
  export type {
package/src/jsx.ts CHANGED
@@ -404,6 +404,35 @@ export function scanCollapsibleSites(
404
404
  childrenText: site.childrenText,
405
405
  key: rocketstyleCollapseKey(tag, site.props, site.childrenText),
406
406
  })
407
+ } else {
408
+ // Dynamic-prop fallthrough: if the full detector bailed but
409
+ // the site matches the ternary-of-two-literals shape, expand
410
+ // into TWO CollapsibleSite entries — one per literal value.
411
+ // Each expanded site is byte-identical to a static-collapse
412
+ // site for that value, so the resolver pre-renders both via
413
+ // the existing SSR pipeline and the compiler emit looks up
414
+ // both by their respective keys to build the dispatcher.
415
+ //
416
+ // No-handler sites route to `__rsCollapseDyn`; handler-bearing
417
+ // sites route to `__rsCollapseDynH` (handlers are orthogonal
418
+ // to the SSR-resolved styler class — see `tryDynamicCollapse`
419
+ // in this file). The scan does NOT distinguish here because
420
+ // the resolver only cares about (componentName, props, text);
421
+ // handlers don't affect the resolution.
422
+ const dyn = detectDynamicCollapsibleShape(node, tag)
423
+ if (dyn) {
424
+ for (const value of [dyn.dynamicProp.valueTruthy, dyn.dynamicProp.valueFalsy]) {
425
+ const expandedProps = { ...dyn.props, [dyn.dynamicProp.name]: value }
426
+ out.push({
427
+ componentName: tag,
428
+ source: imp.source,
429
+ importedName: imp.imported,
430
+ props: expandedProps,
431
+ childrenText: dyn.childrenText,
432
+ key: rocketstyleCollapseKey(tag, expandedProps, dyn.childrenText),
433
+ })
434
+ }
435
+ }
407
436
  }
408
437
  }
409
438
  }
@@ -527,6 +556,158 @@ export function detectPartialCollapsibleShape(
527
556
  return { props, childrenText: childrenText.trim(), handlers }
528
557
  }
529
558
 
559
+ /**
560
+ * A dynamic dimension prop on a collapsible call site. A ConditionalExpression
561
+ * (ternary) where both branches are string literals — `state={cond ? 'a' : 'b'}`
562
+ * is the canonical shape. Pre-resolution: the prop's value belongs to the
563
+ * enumerable set `[valueA, valueB]`. The compiler emits one collapsed
564
+ * variant per literal value + a dispatcher on the original `cond`.
565
+ */
566
+ export interface DynamicCollapsibleProp {
567
+ /** JSX attribute name, e.g. `state`. */
568
+ name: string
569
+ /** Source span of the ternary condition (the `cond` part), re-emitted into the runtime dispatcher. */
570
+ condStart: number
571
+ condEnd: number
572
+ /** Literal value for the `cond === truthy` branch (consequent). */
573
+ valueTruthy: string
574
+ /** Literal value for the `cond === falsy` branch (alternate). */
575
+ valueFalsy: string
576
+ }
577
+
578
+ /**
579
+ * Dynamic-prop partial-collapse detector — PR 2 of the dynamic-prop
580
+ * partial-collapse build (`.claude/plans/open-work-2026-q3.md` → #1
581
+ * dynamic-prop bucket = 15.3% of all real-corpus sites; the next-bigger
582
+ * bite after the `on*`-handler partial-collapse).
583
+ *
584
+ * Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
585
+ * with ONE relaxation" pattern (see that detector's docstring + `PR 1`
586
+ * `_rsCollapseDyn` runtime helper, PR #765). The single relaxation: a
587
+ * `JSXExpressionContainer` wrapping a `ConditionalExpression` whose
588
+ * `consequent` AND `alternate` are BOTH `StringLiteral` is acceptable as
589
+ * a "ternary-of-two-literals" dynamic prop — captured as a {@link DynamicCollapsibleProp}
590
+ * with the cond source span + the two literal values.
591
+ *
592
+ * Constraint: **AT MOST ONE** such dynamic prop per site. Multiple
593
+ * ternaries would compound into a 2^N value-set per site at build time
594
+ * and an N-axis dispatcher at runtime — that's a separable scope
595
+ * (potential PR 5+), NOT this PR. Sites with 2+ ternaries bail (return
596
+ * null), keeping the normal mount; same conservative shape as the rest
597
+ * of the detector family.
598
+ *
599
+ * Constraint: the FULL `on*`-handler relaxation is also folded in — a
600
+ * site can have ONE ternary AND `on*` handlers in the same call. This
601
+ * matches the real-corpus shape (a Button with `state={cond ? 'a' : 'b'}`
602
+ * almost always also has an `onClick`). The two relaxations compose
603
+ * cleanly because they're orthogonal at the resolver layer (handlers
604
+ * don't change rendered CSS; the ternary picks among pre-resolved
605
+ * classes). PR 3's emit will use `_rsCollapseDyn` when handlers are
606
+ * absent and a future combined helper when both are present — for THIS
607
+ * PR (detector-only) the structure carries both so PR 3 can dispatch.
608
+ *
609
+ * Every OTHER non-literal shape still bails (spread, non-handler
610
+ * non-ternary `{expr}` prop, multi-literal ternary anywhere, computed-
611
+ * expression ternary, element/expression child, boolean attr) —
612
+ * conservative by construction, exactly like the rest of the family.
613
+ * Returns `null` when there are ZERO ternaries so the on*-only path
614
+ * (`detectPartialCollapsibleShape`) and the full-collapse path
615
+ * (`detectCollapsibleShape`) stay byte-unchanged and no detector both
616
+ * claims the same site.
617
+ *
618
+ * A consistency test (PR 3) will lock this catalogue against the
619
+ * plugin scan, mirroring the `detectCollapsibleShape` ↔ `scanCollapsibleSites`
620
+ * + `detectPartialCollapsibleShape` ↔ scan invariants — keys cannot drift.
621
+ */
622
+ export function detectDynamicCollapsibleShape(
623
+ node: N,
624
+ _tag: string,
625
+ ): {
626
+ props: Record<string, string>
627
+ childrenText: string
628
+ handlers: CollapsibleHandler[]
629
+ dynamicProp: DynamicCollapsibleProp
630
+ } | null {
631
+ const props: Record<string, string> = {}
632
+ const handlers: CollapsibleHandler[] = []
633
+ const dynamicProps: DynamicCollapsibleProp[] = []
634
+ for (const attr of jsxAttrs(node)) {
635
+ if (attr.type !== 'JSXAttribute') return null // spread → bail
636
+ const nm = attr.name?.type === 'JSXIdentifier' ? attr.name.name : null
637
+ if (!nm) return null
638
+ const v = attr.value
639
+ if (!v) return null // boolean attr → bail
640
+ const isStr =
641
+ v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
642
+ if (isStr) {
643
+ props[nm] = String(v.value)
644
+ continue
645
+ }
646
+ // Non-literal in a `{expr}` container — three possible relaxations:
647
+ // (a) `on[A-Z]…` handler with any expression → peeled
648
+ // (b) any other prop whose expression is a ternary of two string
649
+ // literals → peeled as a DynamicCollapsibleProp
650
+ // (c) anything else → bail
651
+ if (
652
+ v.type === 'JSXExpressionContainer' &&
653
+ v.expression &&
654
+ typeof v.expression.start === 'number' &&
655
+ typeof v.expression.end === 'number'
656
+ ) {
657
+ if (/^on[A-Z]/.test(nm)) {
658
+ handlers.push({ name: nm, exprStart: v.expression.start, exprEnd: v.expression.end })
659
+ continue
660
+ }
661
+ const expr = v.expression
662
+ if (
663
+ expr.type === 'ConditionalExpression' &&
664
+ expr.test &&
665
+ typeof expr.test.start === 'number' &&
666
+ typeof expr.test.end === 'number' &&
667
+ expr.consequent &&
668
+ expr.alternate
669
+ ) {
670
+ // Both branches must be StringLiteral. We deliberately do NOT
671
+ // accept TemplateLiteral / `as`-casted literals / any other
672
+ // shape — keep the static-resolvable set narrow + provable.
673
+ const isLitStr = (n: unknown): n is { type: 'StringLiteral'; value: string } => {
674
+ const x = n as { type?: string; value?: unknown }
675
+ return (
676
+ x?.type === 'StringLiteral' ||
677
+ (x?.type === 'Literal' && typeof x.value === 'string')
678
+ )
679
+ }
680
+ if (isLitStr(expr.consequent) && isLitStr(expr.alternate)) {
681
+ dynamicProps.push({
682
+ name: nm,
683
+ condStart: expr.test.start,
684
+ condEnd: expr.test.end,
685
+ valueTruthy: String((expr.consequent as { value: string }).value),
686
+ valueFalsy: String((expr.alternate as { value: string }).value),
687
+ })
688
+ continue
689
+ }
690
+ }
691
+ }
692
+ return null // `{expr}` non-handler non-ternary, or non-literal ternary branch → bail
693
+ }
694
+ let childrenText = ''
695
+ for (const c of jsxChildren(node)) {
696
+ if (c.type === 'JSXText') childrenText += (c.value ?? '') as string
697
+ else return null // element / expression child → bail
698
+ }
699
+ // Exactly ONE dynamic prop is the scope of this PR. Zero ⇒ defer to
700
+ // the existing detectors (full / on*-handler partial); 2+ ⇒ bail
701
+ // (multi-axis combinatorics is a separable scope).
702
+ if (dynamicProps.length !== 1) return null
703
+ return {
704
+ props,
705
+ childrenText: childrenText.trim(),
706
+ handlers,
707
+ dynamicProp: dynamicProps[0]!,
708
+ }
709
+ }
710
+
530
711
  // ─── Main transform ─────────────────────────────────────────────────────────
531
712
 
532
713
  export function transformJSX(
@@ -665,6 +846,8 @@ export function transformJSX_JS(
665
846
  // ── P0 rocketstyle-collapse state ─────────────────────────────────────────
666
847
  let needsCollapse = false
667
848
  let needsCollapseH = false
849
+ let needsCollapseDyn = false
850
+ let needsCollapseDynH = false
668
851
  const collapseRuleKeys = new Set<string>()
669
852
  const collapseRules: Array<{ ruleKey: string; rules: string[] }> = []
670
853
 
@@ -691,7 +874,11 @@ export function transformJSX_JS(
691
874
  // plugin scans with the same predicate, so its resolved `sites`
692
875
  // keys match these lookups exactly; no drift possible).
693
876
  const shape = detectCollapsibleShape(node, tag)
694
- if (!shape) return tryPartialCollapse(node, tag) // PR 3: on*-handler-only fallback
877
+ // Fallthrough chain same conservative discipline at each layer:
878
+ // 1. on*-handler-only partial (literal dim props + handlers)
879
+ // 2. dynamic-prop partial (ternary-of-two-literals on ≤1 dim prop,
880
+ // no handlers — handler-combined dynamic is a future PR's scope)
881
+ if (!shape) return tryPartialCollapse(node, tag) || tryDynamicCollapse(node, tag)
695
882
  const { props, childrenText } = shape
696
883
  const key = rocketstyleCollapseKey(tag, props, childrenText)
697
884
  const site = cfg.sites.get(key)
@@ -765,6 +952,127 @@ export function transformJSX_JS(
765
952
  return true
766
953
  }
767
954
 
955
+ /**
956
+ * PR 3 of the dynamic-prop partial-collapse build (open-work #1
957
+ * dynamic-prop bucket = 15.3% of all real-corpus sites; the
958
+ * next-bigger bite after the just-shipped `on*`-handler partial).
959
+ * The dynamic-prop fallback `tryRocketstyleCollapse` defers to when
960
+ * BOTH the full and the on*-handler-partial paths bail.
961
+ *
962
+ * Same site-resolution contract as the full path — the dynamic prop
963
+ * is replaced with EACH literal value to compute TWO keys; the
964
+ * resolver pre-renders both via the existing SSR pipeline; if both
965
+ * lookups succeed AND the structural template is byte-identical
966
+ * across values, emit `__rsCollapseDyn(html, [classes...], () =>
967
+ * cond ? 0 : 1, () => __pyrMode() === "dark")` — the PR 1 runtime
968
+ * helper (#765) dispatches across `(value × mode)` with a stride-2
969
+ * value-major class layout.
970
+ *
971
+ * Conservative discipline:
972
+ * - Either expanded key missing from sites map ⇒ bail (an
973
+ * intermittent resolver failure on one value mustn't half-collapse)
974
+ * - Divergent template HTML across values ⇒ bail (the dispatcher
975
+ * assumes a shared template; deriveCollapseDyn cannot be done
976
+ * across values that produce structurally different markup —
977
+ * this is the cross-value parallel of `deriveCollapse`'s
978
+ * light↔dark template-divergence bail)
979
+ *
980
+ * Handler-combined sites: when the detected dynamic site has `on*`
981
+ * handlers (the most common real-corpus shape — bail-census measured
982
+ * the no-handler subset at 0.2% of all sites; handler-combined is
983
+ * the bulk of the 15.4% dynamic-prop bucket), emit
984
+ * `__rsCollapseDynH(...)` (PR A: runtime helper) instead of
985
+ * `__rsCollapseDyn(...)`. Handlers are orthogonal to the SSR-
986
+ * resolved styler class (the resolver pre-renders both values
987
+ * identically regardless of handlers); the union helper just
988
+ * re-attaches them through the same canonical `_bindEvent` path
989
+ * `tryPartialCollapse` uses.
990
+ *
991
+ * Rule injection unions the rule sets across both values (each value
992
+ * may inject distinct CSS rules — e.g. `state="primary"` and
993
+ * `state="secondary"` produce different background-color rules); the
994
+ * union is the byte-set the dispatcher will need at runtime regardless
995
+ * of which value the cond resolves to. Idempotent by per-value
996
+ * `ruleKey` so a re-resolve / HMR is a no-op.
997
+ */
998
+ function tryDynamicCollapse(node: N, tag: string): boolean {
999
+ const cfg = options.collapseRocketstyle
1000
+ if (!cfg) return false
1001
+ const dyn = detectDynamicCollapsibleShape(node, tag)
1002
+ if (!dyn) return false
1003
+ const { props, childrenText, dynamicProp, handlers } = dyn
1004
+ // Look up BOTH expanded sites (one per literal value). The scan's
1005
+ // dynamic-prop fallthrough (above in this file) emits a CollapsibleSite
1006
+ // for each value with identical key construction, so these lookups
1007
+ // must succeed iff both resolved.
1008
+ const truthyProps = { ...props, [dynamicProp.name]: dynamicProp.valueTruthy }
1009
+ const falsyProps = { ...props, [dynamicProp.name]: dynamicProp.valueFalsy }
1010
+ const truthyKey = rocketstyleCollapseKey(tag, truthyProps, childrenText)
1011
+ const falsyKey = rocketstyleCollapseKey(tag, falsyProps, childrenText)
1012
+ const truthySite = cfg.sites.get(truthyKey)
1013
+ const falsySite = cfg.sites.get(falsyKey)
1014
+ if (!truthySite || !falsySite) return false // half-resolved ⇒ keep normal mount
1015
+ // Cross-value template parity — the dispatcher reuses ONE `_tpl`
1016
+ // across both values; divergent markup means we'd silently pick
1017
+ // the truthy variant's HTML for falsy too. Bail conservatively.
1018
+ if (truthySite.templateHtml !== falsySite.templateHtml) return false
1019
+
1020
+ // Build the stride-2 value-major class array (consumed by
1021
+ // `_rsCollapseDyn`): `[v0_light, v0_dark, v1_light, v1_dark]` where
1022
+ // v0 = truthy (cond → 0), v1 = falsy (cond → 1).
1023
+ const classes = [
1024
+ truthySite.lightClass,
1025
+ truthySite.darkClass,
1026
+ falsySite.lightClass,
1027
+ falsySite.darkClass,
1028
+ ]
1029
+ const condSrc = code.slice(dynamicProp.condStart, dynamicProp.condEnd)
1030
+
1031
+ // Handler-combined sites route to `__rsCollapseDynH(...)` (PR A
1032
+ // runtime helper) — handlers re-attached after the class dispatcher
1033
+ // via the canonical `_bindEvent` path, byte-identical to how
1034
+ // `tryPartialCollapse` re-emits handlers via `__rsCollapseH`.
1035
+ // No-handler sites stay on `__rsCollapseDyn(...)` (lighter — no
1036
+ // handlers parameter, no loop allocation).
1037
+ let call: string
1038
+ if (handlers.length > 0) {
1039
+ const handlerObj =
1040
+ `{ ${handlers
1041
+ .map((h) => `${JSON.stringify(h.name)}: (${code.slice(h.exprStart, h.exprEnd)})`)
1042
+ .join(', ')} }`
1043
+ call =
1044
+ `__rsCollapseDynH(${JSON.stringify(truthySite.templateHtml)}, ` +
1045
+ `${JSON.stringify(classes)}, ` +
1046
+ `() => (${condSrc}) ? 0 : 1, ` +
1047
+ `() => __pyrMode() === "dark", ` +
1048
+ `${handlerObj})`
1049
+ needsCollapseDynH = true
1050
+ } else {
1051
+ call =
1052
+ `__rsCollapseDyn(${JSON.stringify(truthySite.templateHtml)}, ` +
1053
+ `${JSON.stringify(classes)}, ` +
1054
+ `() => (${condSrc}) ? 0 : 1, ` +
1055
+ `() => __pyrMode() === "dark")`
1056
+ needsCollapseDyn = true
1057
+ }
1058
+ const start = node.start as number
1059
+ const end = node.end as number
1060
+ const parent = findParent(node)
1061
+ const needsBraces =
1062
+ parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment')
1063
+ replacements.push({ start, end, text: needsBraces ? `{${call}}` : call })
1064
+ // Union BOTH value's rule bundles into the per-module injection.
1065
+ // De-dupe by ruleKey (the FNV-1a hash from the resolver) so two
1066
+ // dynamic sites sharing a value pay one injection.
1067
+ for (const site of [truthySite, falsySite]) {
1068
+ if (!collapseRuleKeys.has(site.ruleKey)) {
1069
+ collapseRuleKeys.add(site.ruleKey)
1070
+ collapseRules.push({ ruleKey: site.ruleKey, rules: site.rules })
1071
+ }
1072
+ }
1073
+ return true
1074
+ }
1075
+
768
1076
  function maybeHoist(node: N): string | null {
769
1077
  if (
770
1078
  (node.type === 'JSXElement' || node.type === 'JSXFragment') &&
@@ -910,7 +1218,7 @@ export function transformJSX_JS(
910
1218
  }
911
1219
  }
912
1220
 
913
- function handleJsxExpression(node: N): void {
1221
+ function handleJsxExpression(node: N, parentJsx?: N): void {
914
1222
  const expr = node.expression
915
1223
  if (!expr || expr.type === 'JSXEmptyExpression') return
916
1224
  const hoistName = maybeHoist(expr)
@@ -919,12 +1227,115 @@ export function transformJSX_JS(
919
1227
  return
920
1228
  }
921
1229
  if (shouldWrap(expr)) {
1230
+ // Skip the accessor wrap for stable references passed as JSX children
1231
+ // of a COMPONENT parent (uppercase tag). The compiler's prop-inlining
1232
+ // pass replaces `{children}` with `() => h.children` for component
1233
+ // parents too (the kinetic Stagger + bokisch.com Intro reproducer);
1234
+ // most consumer libraries (rocketstyle/styler/ui-core/elements) route
1235
+ // children through `mountChild` which handles function children via
1236
+ // `mountReactive`, but libraries that iterate children at the VNode
1237
+ // level (kinetic's StaggerRenderer/TransitionItem) or `cloneVNode`
1238
+ // them directly are silently broken — the function spread produces
1239
+ // `{type: undefined}` and the DOM renders `<undefined>` tags.
1240
+ //
1241
+ // Narrow contract — only stable references are emitted bare:
1242
+ // - Bare Identifier (`{children}` referencing a prop-derived const)
1243
+ // - Simple MemberExpression chain (`{obj.x}`, `{obj.x.y}`)
1244
+ // These shapes evaluate the same way whether called once at JSX-
1245
+ // emit time or repeatedly in a `mountReactive` effect — no
1246
+ // reactivity is lost because the underlying value is just a
1247
+ // property read. Other dynamic shapes (CallExpression, BinaryExpression,
1248
+ // LogicalExpression, etc.) keep the wrap so `<Comp>{count()}</Comp>`
1249
+ // and similar patterns stay reactive end-to-end.
1250
+ //
1251
+ // Without this carve-out, library authors are forced to write
1252
+ // defensive `typeof children === 'function' ? children() : children`
1253
+ // unwraps everywhere they consume `props.children` structurally.
1254
+ if (
1255
+ parentJsx &&
1256
+ isComponentTag(jsxTagName(parentJsx)) &&
1257
+ isStableReference(expr) &&
1258
+ !referencesSignalVar(expr)
1259
+ ) {
1260
+ // Skip the carve-out for signal references — `<Comp>{count}</Comp>`
1261
+ // (bare signal identifier) is the user's deliberate "make this
1262
+ // reactive at the call site" pattern. Auto-call + wrap converts to
1263
+ // `() => count()` so the receiving component re-evaluates inside
1264
+ // its mountReactive/mountChild scope. Prop-derived stable refs
1265
+ // (the kinetic / bokisch fix shape) take the bare path.
1266
+ //
1267
+ // Slice the UNWRAPPED expression — TS type-only layers (`as T`,
1268
+ // `satisfies T`, `!`) are stripped because the receiving component
1269
+ // doesn't care about the static type and esbuild strips casts at
1270
+ // the next stage anyway. Also keeps cross-backend equivalence
1271
+ // with the Rust path (whose `accesses_props` doesn't recurse into
1272
+ // TSAsExpression).
1273
+ const start = expr.start as number
1274
+ const end = expr.end as number
1275
+ const unwrapped = unwrapTypeLayers(expr)
1276
+ const sliced = sliceExpr(unwrapped)
1277
+ replacements.push({ start, end, text: sliced })
1278
+ return
1279
+ }
922
1280
  wrap(expr)
923
1281
  return
924
1282
  }
925
1283
  walkNode(expr)
926
1284
  }
927
1285
 
1286
+ /** Component tag — uppercase first letter. Lowercase = DOM element. */
1287
+ function isComponentTag(tag: string): boolean {
1288
+ return tag.length > 0 && tag.charAt(0) !== tag.charAt(0).toLowerCase()
1289
+ }
1290
+
1291
+ /**
1292
+ * Stable reference — an expression whose value is a bare property read.
1293
+ * Bare Identifier (`children`) or a non-computed MemberExpression chain
1294
+ * (`obj.x.y`) terminating in an Identifier or `this`. These are the
1295
+ * shapes that survive the no-wrap path without losing reactivity:
1296
+ * reading them once captures the same value as reading them N times,
1297
+ * because the underlying getter (if any) is the source of truth either
1298
+ * way. Excludes CallExpression / TaggedTemplateExpression / BinaryExpression
1299
+ * / LogicalExpression / ConditionalExpression / etc. — those keep the
1300
+ * wrap so consumers can re-evaluate inside reactive scopes.
1301
+ *
1302
+ * TS type-only layers (`as T` / `satisfies T` / non-null `!`) and
1303
+ * parentheses are transparent — they don't change runtime semantics
1304
+ * so we unwrap to look at the underlying expression. Reproducer:
1305
+ * `<Comp>{children as VNode[]}</Comp>` in `createKineticComponent.tsx`
1306
+ * — the TS cast wraps the Identifier as a `TSAsExpression`; without
1307
+ * unwrap the carve-out misses the very pattern it was written for.
1308
+ */
1309
+ function isStableReference(expr: N): boolean {
1310
+ const u = unwrapTypeLayers(expr)
1311
+ if (u.type === 'Identifier') return true
1312
+ if (u.type === 'MemberExpression') {
1313
+ let cur: N = u
1314
+ while (cur.type === 'MemberExpression') {
1315
+ if (cur.computed) return false
1316
+ if (cur.property?.type !== 'Identifier') return false
1317
+ cur = cur.object
1318
+ }
1319
+ return cur.type === 'Identifier' || cur.type === 'ThisExpression'
1320
+ }
1321
+ return false
1322
+ }
1323
+
1324
+ /** Strip TS type-only layers + parens that don't affect runtime value. */
1325
+ function unwrapTypeLayers(expr: N): N {
1326
+ let cur: N = expr
1327
+ while (
1328
+ cur.type === 'TSAsExpression' ||
1329
+ cur.type === 'TSSatisfiesExpression' ||
1330
+ cur.type === 'TSNonNullExpression' ||
1331
+ cur.type === 'TSTypeAssertion' ||
1332
+ cur.type === 'ParenthesizedExpression'
1333
+ ) {
1334
+ cur = cur.expression
1335
+ }
1336
+ return cur
1337
+ }
1338
+
928
1339
  // ── Prop-derived variable tracking (collected during the single walk) ─────
929
1340
  const propsNames = new Set<string>()
930
1341
  const propDerivedVars = new Map<string, { start: number; end: number }>()
@@ -1387,7 +1798,7 @@ export function transformJSX_JS(
1387
1798
  else if (attr.type === 'JSXSpreadAttribute') handleJsxSpreadAttribute(attr, node)
1388
1799
  }
1389
1800
  for (const child of jsxChildren(node)) {
1390
- if (child.type === 'JSXExpressionContainer') handleJsxExpression(child)
1801
+ if (child.type === 'JSXExpressionContainer') handleJsxExpression(child, node)
1391
1802
  else walkNode(child)
1392
1803
  }
1393
1804
  // Note: JSXElement is never a function, so no callback depth or scope cleanup needed here
@@ -1465,7 +1876,7 @@ export function transformJSX_JS(
1465
1876
  preamble = `import { ${coreImports.join(', ')} } from "@pyreon/core";\n` + preamble
1466
1877
  }
1467
1878
 
1468
- if (needsCollapse) {
1879
+ if (needsCollapse || needsCollapseDyn || needsCollapseDynH) {
1469
1880
  const cfg = options.collapseRocketstyle!
1470
1881
  const rd = cfg.runtimeDomSource ?? '@pyreon/runtime-dom'
1471
1882
  const st = cfg.stylerSource ?? '@pyreon/styler'
@@ -1480,8 +1891,17 @@ export function transformJSX_JS(
1480
1891
  `__rsSheet.injectRules(${JSON.stringify(r.rules)},${JSON.stringify(r.ruleKey)});`,
1481
1892
  )
1482
1893
  .join('')
1894
+ // Only import the helpers actually emitted into this module — keeps
1895
+ // the bundle bytes per-feature and tree-shakable. needsCollapse
1896
+ // (full) gates `_rsCollapse`; the partial / dynamic flags gate
1897
+ // their respective helpers independently.
1898
+ const rdImports: string[] = []
1899
+ if (needsCollapse) rdImports.push('_rsCollapse as __rsCollapse')
1900
+ if (needsCollapseH) rdImports.push('_rsCollapseH as __rsCollapseH')
1901
+ if (needsCollapseDyn) rdImports.push('_rsCollapseDyn as __rsCollapseDyn')
1902
+ if (needsCollapseDynH) rdImports.push('_rsCollapseDynH as __rsCollapseDynH')
1483
1903
  preamble =
1484
- `import { _rsCollapse as __rsCollapse${needsCollapseH ? ', _rsCollapseH as __rsCollapseH' : ''} } from "${rd}";\n` +
1904
+ `import { ${rdImports.join(', ')} } from "${rd}";\n` +
1485
1905
  `import { sheet as __rsSheet } from "${st}";\n` +
1486
1906
  `import { ${cfg.mode.name} as __pyrMode } from "${cfg.mode.source}";\n` +
1487
1907
  `${inj}\n` +