@pyreon/compiler 0.24.5 → 0.24.6

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.
Files changed (64) hide show
  1. package/package.json +11 -13
  2. package/src/defer-inline.ts +0 -686
  3. package/src/event-names.ts +0 -65
  4. package/src/index.ts +0 -61
  5. package/src/island-audit.ts +0 -675
  6. package/src/jsx.ts +0 -2792
  7. package/src/load-native.ts +0 -156
  8. package/src/lpih.ts +0 -270
  9. package/src/manifest.ts +0 -280
  10. package/src/project-scanner.ts +0 -214
  11. package/src/pyreon-intercept.ts +0 -1029
  12. package/src/react-intercept.ts +0 -1217
  13. package/src/reactivity-lens.ts +0 -190
  14. package/src/ssg-audit.ts +0 -513
  15. package/src/test-audit.ts +0 -435
  16. package/src/tests/backend-parity-r7-r9.test.ts +0 -91
  17. package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
  18. package/src/tests/collapse-bail-census.test.ts +0 -330
  19. package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
  20. package/src/tests/component-child-no-wrap.test.ts +0 -204
  21. package/src/tests/defer-inline.test.ts +0 -387
  22. package/src/tests/depth-stress.test.ts +0 -16
  23. package/src/tests/detector-tag-consistency.test.ts +0 -101
  24. package/src/tests/dynamic-collapse-detector.test.ts +0 -164
  25. package/src/tests/dynamic-collapse-emit.test.ts +0 -192
  26. package/src/tests/dynamic-collapse-scan.test.ts +0 -111
  27. package/src/tests/element-valued-const-child.test.ts +0 -61
  28. package/src/tests/falsy-child-characterization.test.ts +0 -48
  29. package/src/tests/island-audit.test.ts +0 -524
  30. package/src/tests/jsx.test.ts +0 -2908
  31. package/src/tests/load-native.test.ts +0 -53
  32. package/src/tests/lpih.test.ts +0 -404
  33. package/src/tests/malformed-input-resilience.test.ts +0 -50
  34. package/src/tests/manifest-snapshot.test.ts +0 -55
  35. package/src/tests/native-equivalence.test.ts +0 -924
  36. package/src/tests/partial-collapse-detector.test.ts +0 -121
  37. package/src/tests/partial-collapse-emit.test.ts +0 -104
  38. package/src/tests/partial-collapse-robustness.test.ts +0 -53
  39. package/src/tests/project-scanner.test.ts +0 -269
  40. package/src/tests/prop-derived-shadow.test.ts +0 -96
  41. package/src/tests/pure-call-reactive-args.test.ts +0 -50
  42. package/src/tests/pyreon-intercept.test.ts +0 -816
  43. package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
  44. package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
  45. package/src/tests/r15-elemconst-propderived.test.ts +0 -47
  46. package/src/tests/r19-defer-inline-robust.test.ts +0 -54
  47. package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
  48. package/src/tests/react-intercept.test.ts +0 -1104
  49. package/src/tests/reactivity-lens.test.ts +0 -170
  50. package/src/tests/rocketstyle-collapse.test.ts +0 -208
  51. package/src/tests/runtime/control-flow.test.ts +0 -159
  52. package/src/tests/runtime/dom-properties.test.ts +0 -138
  53. package/src/tests/runtime/events.test.ts +0 -301
  54. package/src/tests/runtime/harness.ts +0 -94
  55. package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
  56. package/src/tests/runtime/reactive-props.test.ts +0 -81
  57. package/src/tests/runtime/signals.test.ts +0 -129
  58. package/src/tests/runtime/whitespace.test.ts +0 -106
  59. package/src/tests/signal-autocall-shadow.test.ts +0 -86
  60. package/src/tests/sourcemap-fidelity.test.ts +0 -77
  61. package/src/tests/ssg-audit.test.ts +0 -402
  62. package/src/tests/static-text-baking.test.ts +0 -64
  63. package/src/tests/test-audit.test.ts +0 -549
  64. package/src/tests/transform-state-isolation.test.ts +0 -49
