@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/README.md +138 -54
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +414 -9
- package/lib/types/index.d.ts +94 -1
- package/package.json +12 -12
- package/src/index.ts +2 -0
- package/src/jsx.ts +425 -5
- package/src/lpih.ts +270 -0
- package/src/pyreon-intercept.ts +19 -8
- package/src/ssg-audit.ts +3 -3
- package/src/tests/collapse-bail-census.test.ts +101 -16
- package/src/tests/component-child-no-wrap.test.ts +204 -0
- package/src/tests/dynamic-collapse-detector.test.ts +164 -0
- package/src/tests/dynamic-collapse-emit.test.ts +192 -0
- package/src/tests/dynamic-collapse-scan.test.ts +111 -0
- package/src/tests/lpih.test.ts +404 -0
- package/src/tests/native-equivalence.test.ts +92 -0
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
|
-
|
|
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 {
|
|
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` +
|