@pyreon/compiler 0.23.0 → 0.24.1

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.
@@ -84,6 +84,13 @@ interface SiteClass {
84
84
  * attrs are string literals AND children are static text (partial-collapse
85
85
  * addressable). */
86
86
  partialAddressable: boolean
87
+ /** dynamic-prop only: true iff EXACTLY ONE dynamic attr is a ternary of
88
+ * two string literals AND every OTHER non-literal attr is an `on*`
89
+ * handler (which compose orthogonally via the handler-combined
90
+ * emit), AND children are static text. Counts the subset addressable
91
+ * by the dynamic-prop collapse PR sequence (PRs #765-#767 plus the
92
+ * handler-combined follow-up). */
93
+ dynamicTernaryAddressable: boolean
87
94
  }
88
95
 
89
96
  const isPascal = (t: string): boolean =>
@@ -113,17 +120,47 @@ function classifySite(node: any): SiteClass {
113
120
  const attrs: any[] = opening.attributes ?? []
114
121
  let sawDynamic = false
115
122
  let everyDynamicIsHandler = true
123
+ // Dynamic-prop addressable tracking: count ternaries + check shape.
124
+ // Exactly one ternary-of-two-literals + every other non-literal attr
125
+ // is either a ternary or an `on*` handler (handlers compose via the
126
+ // combined `_rsCollapseDynH` emit) → addressable. Note no
127
+ // `sawHandler` tracking: the original PR 3 no-handler restriction
128
+ // was lifted by the handler-combined follow-up; handlers no longer
129
+ // disqualify a site from `dynamicTernaryAddressable`.
130
+ let ternaryCount = 0
131
+ let everyDynamicIsTernary = true
116
132
  for (const a of attrs) {
117
- if (a.type === 'JSXSpreadAttribute') return { bucket: 'spread', partialAddressable: false }
133
+ if (a.type === 'JSXSpreadAttribute')
134
+ return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
118
135
  const nm = a.name?.type === 'JSXIdentifier' ? a.name.name : null
119
- if (!nm) return { bucket: 'spread', partialAddressable: false }
136
+ if (!nm)
137
+ return { bucket: 'spread', partialAddressable: false, dynamicTernaryAddressable: false }
120
138
  const v = a.value
121
- if (!v) return { bucket: 'boolean-attr', partialAddressable: false }
139
+ if (!v)
140
+ return {
141
+ bucket: 'boolean-attr',
142
+ partialAddressable: false,
143
+ dynamicTernaryAddressable: false,
144
+ }
122
145
  const isStr =
123
146
  v.type === 'StringLiteral' || (v.type === 'Literal' && typeof v.value === 'string')
124
147
  if (!isStr) {
125
148
  sawDynamic = true
126
- if (!/^on[A-Z]/.test(nm)) everyDynamicIsHandler = false
149
+ const isHandler = /^on[A-Z]/.test(nm)
150
+ if (!isHandler) everyDynamicIsHandler = false
151
+ // Probe for the ternary-of-two-literals shape (PR 2 detector's
152
+ // structural shape).
153
+ const expr = v.type === 'JSXExpressionContainer' ? v.expression : null
154
+ const isLitStr = (n: any): boolean =>
155
+ n &&
156
+ (n.type === 'StringLiteral' || (n.type === 'Literal' && typeof n.value === 'string'))
157
+ const isTernaryOfLits =
158
+ expr &&
159
+ expr.type === 'ConditionalExpression' &&
160
+ isLitStr(expr.consequent) &&
161
+ isLitStr(expr.alternate)
162
+ if (isTernaryOfLits) ternaryCount++
163
+ else if (!isHandler) everyDynamicIsTernary = false
127
164
  }
128
165
  }
129
166
  // children
@@ -135,22 +172,41 @@ function classifySite(node: any): SiteClass {
135
172
  else staticChildrenOnly = false // JSXExpressionContainer etc.
136
173
  }
137
174
  if (sawDynamic) {
138
- // Every NON-dynamic attr is a string literal by construction: the loop
139
- // above early-returns on spread / missing-name / boolean attrs, so any
140
- // attr that didn't set `sawDynamic` is necessarily `isStr`. Hence the
141
- // partial-addressable condition is just "every dynamic attr is on*" AND
142
- // "children are static text" — no separate literal check needed.
143
175
  const partialAddressable = everyDynamicIsHandler && staticChildrenOnly
144
- return { bucket: 'dynamic-prop', partialAddressable }
176
+ // Dynamic-collapse claims: EXACTLY 1 ternary, every OTHER dynamic
177
+ // attr is either a ternary or an `on*` handler (no plain dynamic
178
+ // shapes like `state={getValue()}`), static children. The
179
+ // handler-combined follow-up (this PR) lifted the no-handler
180
+ // restriction by routing handler-bearing dynamic sites to the
181
+ // `_rsCollapseDynH` runtime helper instead of bailing — closes
182
+ // the bulk of the 15.4% dynamic-prop bucket (previously the
183
+ // strict no-handler scope only addressed 0.2% of sites).
184
+ //
185
+ // The `everyDynamicIsTernary` flag here is computed in the loop
186
+ // above as "every non-handler dynamic attr is a ternary"; combined
187
+ // with `ternaryCount === 1` + `staticChildrenOnly` it precisely
188
+ // matches what `detectDynamicCollapsibleShape` + `tryDynamicCollapse`
189
+ // claim. Handlers are NO LONGER excluded — they compose orthogonally.
190
+ const dynamicTernaryAddressable =
191
+ ternaryCount === 1 && everyDynamicIsTernary && staticChildrenOnly
192
+ return { bucket: 'dynamic-prop', partialAddressable, dynamicTernaryAddressable }
145
193
  }
146
194
  // No spread / boolean / dynamic attr. Bail can now only come from children.
147
195
  for (const c of kids) {
148
196
  if (c.type === 'JSXText') continue
149
197
  if (c.type === 'JSXElement' || c.type === 'JSXFragment')
150
- return { bucket: 'element-child', partialAddressable: false }
151
- return { bucket: 'expression-child', partialAddressable: false }
198
+ return {
199
+ bucket: 'element-child',
200
+ partialAddressable: false,
201
+ dynamicTernaryAddressable: false,
202
+ }
203
+ return {
204
+ bucket: 'expression-child',
205
+ partialAddressable: false,
206
+ dynamicTernaryAddressable: false,
207
+ }
152
208
  }
153
- return { bucket: 'collapsible', partialAddressable: false }
209
+ return { bucket: 'collapsible', partialAddressable: false, dynamicTernaryAddressable: false }
154
210
  }