@@ -1,170 +0,0 @@
1
- import { transformJSX_JS } from '../jsx'
2
- import { analyzeReactivity, formatReactivityLens } from '../reactivity-lens'
3
- import type { ReactivityFinding } from '../reactivity-lens'
4
-
5
- /**
6
- * Reactivity Lens — unit + drift gate.
7
- *
8
- * The load-bearing correctness contract: a lens span is a FAITHFUL RECORD of
9
- * a codegen decision, never an approximation. Two invariants are gated here:
10
- *
11
- * 1. **Additive** — collecting the lens does NOT change emitted `code`
12
- * (kill-criterion a). Bisect: if a future edit makes lens collection
13
- * mutate codegen, `additive` fails.
14
- * 2. **Drift** — every positive `reactive*` span's OUTPUT carries the
15
- * matching codegen token (`_bind`/`_bindText`/`_rp`), and every
16
- * `static-text` span's text is NOT reactively bound (kill-criterion b).
17
- * Bisect: reverting the `lens(...)` call at any instrumented site drops
18
- * the corresponding span and the matching `expect(kinds).toContain(...)`
19
- * fails — documented per-fixture below.
20
- */
21
-
22
- function kindsAt(code: string): string[] {
23
- return analyzeReactivity(code).findings.map((f) => f.kind)
24
- }
25
- function find(code: string, kind: string): ReactivityFinding[] {
26
- return analyzeReactivity(code).findings.filter((f) => f.kind === kind)
27
- }
28
- function sliceFinding(code: string, f: ReactivityFinding): string {
29
- // Re-derive the byte slice from line/col for a human-readable assertion.
30
- const lines = code.split('\n')
31
- const line = lines[f.line - 1] ?? ''
32
- return f.endLine === f.line
33
- ? line.slice(f.column, f.endColumn)
34
- : line.slice(f.column)
35
- }
36
-
37
- describe('reactivity-lens — additive contract (kill-criterion a)', () => {
38
- const FIXTURES = [
39
- `function C(){ return <div>{count()}</div> }`,
40
- `function C(p){ return <span>{p.label}</span> }`,
41
- `function C(){ return <Box title={n()} /> }`,
42
- `function C(){ return <div class="static">hi</div> }`,
43
- `function C(){ return <a class={() => cls()}>x</a> }`,
44
- `const x = 1; function C(){ const {a}=props; return <i>{a}</i> }`,
45
- ]
46
-
47
- it('lens collection NEVER changes emitted code (byte-identical)', () => {
48
- for (const src of FIXTURES) {
49
- const off = transformJSX_JS(src, 'f.tsx').code
50
- const on = transformJSX_JS(src, 'f.tsx', { reactivityLens: true }).code
51
- expect(on).toBe(off)
52
- }
53
- })
54
-
55
- it('lens field is absent unless opted in', () => {
56
- const r = transformJSX_JS(FIXTURES[0]!, 'f.tsx')
57
- expect(r.reactivityLens).toBeUndefined()
58
- const r2 = transformJSX_JS(FIXTURES[0]!, 'f.tsx', { reactivityLens: true })
59
- expect(Array.isArray(r2.reactivityLens)).toBe(true)
60
- })
61
- })
62
-
63
- describe('reactivity-lens — drift gate (positive claim = codegen record)', () => {
64
- it('reactive text: {count()} → reactive span + _bind in output', () => {
65
- const src = `function C(){ return <div>{count()}</div> }`
66
- const reactive = find(src, 'reactive')
67
- expect(reactive.length).toBe(1)
68
- expect(sliceFinding(src, reactive[0]!)).toBe('count()')
69
- // Drift proof: the codegen actually emitted a reactive binding.
70
- const out = transformJSX_JS(src, 'f.tsx').code
71
- expect(out).toMatch(/_bind(Text|Direct)?\(/)
72
- expect(kindsAt(src)).not.toContain('static-text')
73
- })
74
-
75
- it('static text: {p.x}-free plain identifier → static-text, NOT reactive', () => {
76
- const src = `function C(){ const label = "hi"; return <div>{label}</div> }`
77
- const k = kindsAt(src)
78
- expect(k).toContain('static-text')
79
- expect(k).not.toContain('reactive')
80
- const st = find(src, 'static-text')[0]!
81
- expect(sliceFinding(src, st)).toBe('label')
82
- // Drift proof: codegen baked it (no reactive binding helper for this).
83
- const out = transformJSX_JS(src, 'f.tsx').code
84
- expect(out).not.toMatch(/_bind\(\(\) => \{ \w+\.data = label \}/)
85
- })
86
-
87
- it('reactive prop: <Box title={n()} /> → reactive-prop + _rp in output', () => {
88
- const src = `function C(){ return <Box title={n()} /> }`
89
- const rp = find(src, 'reactive-prop')
90
- expect(rp.length).toBe(1)
91
- expect(sliceFinding(src, rp[0]!)).toBe('n()')
92
- expect(transformJSX_JS(src, 'f.tsx').code).toContain('_rp(() =>')
93
- })
94
-
95
- it('hoisted static: static JSX in a non-template position → hoisted-static + module preamble', () => {
96
- // A top-level returned static element becomes a `_tpl()` clone (template
97
- // path) — that's not a hoist, and the lens correctly stays silent (no
98
- // span = "not asserted"). maybeHoist only fires for static JSX in an
99
- // expression slot of a non-DOM (component) parent; that's the faithful
100
- // trigger and what the lens records.
101
- const src = `function C(){ return <Comp>{<b class="x">hi</b>}</Comp> }`
102
- const hs = find(src, 'hoisted-static')
103
- expect(hs.length).toBeGreaterThanOrEqual(1)
104
- expect(transformJSX_JS(src, 'f.tsx').code).toMatch(/const _\$h\d+ =/)
105
- })
106
-
107
- it('reactive attr: class={() => cls()} → reactive-attr', () => {
108
- const src = `function C(){ return <a class={() => cls()}>x</a> }`
109
- const ra = find(src, 'reactive-attr')
110
- expect(ra.length).toBe(1)
111
- expect(ra[0]!.detail).toContain('class')
112
- })
113
- })
114
-
115
- describe('reactivity-lens — footgun merge (existing detectPyreonPatterns)', () => {
116
- it('param-destructured props surface a footgun finding with the detector code', () => {
117
- // detectPyreonPatterns catches the PARAMETER-destructure shape
118
- // `({ name })`. The body-scope `const {x}=props` shape is the static
119
- // layer's known cliff (doc-only anti-pattern, no reliable AST detector)
120
- // — the lens's structural `static-text`/`reactive` signals are what
121
- // compensate for that downstream. This asserts the merge surfaces
122
- // whatever the existing detector finds, faithfully.
123
- const src = `function C({ name }){ return <div>{name}</div> }`
124
- const fg = find(src, 'footgun')
125
- expect(fg.length).toBeGreaterThanOrEqual(1)
126
- expect(fg.some((f) => f.code === 'props-destructured')).toBe(true)
127
- })
128
-
129
- it('findings are sorted by (line, column)', () => {
130
- const src = [
131
- `function C(props){`,
132
- ` const { a } = props`,
133
- ` return <div>{count()}</div>`,
134
- `}`,
135
- ].join('\n')
136
- const { findings } = analyzeReactivity(src)
137
- for (let i = 1; i < findings.length; i++) {
138
- const prev = findings[i - 1]!
139
- const cur = findings[i]!
140
- expect(
141
- prev.line < cur.line ||
142
- (prev.line === cur.line && prev.column <= cur.column),
143
- ).toBe(true)
144
- }
145
- })
146
- })
147
-
148
- describe('reactivity-lens — zero false "live" on idiomatic code (kill-criterion b)', () => {
149
- it('purely static component yields no reactive* findings', () => {
150
- const src = `function Card(){ return <div class="card"><h2>Title</h2><p>Body</p></div> }`
151
- const k = kindsAt(src)
152
- expect(k).not.toContain('reactive')
153
- expect(k).not.toContain('reactive-prop')
154
- expect(k).not.toContain('reactive-attr')
155
- })
156
-
157
- it('parse failure → empty, never throws', () => {
158
- const r = analyzeReactivity(`function C( { return <div`)
159
- expect(Array.isArray(r.findings)).toBe(true)
160
- })
161
- })
162
-
163
- describe('reactivity-lens — formatter', () => {
164
- it('renders annotated source with kind badges', () => {
165
- const src = `function C(){ return <div>{count()}</div> }`
166
- const out = formatReactivityLens(src, analyzeReactivity(src))
167
- expect(out).toContain('live')
168
- expect(out).toContain('1 |')
169
- })
170
- })
@@ -1,208 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { rocketstyleCollapseKey, transformJSX } from '../jsx'
3
-
4
- // Layer 4: the compiler DETECTS a literal-prop rocketstyle call site
5
- // (bail catalogue — RFC decision 3) and EMITS the collapsed
6
- // `_rsCollapse(...)` + once-per-module idempotent `injectRules`, when
7
- // the Vite plugin supplies an SSR-resolved `sites` entry. It never runs
8
- // the rocketstyle chain itself. These tests stub the resolved `sites`
9
- // map directly (no Vite) — the real SSR resolution is proven in
10
- // @pyreon/vite-plugin's resolver test; the end-to-end byte-parity is
11
- // proven by the ui-showcase e2e gate (Phase 4).
12
-
13
- const SITE = {
14
- templateHtml: '<button data-x="1"><span class="inner">Save</span></button>',
15
- lightClass: 'pyr-L1 pyr-L2',
16
- darkClass: 'pyr-D1 pyr-D2',
17
- rules: ['.pyr-L1{color:red}', '.pyr-D1{color:blue}'],
18
- ruleKey: 'bundleA',
19
- }
20
-
21
- function collapseOpt(candidates: string[], sites: Record<string, typeof SITE>) {
22
- return {
23
- collapseRocketstyle: {
24
- candidates: new Set(candidates),
25
- sites: new Map(Object.entries(sites)),
26
- mode: { name: 'useMode', source: '@pyreon/ui-core' },
27
- },
28
- }
29
- }
30
-
31
- describe('rocketstyleCollapseKey — stable + order-independent', () => {
32
- it('same component+props+text ⇒ same key regardless of attr order', () => {
33
- const a = rocketstyleCollapseKey('Button', { state: 'primary', size: 'md' }, 'Save')
34
- const b = rocketstyleCollapseKey('Button', { size: 'md', state: 'primary' }, 'Save')
35
- expect(a).toBe(b)
36
- expect(a).not.toBe(rocketstyleCollapseKey('Button', { state: 'secondary' }, 'Save'))
37
- expect(a).not.toBe(rocketstyleCollapseKey('Button', { state: 'primary', size: 'md' }, 'Go'))
38
- })
39
- })
40
-
41
- describe('compiler — collapsible call site emission', () => {
42
- it('emits _rsCollapse + dual-emit mode thunk + once-per-module injectRules', () => {
43
- const key = rocketstyleCollapseKey('Button', { state: 'primary', size: 'medium' }, 'Save')
44
- const src = `
45
- import { Button } from '@pyreon/ui-components'
46
- export function App() {
47
- return <Button state="primary" size="medium">Save</Button>
48
- }`
49
- const { code } = transformJSX(src, 'App.tsx', collapseOpt(['Button'], { [key]: SITE }))
50
- // collapsed call replaces the JSX
51
- expect(code).toContain(
52
- '__rsCollapse("<button data-x=\\"1\\"><span class=\\"inner\\">Save</span></button>", "pyr-L1 pyr-L2", "pyr-D1 pyr-D2", () => __pyrMode() === "dark")',
53
- )
54
- // dual-emit mode accessor imported from the configured source
55
- expect(code).toContain('import { useMode as __pyrMode } from "@pyreon/ui-core";')
56
- // runtime helper + styler sheet imports
57
- expect(code).toContain('import { _rsCollapse as __rsCollapse } from "@pyreon/runtime-dom";')
58
- expect(code).toContain('import { sheet as __rsSheet } from "@pyreon/styler";')
59
- // once-per-module idempotent rule injection, keyed by ruleKey
60
- expect(code).toContain('__rsSheet.injectRules(')
61
- expect(code).toContain(JSON.stringify(SITE.rules))
62
- expect(code).toContain('"bundleA")')
63
- // the original <Button …> JSX is gone
64
- expect(code).not.toContain('<Button')
65
- })
66
-
67
- it('two identical sites in one module emit ONE injectRules (deduped by ruleKey)', () => {
68
- const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'X')
69
- const src = `
70
- import { Button } from '@pyreon/ui-components'
71
- export const A = () => <Button state="primary">X</Button>
72
- export const B = () => <Button state="primary">X</Button>`
73
- const { code } = transformJSX(src, 'M.tsx', collapseOpt(['Button'], { [key]: SITE }))
74
- const injCount = code.split('__rsSheet.injectRules(').length - 1
75
- expect(injCount).toBe(1)
76
- const callCount = code.split('__rsCollapse(').length - 1
77
- // 2 call sites (the `_rsCollapse as __rsCollapse` import alias has
78
- // no trailing `(`, so it doesn't count)
79
- expect(callCount).toBe(2)
80
- })
81
- })
82
-
83
- describe('compiler — bail catalogue (RFC decision 3): NO collapse', () => {
84
- const key = rocketstyleCollapseKey('Button', { state: 'primary' }, 'Save')
85
- const sites = { [key]: SITE }
86
-
87
- function noCollapse(src: string, opt = collapseOpt(['Button'], sites)) {
88
- const { code } = transformJSX(src, 'B.tsx', opt)
89
- expect(code).not.toContain('__rsCollapse')
90
- return code
91
- }
92
-
93
- it('bails on a non-literal (signal/expr) dimension prop', () => {
94
- noCollapse(`
95
- import { Button } from '@pyreon/ui-components'
96
- export const A = (p) => <Button state={p.s}>Save</Button>`)
97
- })
98
-
99
- it('bails on a JSX spread attribute', () => {
100
- noCollapse(`
101
- import { Button } from '@pyreon/ui-components'
102
- export const A = (p) => <Button state="primary" {...p}>Save</Button>`)
103
- })
104
-
105
- it('bails on an element child (non-static-text children)', () => {
106
- noCollapse(`
107
- import { Button } from '@pyreon/ui-components'
108
- export const A = () => <Button state="primary"><i>Save</i></Button>`)
109
- })
110
-
111
- it('bails on an expression child', () => {
112
- noCollapse(`
113
- import { Button } from '@pyreon/ui-components'
114
- export const A = (p) => <Button state="primary">{p.label}</Button>`)
115
- })
116
-
117
- it('bails when the component is not a registered candidate', () => {
118
- noCollapse(
119
- `
120
- import { Card } from '@pyreon/ui-components'
121
- export const A = () => <Card state="primary">Save</Card>`,
122
- collapseOpt(['Button'], sites),
123
- )
124
- })
125
-
126
- it('bails when there is no resolved site for the key (resolver bailed / not data)', () => {
127
- noCollapse(
128
- `
129
- import { Button } from '@pyreon/ui-components'
130
- export const A = () => <Button state="zzz">Save</Button>`,
131
- collapseOpt(['Button'], sites),
132
- )
133
- })
134
-
135
- it('does nothing when collapseRocketstyle option is absent (default OFF)', () => {
136
- const { code } = transformJSX(
137
- `
138
- import { Button } from '@pyreon/ui-components'
139
- export const A = () => <Button state="primary">Save</Button>`,
140
- 'Off.tsx',
141
- {},
142
- )
143
- expect(code).not.toContain('__rsCollapse')
144
- })
145
- })
146
-
147
- describe('bisect: collapse forces the JS path', () => {
148
- it('emits the collapse even though a native binary may be present', () => {
149
- // transformJSX must short-circuit to transformJSX_JS when
150
- // collapseRocketstyle is set (the Rust binary doesn't implement it).
151
- // If the force-JS guard were removed and a native binary were
152
- // loaded, this would emit no __rsCollapse — proving the guard is
153
- // load-bearing. With the guard, JS path always runs.
154
- const key = rocketstyleCollapseKey('Button', {}, 'Hi')
155
- const { code } = transformJSX(
156
- `
157
- import { Button } from '@pyreon/ui-components'
158
- export const A = () => <Button>Hi</Button>`,
159
- 'J.tsx',
160
- collapseOpt(['Button'], { [key]: SITE }),
161
- )
162
- expect(code).toContain('__rsCollapse(')
163
- })
164
- })
165
-
166
- describe('scanCollapsibleSites — plugin scanner == compiler detection', () => {
167
- it('finds the collapsible site with the SAME key the compiler looks up', async () => {
168
- const { scanCollapsibleSites } = await import('../jsx')
169
- const src = `
170
- import { Button as Btn } from '@pyreon/ui-components'
171
- import { useState } from 'somewhere'
172
- export const A = () => <Btn state="primary" size="medium">Save</Btn>
173
- export const B = (p) => <Btn state={p.s}>x</Btn>
174
- export const C = () => <div state="primary">not a candidate</div>`
175
- const sites = scanCollapsibleSites(src, 'A.tsx', new Set(['@pyreon/ui-components']))
176
- // only the literal-prop, static-text <Btn> collapses; the {expr}
177
- // one and the <div> are bailed (catalogue) / non-candidate.
178
- expect(sites).toHaveLength(1)
179
- const s = sites[0]!
180
- expect(s.componentName).toBe('Btn') // LOCAL alias — key uses this
181
- expect(s.importedName).toBe('Button') // resolver imports this
182
- expect(s.source).toBe('@pyreon/ui-components')
183
- expect(s.props).toEqual({ state: 'primary', size: 'medium' })
184
- expect(s.childrenText).toBe('Save')
185
- // The key the plugin computes here MUST equal the key the compiler
186
- // recomputes from the JSX node — proven by feeding a sites map
187
- // keyed by s.key and asserting the compiler collapses.
188
- const { code } = transformJSX(src, 'A.tsx', {
189
- collapseRocketstyle: {
190
- candidates: new Set(['Btn']),
191
- sites: new Map([[s.key, SITE]]),
192
- mode: { name: 'useMode', source: '@pyreon/ui-core' },
193
- },
194
- })
195
- // exactly the literal-prop site collapsed; the {expr} <Btn> bailed
196
- // and remains as JSX (1 collapse call, 1 surviving <Btn).
197
- expect(code.split('__rsCollapse(').length - 1).toBe(1)
198
- expect(code).toContain('<Btn state={')
199
- })
200
-
201
- it('skips a component imported from a non-collapsible source', async () => {
202
- const { scanCollapsibleSites } = await import('../jsx')
203
- const src = `
204
- import { Button } from './local-button'
205
- export const A = () => <Button state="primary">Save</Button>`
206
- expect(scanCollapsibleSites(src, 'A.tsx', new Set(['@pyreon/ui-components']))).toHaveLength(0)
207
- })
208
- })
@@ -1,159 +0,0 @@
1
- // @vitest-environment happy-dom
2
- /// <reference lib="dom" />
3
- import { For, h, Show } from '@pyreon/core'
4
- import { signal } from '@pyreon/reactivity'
5
- import { describe, expect, it } from 'vitest'
6
- import { flush, mountInBrowser } from '@pyreon/test-utils/browser'
7
-
8
- /**
9
- * Compiler-runtime tests — control-flow primitives.
10
- *
11
- * These tests verify `<For>` and `<Show>` integrate correctly with the
12
- * Pyreon mount path. They use direct `h()` calls instead of JSX because
13
- * the harness's `compileAndMount` runs only the template-optimization
14
- * pass of `@pyreon/compiler` — the bundler-level JSX → `h()` transform
15
- * (normally done by Vite's esbuild) does NOT run in the harness, so JSX
16
- * containing components like `<For>` would be left raw and unparseable.
17
- *
18
- * `<Match>`, `<Suspense>`, `<ErrorBoundary>` are deferred to Phase C1
19
- * because they need real Chromium for the async / boundary shapes.
20
- */
21
-
22
- describe('compiler-runtime — control flow (h() form)', () => {
23
- it('<For> renders each item and reacts to signal updates', async () => {
24
- const items = signal([
25
- { id: 1, name: 'a' },
26
- { id: 2, name: 'b' },
27
- ])
28
- const { container, unmount } = mountInBrowser(
29
- h(
30
- 'div',
31
- { id: 'root' },
32
- h(For, {
33
- each: items,
34
- by: (i: { id: number; name: string }) => i.id,
35
- children: (i: { name: string }) => h('span', null, i.name),
36
- }),
37
- ),
38
- )
39
- const root = container.querySelector('#root')!
40
- expect(root.querySelectorAll('span').length).toBe(2)
41
- expect(root.textContent).toBe('ab')
42
- items.set([
43
- { id: 1, name: 'a' },
44
- { id: 2, name: 'b' },
45
- { id: 3, name: 'c' },
46
- ])
47
- await flush()
48
- expect(root.querySelectorAll('span').length).toBe(3)
49
- expect(root.textContent).toBe('abc')
50
- unmount()
51
- })
52
-
53
- it('<For> handles removal correctly', async () => {
54
- const items = signal([
55
- { id: 1, name: 'a' },
56
- { id: 2, name: 'b' },
57
- { id: 3, name: 'c' },
58
- ])
59
- const { container, unmount } = mountInBrowser(
60
- h(
61
- 'div',
62
- { id: 'root' },
63
- h(For, {
64
- each: items,
65
- by: (i: { id: number; name: string }) => i.id,
66
- children: (i: { name: string }) => h('span', null, i.name),
67
- }),
68
- ),
69
- )
70
- const root = container.querySelector('#root')!
71
- expect(root.querySelectorAll('span').length).toBe(3)
72
- items.set([{ id: 2, name: 'b' }])
73
- await flush()
74
- expect(root.querySelectorAll('span').length).toBe(1)
75
- expect(root.textContent).toBe('b')
76
- unmount()
77
- })
78
-
79
- it('<Show> conditionally renders based on signal', async () => {
80
- const visible = signal(true)
81
- const { container, unmount } = mountInBrowser(
82
- h(
83
- 'div',
84
- { id: 'root' },
85
- h(Show, { when: () => visible(), children: h('span', { id: 'x' }, 'visible') }),
86
- ),
87
- )
88
- const root = container.querySelector('#root')!
89
- expect(root.querySelector('#x')).not.toBeNull()
90
- visible.set(false)
91
- await flush()
92
- expect(root.querySelector('#x')).toBeNull()
93
- visible.set(true)
94
- await flush()
95
- expect(root.querySelector('#x')).not.toBeNull()
96
- unmount()
97
- })
98
-
99
- it('<Show> with fallback renders fallback when condition is false', async () => {
100
- const flag = signal(false)
101
- const { container, unmount } = mountInBrowser(
102
- h(
103
- 'div',
104
- { id: 'root' },
105
- h(Show, {
106
- when: () => flag(),
107
- fallback: h('span', { id: 'fb' }, 'fallback'),
108
- children: h('span', { id: 'x' }, 'visible'),
109
- }),
110
- ),
111
- )
112
- const root = container.querySelector('#root')!
113
- expect(root.querySelector('#fb')).not.toBeNull()
114
- expect(root.querySelector('#x')).toBeNull()
115
- flag.set(true)
116
- await flush()
117
- expect(root.querySelector('#fb')).toBeNull()
118
- expect(root.querySelector('#x')).not.toBeNull()
119
- unmount()
120
- })
121
-
122
- it('<Show> with value prop (not accessor) accepts boolean', () => {
123
- // Per #352's `<Show>` defensive normalization fix — `when` accepts
124
- // both `() => boolean` accessor AND raw boolean (for static cases +
125
- // signal auto-call edge case).
126
- const { container, unmount } = mountInBrowser(
127
- h(
128
- 'div',
129
- { id: 'root' },
130
- h(Show, { when: true, children: h('span', { id: 'x' }, 'on') }),
131
- ),
132
- )
133
- expect(container.querySelector('#x')).not.toBeNull()
134
- unmount()
135
- })
136
-
137
- it('nested control flow: <Show> inside <For>', async () => {
138
- const items = signal([
139
- { id: 1, name: 'a', visible: true },
140
- { id: 2, name: 'b', visible: false },
141
- { id: 3, name: 'c', visible: true },
142
- ])
143
- const { container, unmount } = mountInBrowser(
144
- h(
145
- 'div',
146
- { id: 'root' },
147
- h(For, {
148
- each: items,
149
- by: (i: { id: number }) => i.id,
150
- children: (i: { name: string; visible: boolean }) =>
151
- h(Show, { when: () => i.visible, children: h('span', null, i.name) }),
152
- }),
153
- ),
154
- )
155
- const root = container.querySelector('#root')!
156
- expect(root.textContent).toBe('ac')
157
- unmount()
158
- })
159
- })
@@ -1,138 +0,0 @@
1
- // @vitest-environment happy-dom
2
- /// <reference lib="dom" />
3
- import { signal } from '@pyreon/reactivity'
4
- import { describe, expect, it } from 'vitest'
5
- import { flush } from '@pyreon/test-utils/browser'
6
- import { compileAndMount } from './harness'
7
-
8
- /**
9
- * Compiler-runtime tests — DOM-property assignment.
10
- *
11
- * The #352 DOM-property bug used `setAttribute("value", v)` instead of
12
- * `el.value = v` for IDL properties whose live value diverges from the
13
- * content attribute. The fix added a `DOM_PROPS` set covering: value,
14
- * checked, selected, disabled, multiple, readOnly, indeterminate. This
15
- * file pins down each property + asserts the compiled output uses
16
- * property assignment so the live state reflects updates correctly.
17
- *
18
- * Note: happy-dom's `.value` getter follows the attribute even in
19
- * static cases, so for `value` specifically the assertion verifies
20
- * the post-update read works (which would also work via setAttribute
21
- * in happy-dom — the real differentiator is in real Chromium after a
22
- * user types). For `checked` / `disabled` / etc., happy-dom DOES
23
- * differentiate property vs attribute, so those assertions are robust.
24
- */
25
-
26
- describe('compiler-runtime — DOM properties', () => {
27
- it('value property reflects signal updates via .value', async () => {
28
- const text = signal('initial')
29
- const { container, unmount } = compileAndMount(
30
- `<div><input id="i" value={() => text()} /></div>`,
31
- { text },
32
- )
33
- const input = container.querySelector<HTMLInputElement>('#i')!
34
- expect(input.value).toBe('initial')
35
- text.set('updated')
36
- await flush()
37
- expect(input.value).toBe('updated')
38
- text.set('')
39
- await flush()
40
- expect(input.value).toBe('')
41
- unmount()
42
- })
43
-
44
- it('checked property reflects via .checked (not boolean attribute)', async () => {
45
- const isOn = signal(true)
46
- const { container, unmount } = compileAndMount(
47
- `<div><input id="c" type="checkbox" checked={() => isOn()} /></div>`,
48
- { isOn },
49
- )
50
- const cb = container.querySelector<HTMLInputElement>('#c')!
51
- expect(cb.checked).toBe(true)
52
- isOn.set(false)
53
- await flush()
54
- expect(cb.checked).toBe(false)
55
- isOn.set(true)
56
- await flush()
57
- expect(cb.checked).toBe(true)
58
- unmount()
59
- })
60
-
61
- it('disabled property reflects via .disabled', async () => {
62
- const off = signal(false)
63
- const { container, unmount } = compileAndMount(
64
- `<div><button id="b" disabled={() => off()}>x</button></div>`,
65
- { off },
66
- )
67
- const btn = container.querySelector<HTMLButtonElement>('#b')!
68
- expect(btn.disabled).toBe(false)
69
- off.set(true)
70
- await flush()
71
- expect(btn.disabled).toBe(true)
72
- off.set(false)
73
- await flush()
74
- expect(btn.disabled).toBe(false)
75
- unmount()
76
- })
77
-
78
- it('selected on <option> reflects via .selected', async () => {
79
- // Need a sibling option so the browser's "at least one option must
80
- // be selected" auto-selection doesn't pick our option after we
81
- // unselect it.
82
- const sel = signal(false)
83
- const { container, unmount } = compileAndMount(
84
- `<div><select><option>first</option><option id="o" selected={() => sel()}>a</option></select></div>`,
85
- { sel },
86
- )
87
- const opt = container.querySelector<HTMLOptionElement>('#o')!
88
- expect(opt.selected).toBe(false)
89
- sel.set(true)
90
- await flush()
91
- expect(opt.selected).toBe(true)
92
- unmount()
93
- })
94
-
95
- it('multiple on <select> reflects via .multiple', async () => {
96
- const multi = signal(true)
97
- const { container, unmount } = compileAndMount(
98
- `<div><select id="s" multiple={() => multi()}><option>a</option></select></div>`,
99
- { multi },
100
- )
101
- const sel = container.querySelector<HTMLSelectElement>('#s')!
102
- expect(sel.multiple).toBe(true)
103
- multi.set(false)
104
- await flush()
105
- expect(sel.multiple).toBe(false)
106
- unmount()
107
- })
108
-
109
- it('readOnly on <input> reflects via .readOnly', async () => {
110
- const ro = signal(false)
111
- const { container, unmount } = compileAndMount(
112
- `<div><input id="i" readOnly={() => ro()} /></div>`,
113
- { ro },
114
- )
115
- const input = container.querySelector<HTMLInputElement>('#i')!
116
- expect(input.readOnly).toBe(false)
117
- ro.set(true)
118
- await flush()
119
- expect(input.readOnly).toBe(true)
120
- unmount()
121
- })
122
-
123
- it('non-DOM-prop attributes still go through setAttribute', async () => {
124
- // `placeholder` is a real HTML attribute, not a DOM IDL property
125
- // that diverges. Should still flow through setAttribute (not break).
126
- const placeholder = signal('type here')
127
- const { container, unmount } = compileAndMount(
128
- `<div><input id="i" placeholder={() => placeholder()} /></div>`,
129
- { placeholder },
130
- )
131
- const input = container.querySelector<HTMLInputElement>('#i')!
132
- expect(input.getAttribute('placeholder')).toBe('type here')
133
- placeholder.set('changed')
134
- await flush()
135
- expect(input.getAttribute('placeholder')).toBe('changed')
136
- unmount()
137
- })
138
- })