@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.
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Compiler hardening — JSX child of COMPONENT parent is NOT wrapped in an
3
+ * accessor when the expression is a stable reference (Identifier or simple
4
+ * MemberExpression chain).
5
+ *
6
+ * Reported root cause behind the kinetic Stagger + bokisch.com Intro repro
7
+ * (PR #731 shipped the library-side workaround; this is the upstream fix).
8
+ *
9
+ * Pre-fix the compiler rewrote `<Comp>{children}</Comp>` (where `children`
10
+ * is a local `const` derived from a getter — `const children = childHolder.children`
11
+ * after `splitProps`) as `Comp({ ..., children: () => h.children })`. Receiving
12
+ * components saw `props.children` as a FUNCTION instead of the expected
13
+ * `VNode | VNode[]`. DOM-consuming code routes through `mountChild` which
14
+ * handles function children correctly (via `mountReactive`), so the wrap is
15
+ * invisible there. Libraries that iterate children at the VNode level
16
+ * (kinetic's StaggerRenderer/TransitionItem) or `cloneVNode` them directly
17
+ * were silently broken — the function spread produced `{type: undefined}`
18
+ * and the DOM rendered literal `<undefined>` tags.
19
+ *
20
+ * Fix shape: for JSX children of COMPONENT parents (uppercase tag), skip
21
+ * the accessor wrap when the expression is a stable reference. The
22
+ * compiler's prop-inlining pass still runs (so `children` is replaced with
23
+ * `h.children` at the JSX use site) but the resulting expression is
24
+ * emitted bare. Dynamic shapes (CallExpression, BinaryExpression, etc.)
25
+ * keep the wrap so `<Comp>{count()}</Comp>` and similar patterns stay
26
+ * reactive end-to-end.
27
+ *
28
+ * Note: `transformJSX_JS` returns Pyreon-transformed SOURCE — JSX stays as
29
+ * JSX (the final JSX→jsx() lowering is esbuild's job). So the inlined
30
+ * expression shows up between `{...}` in the emitted text.
31
+ *
32
+ * Bisect: revert the `isComponentTag(...) && isStableReference(expr)`
33
+ * carve-out in `handleJsxExpression` (jsx.ts) → the CONTRACT specs fail
34
+ * (emit reverts to `{() => h.children}`); the wrap-still-fires CONTROL
35
+ * specs stay green (proving the carve-out doesn't touch the call/binary
36
+ * paths).
37
+ */
38
+ import { describe, expect, test } from 'vitest'
39
+ import { transformJSX_JS } from '../jsx'
40
+
41
+ const t = (src: string): string => transformJSX_JS(src, 'test.tsx').code
42
+
43
+ describe('JSX transform — component child of stable reference', () => {
44
+ test('CONTRACT — bare Identifier (splitProps-derived const) is emitted without accessor wrap', () => {
45
+ // The bokisch.com Intro shape, distilled. `splitProps` registers
46
+ // `childHolder` as a prop-derived binding; `const children = childHolder.children`
47
+ // makes `children` prop-derived; the JSX child `{children}` would,
48
+ // pre-fix, emit `{() => childHolder.children}`. Now emits the inlined
49
+ // value bare.
50
+ const src = `
51
+ const Comp = (props) => {
52
+ const [childHolder, restHtml] = splitProps(props, ['children'])
53
+ const children = childHolder.children
54
+ return <Inner>{children}</Inner>
55
+ }
56
+ `
57
+ const out = t(src)
58
+ expect(out, 'children must NOT be wrapped in an accessor').not.toContain('() =>')
59
+ expect(out, 'inlined value must appear bare in JSX child position').toMatch(
60
+ /<Inner>\s*\{\(?childHolder\.children\)?\}\s*<\/Inner>/,
61
+ )
62
+ })
63
+
64
+ test('CONTRACT — simple MemberExpression chain is emitted without accessor wrap', () => {
65
+ const src = `
66
+ const Comp = (props) => {
67
+ const [obj] = splitProps(props, ['deep'])
68
+ return <Inner>{obj.deep.x}</Inner>
69
+ }
70
+ `
71
+ const out = t(src)
72
+ expect(out, 'member chain must NOT be wrapped in an accessor').not.toContain(
73
+ '() => obj.deep.x',
74
+ )
75
+ expect(out, 'member chain must appear bare').toMatch(
76
+ /<Inner>\s*\{obj\.deep\.x\}\s*<\/Inner>/,
77
+ )
78
+ })
79
+
80
+ test('CONTROL — CallExpression child KEEPS the wrap (preserves reactivity)', () => {
81
+ // `<Comp>{count()}</Comp>` — the user explicitly reads a signal in the
82
+ // child position. The wrap converts to `() => count()` so the
83
+ // receiving component can subscribe via mountChild → mountReactive.
84
+ const src = `
85
+ const count = signal(0)
86
+ const Comp = () => <Inner>{count()}</Inner>
87
+ `
88
+ const out = t(src)
89
+ expect(out, 'call-expression child must keep the wrap').toContain('() => count()')
90
+ })
91
+
92
+ test('CONTROL — BinaryExpression child KEEPS the wrap', () => {
93
+ const src = `
94
+ const Comp = (props) => {
95
+ const [own] = splitProps(props, ['a', 'b'])
96
+ return <Inner>{own.a + own.b}</Inner>
97
+ }
98
+ `
99
+ const out = t(src)
100
+ expect(out, 'binary-expression child must keep the wrap').toMatch(/\(\)\s*=>/)
101
+ expect(out).toContain('own.a')
102
+ expect(out).toContain('own.b')
103
+ })
104
+
105
+ test('CONTROL — DOM-element parent with bare Identifier KEEPS the binding (reactive)', () => {
106
+ // The carve-out only fires for COMPONENT parents (uppercase tag).
107
+ // DOM-element children must still go through the reactive binding
108
+ // path so mountChild/mountReactive can re-evaluate inside an effect.
109
+ const src = `
110
+ const Comp = (props) => {
111
+ const [own] = splitProps(props, ['children'])
112
+ const children = own.children
113
+ return <div>{children}</div>
114
+ }
115
+ `
116
+ const out = t(src)
117
+ // The template path emits this as a reactive binding (_bindText /
118
+ // _bindDirect / etc.), not a bare text-node. Either way, the
119
+ // expression must still route through a reactive primitive.
120
+ expect(out, 'DOM-element child must route through a reactive path').not.toContain(
121
+ '<div>{own.children}</div>',
122
+ )
123
+ })
124
+
125
+ test('CONTRACT — TS-cast wrapper (`as VNode[]`) is transparent', () => {
126
+ // The EXACT shape `createKineticComponent.tsx` ships:
127
+ // `<StaggerRenderer>{children as VNode[]}</StaggerRenderer>`
128
+ // The TS `as` cast wraps `children` as a `TSAsExpression`. Without
129
+ // unwrapping, the carve-out misses the bokisch reproducer entirely.
130
+ // The cast is preserved in the emit — esbuild's later TS-strip pass
131
+ // removes it. Reproducer: pre-fix this test fails with
132
+ // `expected to NOT contain '() =>'` because the wrap still fires.
133
+ const src = `
134
+ const Kinetic = (props) => {
135
+ const [childHolder] = splitProps(props, ['children'])
136
+ const children = childHolder.children
137
+ return <Inner>{children as VNode[]}</Inner>
138
+ }
139
+ `
140
+ const out = t(src)
141
+ expect(out, 'TS-cast wrapper must not block the carve-out').not.toContain('() =>')
142
+ // Slice unwraps the TS cast — output is just the inlined value.
143
+ expect(out).toMatch(/<Inner>\s*\{\(?childHolder\.children\)?\}\s*<\/Inner>/)
144
+ })
145
+
146
+ test('CONTRACT — non-null `!` postfix is transparent', () => {
147
+ const src = `
148
+ const Comp = (props) => {
149
+ const [own] = splitProps(props, ['children'])
150
+ return <Inner>{own.children!}</Inner>
151
+ }
152
+ `
153
+ const out = t(src)
154
+ expect(out).not.toContain('() =>')
155
+ expect(out).toMatch(/<Inner>\s*\{own\.children\}\s*<\/Inner>/)
156
+ })
157
+
158
+ test('CONTRACT — kinetic Stagger reproducer compiles to bare children prop', () => {
159
+ // Exact shape from `packages/ui-system/kinetic/src/kinetic/createKineticComponent.tsx`.
160
+ // Pre-fix emit (JSX child position): `{() => childHolder.children}`.
161
+ // Post-fix emit: `{childHolder.children}` (no wrap).
162
+ const src = `
163
+ const Kinetic = (props) => {
164
+ const [childHolder, restHtml] = splitProps(props, ['children'])
165
+ const children = childHolder.children
166
+ return <StaggerRenderer htmlProps={restHtml}>{children}</StaggerRenderer>
167
+ }
168
+ `
169
+ const out = t(src)
170
+ expect(out).not.toContain('() => childHolder.children')
171
+ expect(out).not.toContain('() => children')
172
+ expect(out).toMatch(
173
+ /<StaggerRenderer[^>]*>\s*\{\(?childHolder\.children\)?\}\s*<\/StaggerRenderer>/,
174
+ )
175
+ })
176
+
177
+ test('CONTROL — bare SIGNAL identifier KEEPS the wrap (auto-call + wrap is reactive)', () => {
178
+ // `<Comp>{count}</Comp>` where count is a tracked signal — the user's
179
+ // deliberate "make this reactive at the call site" pattern. The
180
+ // compiler auto-calls (`count` → `count()`) AND wraps (`() => count()`)
181
+ // so the receiving component re-evaluates in its mountReactive scope.
182
+ // The stable-reference carve-out explicitly excludes signal references.
183
+ const src = `
184
+ function C() {
185
+ const count = signal(0)
186
+ return <MyComp>{count}</MyComp>
187
+ }
188
+ `
189
+ const out = t(src)
190
+ expect(out, 'signal child must be wrapped + auto-called').toContain('() => count()')
191
+ })
192
+
193
+ test('CONTROL — already-arrow-wrapped child is unchanged (idempotent)', () => {
194
+ // Users who explicitly want reactivity write `<Comp>{() => x()}</Comp>`.
195
+ // The compiler's shouldWrap returns false for ArrowFunctionExpression,
196
+ // so the carve-out never fires. The user's accessor passes through.
197
+ const src = `
198
+ const x = signal('a')
199
+ const Comp = () => <Inner>{() => x()}</Inner>
200
+ `
201
+ const out = t(src)
202
+ expect(out, 'user-written accessor must pass through').toContain('() => x()')
203
+ })
204
+ })
@@ -0,0 +1,164 @@
1
+ /**
2
+ * PR 2 of the dynamic-prop partial-collapse build (`.claude/plans/open-work-2026-q3.md`
3
+ * → #1 dynamic-prop bucket = 15.3% of all real-corpus sites; the
4
+ * next-bigger bite after the just-shipped `on*`-handler partial-collapse
5
+ * via `detectPartialCollapsibleShape` + `_rsCollapseH` + emit).
6
+ *
7
+ * Mirrors `detectPartialCollapsibleShape`'s "extend the bail catalogue
8
+ * with ONE relaxation" pattern (see that detector's docstring + tests).
9
+ * The single relaxation: a `JSXExpressionContainer` whose expression is
10
+ * a `ConditionalExpression` with BOTH branches being `StringLiteral` is
11
+ * acceptable as a "ternary-of-two-literals" dynamic prop. Captured as a
12
+ * `DynamicCollapsibleProp` with cond source span + the two literal
13
+ * values, so PR 3's resolver can pre-render BOTH values via the
14
+ * existing SSR pipeline and PR 3's emit can dispatch via the cond.
15
+ *
16
+ * Contract under test:
17
+ *
18
+ * - literal-prop + ONE ternary-of-two-literals + optional `on*` handlers
19
+ * + static-text children → { props, dynamicProp, handlers, childrenText }
20
+ * - ZERO ternaries (literal-only) → null (defers to full / on*-only paths)
21
+ * - 2+ ternaries → null (multi-axis combinatorics is separable scope)
22
+ * - ternary with ANY non-literal branch (template literal, identifier,
23
+ * non-string literal, computed expr) → null
24
+ * - spread / boolean attr / element child / expression child → null
25
+ * - cond span (`condStart`/`condEnd`) slices the EXACT source of the
26
+ * ternary's test expression (load-bearing for PR 3's emit, which
27
+ * re-emits `code.slice(condStart, condEnd)` into `_rsCollapseDyn`)
28
+ *
29
+ * Bisect-verify (documented in the PR body): replace the body of
30
+ * `detectDynamicCollapsibleShape` with `return null` → the POSITIVE
31
+ * specs fail with `expected null to be …`; the NEGATIVE specs still
32
+ * pass. Restore → all pass. That asymmetry proves the positive
33
+ * assertions are load-bearing on the ternary-relaxation logic.
34
+ */
35
+ import { describe, expect, it } from 'vitest'
36
+ import { parseSync } from 'oxc-parser'
37
+ import { detectDynamicCollapsibleShape } from '../jsx'
38
+
39
+ function firstJsxElement(code: string): any {
40
+ const { program } = parseSync('input.tsx', code, { sourceType: 'module', lang: 'tsx' })
41
+ let found: any = null
42
+ const visit = (node: any): void => {
43
+ if (found || !node || typeof node !== 'object') return
44
+ if (node.type === 'JSXElement') {
45
+ found = node
46
+ return
47
+ }
48
+ for (const k in node) {
49
+ const v = node[k]
50
+ if (Array.isArray(v)) for (const c of v) visit(c)
51
+ else if (v && typeof v === 'object' && typeof v.type === 'string') visit(v)
52
+ }
53
+ }
54
+ visit(program)
55
+ return found
56
+ }
57
+
58
+ const detect = (code: string) => detectDynamicCollapsibleShape(firstJsxElement(code), 'Button')
59
+
60
+ describe('detectDynamicCollapsibleShape — PR 2 (ternary-of-two-literals dynamic-prop subset)', () => {
61
+ // ── POSITIVE: the dynamic-collapsible subset ────────────────────────────
62
+ it('claims a literal-prop site with ONE ternary-of-two-literals', () => {
63
+ const code = 'const x = <Button state={cond ? "primary" : "secondary"} size="medium">Save</Button>'
64
+ const r = detect(code)
65
+ expect(r).not.toBeNull()
66
+ expect(r!.props).toEqual({ size: 'medium' })
67
+ expect(r!.childrenText).toBe('Save')
68
+ expect(r!.handlers).toEqual([])
69
+ expect(r!.dynamicProp.name).toBe('state')
70
+ expect(r!.dynamicProp.valueTruthy).toBe('primary')
71
+ expect(r!.dynamicProp.valueFalsy).toBe('secondary')
72
+ expect(code.slice(r!.dynamicProp.condStart, r!.dynamicProp.condEnd)).toBe('cond')
73
+ })
74
+
75
+ it('captures the EXACT cond span for a complex condition', () => {
76
+ // The condStart/condEnd MUST slice the original source of the test
77
+ // expression so PR 3's emit can re-thread it into the dispatcher
78
+ // verbatim (paren-wrapped to keep it a single expr like the
79
+ // on*-handler emit does for arrow bodies).
80
+ const code = 'const x = <Btn state={user.role === "admin" ? "primary" : "danger"}>Go</Btn>'
81
+ const r = detect(code)
82
+ expect(r).not.toBeNull()
83
+ expect(code.slice(r!.dynamicProp.condStart, r!.dynamicProp.condEnd)).toBe(
84
+ 'user.role === "admin"',
85
+ )
86
+ })
87
+
88
+ it('composes with on*-handler relaxation — one ternary + one handler', () => {
89
+ // Real-corpus shape: a Button with state={cond ? ... : ...} almost
90
+ // always also has an onClick. PR 3's emit will route to a combined
91
+ // helper when handlers are non-empty; this PR (detector) just
92
+ // carries both for the dispatcher's sake.
93
+ const code =
94
+ 'const x = <Button state={cond ? "primary" : "secondary"} onClick={go}>Save</Button>'
95
+ const r = detect(code)
96
+ expect(r).not.toBeNull()
97
+ expect(r!.dynamicProp.name).toBe('state')
98
+ expect(r!.handlers.map((h) => h.name)).toEqual(['onClick'])
99
+ })
100
+
101
+ it('handles ternary on a non-state dim prop (size, variant, …)', () => {
102
+ const code = 'const x = <Button size={isLarge ? "large" : "medium"}>S</Button>'
103
+ const r = detect(code)
104
+ expect(r).not.toBeNull()
105
+ expect(r!.dynamicProp.name).toBe('size')
106
+ expect(r!.dynamicProp.valueTruthy).toBe('large')
107
+ expect(r!.dynamicProp.valueFalsy).toBe('medium')
108
+ })
109
+
110
+ it('trims static-text children (parity with the rest of the family)', () => {
111
+ const code =
112
+ 'const x = <Button state={c ? "a" : "b"}>\n Save\n</Button>'
113
+ const r = detect(code)
114
+ expect(r).not.toBeNull()
115
+ expect(r!.childrenText).toBe('Save')
116
+ })
117
+
118
+ // ── NEGATIVE: every uncertain shape bails (null) ────────────────────────
119
+ it('returns null for ZERO ternaries (defers to full / on*-only paths)', () => {
120
+ // Load-bearing separation: a fully-literal site is the FULL-collapse
121
+ // shape; on*-handler-only is the partial-collapse shape. The
122
+ // dynamic-prop detector must NOT claim either, so the three
123
+ // detectors never both/all-three fire on one site.
124
+ expect(detect('const x = <Button state="primary">Save</Button>')).toBeNull()
125
+ expect(detect('const x = <Button state="primary" onClick={h}>Save</Button>')).toBeNull()
126
+ })
127
+
128
+ it('returns null for 2+ ternaries (multi-axis combinatorics is separable scope)', () => {
129
+ const code =
130
+ 'const x = <Button state={a ? "x" : "y"} size={b ? "small" : "large"}>S</Button>'
131
+ expect(detect(code)).toBeNull()
132
+ })
133
+
134
+ it('returns null for a ternary with a NON-string-literal branch', () => {
135
+ // TemplateLiteral, Identifier, numeric/boolean literal — none of
136
+ // these are statically resolvable to a known dimension value.
137
+ expect(detect('const x = <Button state={c ? `pri` : "sec"}>S</Button>')).toBeNull()
138
+ expect(detect('const x = <Button state={c ? maybePrimary : "sec"}>S</Button>')).toBeNull()
139
+ expect(detect('const x = <Button state={c ? "pri" : 1}>S</Button>')).toBeNull()
140
+ })
141
+
142
+ it('returns null for a non-ternary dynamic prop alongside literals', () => {
143
+ // Signal-call / function-call / arbitrary expression — not statically
144
+ // enumerable, no resolution possible.
145
+ expect(detect('const x = <Button state={getMy()} size="medium">S</Button>')).toBeNull()
146
+ expect(detect('const x = <Button state={sig()} size="medium">S</Button>')).toBeNull()
147
+ })
148
+
149
+ it('returns null for a spread attribute', () => {
150
+ expect(detect('const x = <Button {...rest} state={c ? "a" : "b"}>X</Button>')).toBeNull()
151
+ })
152
+
153
+ it('returns null for a boolean attribute', () => {
154
+ expect(detect('const x = <Button disabled state={c ? "a" : "b"}>X</Button>')).toBeNull()
155
+ })
156
+
157
+ it('returns null for an element child', () => {
158
+ expect(detect('const x = <Button state={c ? "a" : "b"}><span /></Button>')).toBeNull()
159
+ })
160
+
161
+ it('returns null for an expression child', () => {
162
+ expect(detect('const x = <Button state={c ? "a" : "b"}>{label}</Button>')).toBeNull()
163
+ })
164
+ })
@@ -0,0 +1,192 @@
1
+ /**
2
+ * PR 3 of the dynamic-prop partial-collapse build — the compiler EMIT
3
+ * half: `tryRocketstyleCollapse` falls through to `tryDynamicCollapse`
4
+ * when BOTH the full and the `on*`-handler-partial paths bail. Emits
5
+ * `__rsCollapseDyn(html, [stride-2 classes], () => cond ? 0 : 1, () =>
6
+ * __pyrMode() === "dark")` consumed by PR 1's runtime helper `_rsCollapseDyn`
7
+ * (#765).
8
+ *
9
+ * Mirrors the existing `partial-collapse-emit.test.ts` harness exactly
10
+ * (stubbed resolved-`sites` map — the resolver/plugin scan is PR 4's
11
+ * gate; this proves the emit contract in isolation).
12
+ *
13
+ * Bisect-verify (PR body): revert the fallback chain
14
+ * (`return tryPartialCollapse(...) || tryDynamicCollapse(...)` →
15
+ * `return tryPartialCollapse(...)`) → the dynamic-emit specs fail
16
+ * (`__rsCollapseDyn(` absent) while the FULL + PARTIAL specs still
17
+ * pass (proving the dynamic fallthrough is the only delta).
18
+ * Restore → all pass.
19
+ */
20
+ import { describe, expect, it } from 'vitest'
21
+ import { rocketstyleCollapseKey, transformJSX } from '../jsx'
22
+
23
+ // Per-value resolved sites — the dynamic emit looks up BOTH literals
24
+ // via separate keys. `templateHtml` MUST be byte-identical across
25
+ // values for the dispatcher to share one `_tpl` (cross-value template
26
+ // parity bail).
27
+ const TPL = '<button>Save</button>'
28
+
29
+ const PRIMARY = {
30
+ templateHtml: TPL,
31
+ lightClass: 'pyr-pri-L',
32
+ darkClass: 'pyr-pri-D',
33
+ rules: ['.pyr-pri-L{color:red}', '.pyr-pri-D{color:darkred}'],
34
+ ruleKey: 'bundle-pri',
35
+ }
36
+ const SECONDARY = {
37
+ templateHtml: TPL,
38
+ lightClass: 'pyr-sec-L',
39
+ darkClass: 'pyr-sec-D',
40
+ rules: ['.pyr-sec-L{color:blue}', '.pyr-sec-D{color:darkblue}'],
41
+ ruleKey: 'bundle-sec',
42
+ }
43
+
44
+ function collapseOpt(
45
+ candidates: string[],
46
+ sites: Record<string, { templateHtml: string; lightClass: string; darkClass: string; rules: string[]; ruleKey: string }>,
47
+ ) {
48
+ return {
49
+ collapseRocketstyle: {
50
+ candidates: new Set(candidates),
51
+ sites: new Map(Object.entries(sites)),
52
+ mode: { name: 'useMode', source: '@pyreon/ui-core' },
53
+ },
54
+ }
55
+ }
56
+
57
+ describe('compiler — dynamic-prop collapse emission (PR 3, ternary-of-two-literals)', () => {
58
+ it('emits __rsCollapseDyn with stride-2 value-major classes + cond dispatcher', () => {
59
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary', size: 'medium' }, 'Save')
60
+ const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary', size: 'medium' }, 'Save')
61
+ const src =
62
+ 'const x = <Button state={isPrimary ? "primary" : "secondary"} size="medium">Save</Button>'
63
+ const { code } = transformJSX(
64
+ src,
65
+ 'A.tsx',
66
+ collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
67
+ )
68
+
69
+ // Stride-2 value-major class layout: `[v0_light, v0_dark, v1_light, v1_dark]`.
70
+ // v0 = consequent (cond → 0), v1 = alternate (cond → 1) — matches
71
+ // `_rsCollapseDyn` doc + bisect-verified in PR 1 (#765).
72
+ expect(code).toContain(
73
+ '__rsCollapseDyn("<button>Save</button>", ' +
74
+ '["pyr-pri-L","pyr-pri-D","pyr-sec-L","pyr-sec-D"], ' +
75
+ '() => (isPrimary) ? 0 : 1, ' +
76
+ '() => __pyrMode() === "dark")',
77
+ )
78
+ })
79
+
80
+ it('imports the _rsCollapseDyn helper (and NOT the full / H helpers when not used)', () => {
81
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Go')
82
+ const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'Go')
83
+ const src = 'const x = <Button state={c ? "primary" : "secondary"}>Go</Button>'
84
+ const { code } = transformJSX(
85
+ src,
86
+ 'B.tsx',
87
+ collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
88
+ )
89
+ // Conditional import — dynamic-only modules pull `_rsCollapseDyn`
90
+ // ONLY (tree-shake-friendly per-feature granularity).
91
+ expect(code).toContain('import { _rsCollapseDyn as __rsCollapseDyn } from "@pyreon/runtime-dom";')
92
+ expect(code).not.toContain('_rsCollapse as __rsCollapse')
93
+ expect(code).not.toContain('_rsCollapseH as __rsCollapseH')
94
+ })
95
+
96
+ it('unions BOTH values\' rule bundles via injectRules (de-duped by ruleKey)', () => {
97
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'X')
98
+ const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'X')
99
+ const src = 'const x = <Button state={c ? "primary" : "secondary"}>X</Button>'
100
+ const { code } = transformJSX(
101
+ src,
102
+ 'C.tsx',
103
+ collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
104
+ )
105
+ // Each value's rule bundle injected separately (different ruleKeys
106
+ // ⇒ no dedupe). Same idempotent injectRules contract as the full
107
+ // path — styler dedupes by key at runtime.
108
+ expect(code).toContain('"bundle-pri"')
109
+ expect(code).toContain('"bundle-sec"')
110
+ expect(code).toContain('__rsSheet.injectRules(')
111
+ })
112
+
113
+ it('preserves complex cond source verbatim (paren-wrapped for safe re-emission)', () => {
114
+ const truthyKey = rocketstyleCollapseKey('Btn', { state: 'primary' }, 'Hi')
115
+ const falsyKey = rocketstyleCollapseKey('Btn', { state: 'danger' }, 'Hi')
116
+ const src = 'const x = <Btn state={user.role === "admin" ? "primary" : "danger"}>Hi</Btn>'
117
+ const { code } = transformJSX(
118
+ src,
119
+ 'D.tsx',
120
+ collapseOpt(['Btn'], {
121
+ [truthyKey]: { ...PRIMARY },
122
+ [falsyKey]: { ...SECONDARY, lightClass: 'pyr-dng-L', darkClass: 'pyr-dng-D' },
123
+ }),
124
+ )
125
+ // The paren-wrap (`(user.role === "admin")`) keeps the cond a single
126
+ // expression — same shape as the on*-handler emit re-emits arrow bodies.
127
+ expect(code).toContain('() => (user.role === "admin") ? 0 : 1')
128
+ })
129
+
130
+ // ── Conservative-bail discipline ───────────────────────────────────────
131
+ it('BAILS when EITHER expanded site is missing from the resolved map', () => {
132
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'S')
133
+ // Only the truthy half resolved — falsy is absent (resolver returned
134
+ // null for that variant). Half-resolved ⇒ keep the normal mount.
135
+ const src = 'const x = <Button state={c ? "primary" : "secondary"}>S</Button>'
136
+ const { code } = transformJSX(
137
+ src,
138
+ 'E.tsx',
139
+ collapseOpt(['Button'], { [truthyKey]: PRIMARY }),
140
+ )
141
+ expect(code).not.toContain('__rsCollapseDyn(')
142
+ // Normal mount preserved — `<Button …>` JSX still appears (or its
143
+ // standard `h()` form post-transform).
144
+ })
145
+
146
+ it('BAILS when the structural template diverges across values', () => {
147
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'D')
148
+ const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'D')
149
+ const src = 'const x = <Button state={c ? "primary" : "secondary"}>D</Button>'
150
+ const { code } = transformJSX(
151
+ src,
152
+ 'F.tsx',
153
+ collapseOpt(['Button'], {
154
+ [truthyKey]: { ...PRIMARY, templateHtml: '<button>D</button>' },
155
+ [falsyKey]: { ...SECONDARY, templateHtml: '<button data-extra>D</button>' }, // divergent
156
+ }),
157
+ )
158
+ expect(code).not.toContain('__rsCollapseDyn(')
159
+ })
160
+
161
+ it('BAILS when the dynamic site ALSO has on*-handlers (PR 3 scope: no-handler only)', () => {
162
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'H')
163
+ const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'H')
164
+ const src =
165
+ 'const x = <Button state={c ? "primary" : "secondary"} onClick={go}>H</Button>'
166
+ const { code } = transformJSX(
167
+ src,
168
+ 'G.tsx',
169
+ collapseOpt(['Button'], { [truthyKey]: PRIMARY, [falsyKey]: SECONDARY }),
170
+ )
171
+ expect(code).not.toContain('__rsCollapseDyn(')
172
+ expect(code).not.toContain('__rsCollapseH(')
173
+ })
174
+
175
+ // ── Regression: FULL + on*-PARTIAL paths byte-unchanged ────────────────
176
+ it('FULL-collapse path byte-unchanged: no-handler literal site emits plain __rsCollapse', () => {
177
+ const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Save')
178
+ const src = 'const x = <Button state="primary">Save</Button>'
179
+ const { code } = transformJSX(src, 'H.tsx', collapseOpt(['Button'], { [key]: PRIMARY }))
180
+ expect(code).toContain('__rsCollapse(')
181
+ expect(code).not.toContain('__rsCollapseH(')
182
+ expect(code).not.toContain('__rsCollapseDyn(')
183
+ })
184
+
185
+ it('PARTIAL on*-handler path byte-unchanged: literal site + handler emits __rsCollapseH', () => {
186
+ const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Save')
187
+ const src = 'const x = <Button state="primary" onClick={go}>Save</Button>'
188
+ const { code } = transformJSX(src, 'I.tsx', collapseOpt(['Button'], { [key]: PRIMARY }))
189
+ expect(code).toContain('__rsCollapseH(')
190
+ expect(code).not.toContain('__rsCollapseDyn(')
191
+ })
192
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * PR 3 of the dynamic-prop partial-collapse build — `scanCollapsibleSites`
3
+ * extension. The plugin-side scan (`@pyreon/vite-plugin`) calls this to
4
+ * learn WHICH (component, props, text) tuples need resolution. For
5
+ * dynamic-prop sites it must expand into TWO `CollapsibleSite` entries
6
+ * (one per literal value) so the resolver pre-renders both via the
7
+ * existing SSR pipeline AND the compiler emit (PR 3 `tryDynamicCollapse`)
8
+ * looks up both via identical key construction.
9
+ *
10
+ * Key invariant: the keys this scan emits must EQUAL the keys
11
+ * `tryDynamicCollapse` computes from the same JSX — same load-bearing
12
+ * separation as the existing full / on*-handler scan↔emit invariants
13
+ * (`scanCollapsibleSites` ↔ `detectCollapsibleShape`).
14
+ */
15
+ import { describe, expect, it } from 'vitest'
16
+ import { rocketstyleCollapseKey, scanCollapsibleSites } from '../jsx'
17
+
18
+ const COLLAPSIBLE = new Set(['@pyreon/ui-components'])
19
+
20
+ describe('scanCollapsibleSites — dynamic-prop expansion (PR 3)', () => {
21
+ it('expands a single ternary site into TWO CollapsibleSite entries (one per literal)', () => {
22
+ const src = `
23
+ import { Button } from '@pyreon/ui-components'
24
+ const x = <Button state={cond ? "primary" : "secondary"} size="medium">Save</Button>
25
+ `
26
+ const sites = scanCollapsibleSites(src, 'A.tsx', COLLAPSIBLE)
27
+ expect(sites).toHaveLength(2)
28
+ const byState = new Map(sites.map((s) => [s.props.state, s]))
29
+ expect(byState.get('primary')!.props).toEqual({ state: 'primary', size: 'medium' })
30
+ expect(byState.get('secondary')!.props).toEqual({ state: 'secondary', size: 'medium' })
31
+ expect(byState.get('primary')!.childrenText).toBe('Save')
32
+ expect(byState.get('secondary')!.childrenText).toBe('Save')
33
+ // Both entries share the same component / source / importedName —
34
+ // only `props.state` differs.
35
+ expect(byState.get('primary')!.componentName).toBe('Button')
36
+ expect(byState.get('secondary')!.componentName).toBe('Button')
37
+ })
38
+
39
+ it('emits keys IDENTICAL to what tryDynamicCollapse will look up', () => {
40
+ // The cross-detector invariant: the scan and the emit MUST agree
41
+ // on the key for each expanded value. Drift would cause the emit
42
+ // to miss its own pre-resolved sites and bail to normal mount.
43
+ const src = `
44
+ import { Button } from '@pyreon/ui-components'
45
+ const x = <Button state={c ? "primary" : "secondary"}>Go</Button>
46
+ `
47
+ const sites = scanCollapsibleSites(src, 'B.tsx', COLLAPSIBLE)
48
+ expect(sites).toHaveLength(2)
49
+ const truthyKey = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Go')
50
+ const falsyKey = rocketstyleCollapseKey('Button', { state: 'secondary' }, 'Go')
51
+ const keys = sites.map((s) => s.key).sort()
52
+ expect(keys).toEqual([truthyKey, falsyKey].sort())
53
+ })
54
+
55
+ it('EXPANDS handler-combined dynamic sites too (handler-combined emit unlocks the 15.4% bucket)', () => {
56
+ // The follow-up emit (`__rsCollapseDynH` from `tryDynamicCollapse`)
57
+ // handles ternary-plus-handler sites via the combined runtime
58
+ // helper. The scan emits both literal-value expansions identically
59
+ // — handlers don't affect the resolver's input (componentName,
60
+ // props, childrenText). Handlers are re-attached by the runtime
61
+ // helper, not by the resolver.
62
+ //
63
+ // Previously the scan SKIPPED handler-combined sites (matching
64
+ // the PR 3 emit's no-handler scope); the follow-up lifts that
65
+ // restriction so the resolver pre-renders both values for
66
+ // handler-bearing sites too.
67
+ const src = `
68
+ import { Button } from '@pyreon/ui-components'
69
+ const x = <Button state={c ? "primary" : "secondary"} onClick={go}>H</Button>
70
+ `
71
+ const sites = scanCollapsibleSites(src, 'C.tsx', COLLAPSIBLE)
72
+ expect(sites).toHaveLength(2)
73
+ const byState = new Map(sites.map((s) => [s.props.state, s]))
74
+ expect(byState.get('primary')!.childrenText).toBe('H')
75
+ expect(byState.get('secondary')!.childrenText).toBe('H')
76
+ })
77
+
78
+ it('does not double-emit when the FULL detector already claims the site (literal-only)', () => {
79
+ // A fully-literal site is the FULL-collapse shape — claimed by
80
+ // detectCollapsibleShape; the dynamic-fallthrough branch in the
81
+ // scan only runs when the full detector returned null.
82
+ const src = `
83
+ import { Button } from '@pyreon/ui-components'
84
+ const x = <Button state="primary" size="medium">Save</Button>
85
+ `
86
+ const sites = scanCollapsibleSites(src, 'D.tsx', COLLAPSIBLE)
87
+ expect(sites).toHaveLength(1)
88
+ expect(sites[0]!.props).toEqual({ state: 'primary', size: 'medium' })
89
+ })
90
+
91
+ it('emits 4 entries for a module with 2 ternary sites (no dedupe across distinct sites)', () => {
92
+ const src = `
93
+ import { Button } from '@pyreon/ui-components'
94
+ const a = <Button state={c1 ? "primary" : "secondary"}>A</Button>
95
+ const b = <Button state={c2 ? "danger" : "success"}>B</Button>
96
+ `
97
+ const sites = scanCollapsibleSites(src, 'E.tsx', COLLAPSIBLE)
98
+ expect(sites).toHaveLength(4)
99
+ const states = sites.map((s) => `${s.props.state}/${s.childrenText}`).sort()
100
+ expect(states).toEqual(['danger/B', 'primary/A', 'secondary/A', 'success/B'])
101
+ })
102
+
103
+ it('skips multi-ternary site entirely (separable scope, not this PR)', () => {
104
+ const src = `
105
+ import { Button } from '@pyreon/ui-components'
106
+ const x = <Button state={a ? "x" : "y"} size={b ? "small" : "large"}>S</Button>
107
+ `
108
+ const sites = scanCollapsibleSites(src, 'F.tsx', COLLAPSIBLE)
109
+ expect(sites).toHaveLength(0)
110
+ })
111
+ })