155
211
 
156
212
  describe('proposal #1 — collapse-tail bail-reason census (measurement, not a build)', () => {
@@ -168,6 +224,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
168
224
  }
169
225
  let candidates = 0
170
226
  let partialAddressable = 0
227
+ let dynamicTernaryAddressable = 0
171
228
  let myCollapsible = 0
172
229
  let scannerCollapsible = 0
173
230
 
@@ -191,6 +248,7 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
191
248
  tally[c.bucket]++
192
249
  if (c.bucket === 'collapsible') myCollapsible++
193
250
  if (c.partialAddressable) partialAddressable++
251
+ if (c.dynamicTernaryAddressable) dynamicTernaryAddressable++
194
252
  }
195
253
  }
196
254
  for (const k in node) {
@@ -220,14 +278,27 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
220
278
  ` bail:expression-child : ${tally['expression-child']} (${pct(tally['expression-child'])})`,
221
279
  ` ── partial-collapse ADDRESSABLE : ${partialAddressable} (${pct(partialAddressable)} of all sites)`,
222
280
  ` (dynamic-prop bails where every dynamic attr is on*, all else literal, static children)`,
281
+ ` ── dynamic-collapse ADDRESSABLE : ${dynamicTernaryAddressable} (${pct(dynamicTernaryAddressable)} of all sites)`,
282
+ ` (dynamic-prop bails where EXACTLY ONE attr is a ternary-of-two-string-literals,`,
283
+ ` every other non-literal attr is on* (handlers compose via _rsCollapseDynH),`,
284
+ ` static children — dynamic-prop sequence #765-#767 + handler-combined follow-up)`,
223
285
  '',
224
286
  ].join('\n'),
225
287
  )
226
288
 
227
289
  // ── Trustworthiness gate (bisect-equivalent) ────────────────────────────
228
- // This independent walk's "collapsible" count MUST equal the production
229
- // scanner's truth-set. If they diverge the census is measuring fiction.
230
- expect(myCollapsible).toBe(scannerCollapsible)
290
+ // This independent walk's "collapsible-equivalent" count MUST equal the
291
+ // production scanner's truth-set. If they diverge the census is
292
+ // measuring fiction.
293
+ //
294
+ // Per PR 3 (#767) the scanner emits TWO `CollapsibleSite` entries per
295
+ // dynamic-prop site (one per literal value — the resolver pre-renders
296
+ // both); the compiler emit still produces ONE collapsed call site. So
297
+ // the per-site classifier count + 2× the dynamic-addressable count
298
+ // equals the scanner's per-resolution count. If they diverge, either
299
+ // the classifier and scanner disagree on which dynamic sites are
300
+ // addressable, OR the scanner's expansion drifted from this formula.
301
+ expect(myCollapsible + 2 * dynamicTernaryAddressable).toBe(scannerCollapsible)
231
302
 
232
303
  // ── Lock the headline finding (ratchet record) ──────────────────────────
233
304
  // The corpus is real and large; these are the measured facts as of this
@@ -241,5 +312,19 @@ describe('proposal #1 — collapse-tail bail-reason census (measurement, not a b
241
312
  // the classifier ran over a non-trivial dynamic-prop population so the
242
313
  // ratio is meaningful, not noise.
243
314
  expect(tally['dynamic-prop'] + tally.collapsible).toBeGreaterThan(0)
315
+ // PR 4 of the dynamic-prop partial-collapse build: lock that the
316
+ // dynamic-collapse classifier ran over a meaningful population
317
+ // (dynamic-prop bucket non-zero). The addressable count is in the
318
+ // log; we DON'T assert it >0 here because the strict no-handler
319
+ // PR 3 scope is honestly small in real-world corpora (real Buttons
320
+ // with `state={cond ? ... : ...}` almost always also carry
321
+ // `onClick` → BAIL until the handler-combined follow-up). The
322
+ // dynamic-prop bucket itself is the size of the future surface;
323
+ // PR 3's no-handler subset is the first measurable step.
324
+ expect(tally['dynamic-prop']).toBeGreaterThan(0)
325
+ // The dynamic-addressable count can be 0 in a clean run (no
326
+ // matching sites in the corpus); just lock that the counter is
327
+ // wired and consistent with the bucket.
328
+ expect(dynamicTernaryAddressable).toBeLessThanOrEqual(tally['dynamic-prop'])
244
329
  })
245
330
  })
@@ -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
+ })