@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
|
@@ -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
|
+
})
|