@pyreon/compiler 0.14.0 → 0.16.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.
@@ -328,4 +328,300 @@ describe('detectPyreonPatterns', () => {
328
328
  expect(lines).toEqual([...lines].sort((a, b) => a - b))
329
329
  })
330
330
  })
331
+
332
+ describe('signal-write-as-call', () => {
333
+ it('flags `sig(value)` when sig was declared as a signal', () => {
334
+ const code = `
335
+ import { signal } from '@pyreon/reactivity'
336
+ const count = signal(0)
337
+ function inc() { count(count() + 1) }
338
+ `
339
+ const diags = detectPyreonPatterns(code)
340
+ const hits = diags.filter((d) => d.code === 'signal-write-as-call')
341
+ expect(hits).toHaveLength(1)
342
+ expect(hits[0]!.message).toContain('signal()')
343
+ expect(hits[0]!.suggested).toContain('count.set(')
344
+ })
345
+
346
+ it('does NOT flag `sig()` (zero args — that is the read API)', () => {
347
+ const code = `
348
+ const count = signal(0)
349
+ function read() { return count() }
350
+ `
351
+ const diags = detectPyreonPatterns(code)
352
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
353
+ })
354
+
355
+ it('does NOT flag `sig.set(value)` (the proper write API)', () => {
356
+ const code = `
357
+ const count = signal(0)
358
+ function set(v) { count.set(v) }
359
+ `
360
+ const diags = detectPyreonPatterns(code)
361
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
362
+ })
363
+
364
+ it('does NOT flag calls on identifiers that are not signal-bound', () => {
365
+ const code = `
366
+ const handler = (v) => console.log(v)
367
+ handler(42)
368
+ `
369
+ const diags = detectPyreonPatterns(code)
370
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toEqual([])
371
+ })
372
+
373
+ it('flags `computed(value)` shape too — same misread of the API', () => {
374
+ const code = `
375
+ const doubled = computed(() => count() * 2)
376
+ function bug() { doubled(99) }
377
+ `
378
+ const diags = detectPyreonPatterns(code)
379
+ expect(diags.filter((d) => d.code === 'signal-write-as-call')).toHaveLength(1)
380
+ })
381
+ })
382
+
383
+ describe('static-return-null-conditional', () => {
384
+ it('flags `if (cond) return null` at the top of a component body', () => {
385
+ const code = `
386
+ function TabPanel({ id }) {
387
+ if (!isActive(id)) return null
388
+ return <div class="panel">content</div>
389
+ }
390
+ `
391
+ const diags = detectPyreonPatterns(code)
392
+ const hits = diags.filter((d) => d.code === 'static-return-null-conditional')
393
+ expect(hits).toHaveLength(1)
394
+ expect(hits[0]!.message).toContain('run ONCE')
395
+ expect(hits[0]!.suggested).toContain('=> {')
396
+ })
397
+
398
+ it('flags the block-form `if (cond) { return null }` too', () => {
399
+ const code = `
400
+ function Modal() {
401
+ if (!isOpen()) {
402
+ return null
403
+ }
404
+ return <div class="modal">…</div>
405
+ }
406
+ `
407
+ const diags = detectPyreonPatterns(code)
408
+ expect(
409
+ diags.filter((d) => d.code === 'static-return-null-conditional'),
410
+ ).toHaveLength(1)
411
+ })
412
+
413
+ it('does NOT flag non-component functions returning null', () => {
414
+ const code = `
415
+ function findUser(id) {
416
+ if (!id) return null
417
+ return { id }
418
+ }
419
+ `
420
+ const diags = detectPyreonPatterns(code)
421
+ expect(diags.filter((d) => d.code === 'static-return-null-conditional')).toEqual([])
422
+ })
423
+
424
+ it('does NOT flag the recommended reactive-accessor pattern', () => {
425
+ const code = `
426
+ function TabPanel() {
427
+ return (() => {
428
+ if (!isActive()) return null
429
+ return <div>content</div>
430
+ })
431
+ }
432
+ `
433
+ const diags = detectPyreonPatterns(code)
434
+ // The inner arrow contains the if-return-null but is itself a
435
+ // returned reactive accessor — not the "static-return-null" shape
436
+ // because the OUTER component's body has no top-level if-return-null.
437
+ expect(diags.filter((d) => d.code === 'static-return-null-conditional')).toEqual([])
438
+ })
439
+
440
+ it('only flags ONCE per component body even when chained', () => {
441
+ const code = `
442
+ function MultiGuard() {
443
+ if (!a()) return null
444
+ if (!b()) return null
445
+ return <div>ok</div>
446
+ }
447
+ `
448
+ const diags = detectPyreonPatterns(code)
449
+ expect(
450
+ diags.filter((d) => d.code === 'static-return-null-conditional'),
451
+ ).toHaveLength(1)
452
+ })
453
+ })
454
+
455
+ describe('as-unknown-as-vnodechild', () => {
456
+ it('flags `expr as unknown as VNodeChild`', () => {
457
+ const code = `
458
+ function Wrapper() {
459
+ return (<div>hi</div> as unknown as VNodeChild)
460
+ }
461
+ `
462
+ const diags = detectPyreonPatterns(code)
463
+ const hits = diags.filter((d) => d.code === 'as-unknown-as-vnodechild')
464
+ expect(hits).toHaveLength(1)
465
+ expect(hits[0]!.message).toContain('JSX.Element')
466
+ })
467
+
468
+ it('does NOT flag a single `as VNodeChild` (no double-cast)', () => {
469
+ const code = `
470
+ function Wrapper() {
471
+ return (something as VNodeChild)
472
+ }
473
+ `
474
+ const diags = detectPyreonPatterns(code)
475
+ expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
476
+ })
477
+
478
+ it('does NOT flag `as unknown as OtherType`', () => {
479
+ const code = `
480
+ const x = (foo as unknown as Whatever)
481
+ `
482
+ const diags = detectPyreonPatterns(code)
483
+ expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
484
+ })
485
+ })
486
+
487
+ describe('island-never-with-registry-entry', () => {
488
+ it('flags a hydrateIslands key matching a hydrate: "never" island declaration', () => {
489
+ const code = `
490
+ import { island } from '@pyreon/server'
491
+ import { hydrateIslands } from '@pyreon/server/client'
492
+ export const StaticBadge = island(() => import('./StaticBadge'), {
493
+ name: 'StaticBadge',
494
+ hydrate: 'never',
495
+ })
496
+ hydrateIslands({
497
+ StaticBadge: () => import('./StaticBadge'),
498
+ })
499
+ `
500
+ const diags = detectPyreonPatterns(code)
501
+ const hits = diags.filter((d) => d.code === 'island-never-with-registry-entry')
502
+ expect(hits).toHaveLength(1)
503
+ expect(hits[0]!.message).toContain('StaticBadge')
504
+ expect(hits[0]!.message).toContain("'never'")
505
+ })
506
+
507
+ it('does NOT flag a hydrateIslands key for a non-never island', () => {
508
+ const code = `
509
+ import { island } from '@pyreon/server'
510
+ import { hydrateIslands } from '@pyreon/server/client'
511
+ export const Counter = island(() => import('./Counter'), {
512
+ name: 'Counter',
513
+ hydrate: 'load',
514
+ })
515
+ hydrateIslands({
516
+ Counter: () => import('./Counter'),
517
+ })
518
+ `
519
+ const diags = detectPyreonPatterns(code)
520
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
521
+ })
522
+
523
+ it('does NOT flag a never-island when no hydrateIslands call appears', () => {
524
+ const code = `
525
+ import { island } from '@pyreon/server'
526
+ export const StaticBadge = island(() => import('./StaticBadge'), {
527
+ name: 'StaticBadge',
528
+ hydrate: 'never',
529
+ })
530
+ `
531
+ const diags = detectPyreonPatterns(code)
532
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
533
+ })
534
+
535
+ it('does NOT flag when hydrateIslands omits never-strategy islands (canonical)', () => {
536
+ const code = `
537
+ import { island } from '@pyreon/server'
538
+ import { hydrateIslands } from '@pyreon/server/client'
539
+ export const Counter = island(() => import('./Counter'), {
540
+ name: 'Counter',
541
+ hydrate: 'load',
542
+ })
543
+ export const StaticBadge = island(() => import('./StaticBadge'), {
544
+ name: 'StaticBadge',
545
+ hydrate: 'never',
546
+ })
547
+ hydrateIslands({
548
+ Counter: () => import('./Counter'),
549
+ // StaticBadge intentionally omitted
550
+ })
551
+ `
552
+ const diags = detectPyreonPatterns(code)
553
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
554
+ })
555
+
556
+ it('flags multiple never-islands registered in the same hydrateIslands call', () => {
557
+ const code = `
558
+ import { island } from '@pyreon/server'
559
+ import { hydrateIslands } from '@pyreon/server/client'
560
+ export const A = island(() => import('./A'), { name: 'A', hydrate: 'never' })
561
+ export const B = island(() => import('./B'), { name: 'B', hydrate: 'never' })
562
+ hydrateIslands({
563
+ A: () => import('./A'),
564
+ B: () => import('./B'),
565
+ })
566
+ `
567
+ const diags = detectPyreonPatterns(code)
568
+ const hits = diags.filter((d) => d.code === 'island-never-with-registry-entry')
569
+ expect(hits).toHaveLength(2)
570
+ expect(hits.map((h) => h.message).join('|')).toContain('"A"')
571
+ expect(hits.map((h) => h.message).join('|')).toContain('"B"')
572
+ })
573
+
574
+ it('handles string-literal property keys in hydrateIslands', () => {
575
+ const code = `
576
+ import { island } from '@pyreon/server'
577
+ import { hydrateIslands } from '@pyreon/server/client'
578
+ export const X = island(() => import('./X'), { name: 'X', hydrate: 'never' })
579
+ hydrateIslands({
580
+ 'X': () => import('./X'),
581
+ })
582
+ `
583
+ const diags = detectPyreonPatterns(code)
584
+ expect(
585
+ diags.filter((d) => d.code === 'island-never-with-registry-entry'),
586
+ ).toHaveLength(1)
587
+ })
588
+
589
+ it('does NOT flag non-string `hydrate` values (variable indirection)', () => {
590
+ const code = `
591
+ import { island } from '@pyreon/server'
592
+ import { hydrateIslands } from '@pyreon/server/client'
593
+ const STRATEGY = 'never'
594
+ export const X = island(() => import('./X'), { name: 'X', hydrate: STRATEGY })
595
+ hydrateIslands({
596
+ X: () => import('./X'),
597
+ })
598
+ `
599
+ // The detector intentionally only recognizes string-literal hydrate
600
+ // values — variable indirection takes us past the static-detection
601
+ // surface into pyreon doctor --check-islands territory.
602
+ const diags = detectPyreonPatterns(code)
603
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
604
+ })
605
+
606
+ it('reports `fixable: false` (no auto-fix; manual edit required)', () => {
607
+ const code = `
608
+ import { island } from '@pyreon/server'
609
+ import { hydrateIslands } from '@pyreon/server/client'
610
+ export const X = island(() => import('./X'), { name: 'X', hydrate: 'never' })
611
+ hydrateIslands({
612
+ X: () => import('./X'),
613
+ })
614
+ `
615
+ const diags = detectPyreonPatterns(code)
616
+ const hit = diags.find((d) => d.code === 'island-never-with-registry-entry')
617
+ expect(hit).toBeDefined()
618
+ expect(hit!.fixable).toBe(false)
619
+ })
620
+
621
+ it('hasPyreonPatterns regex pre-filter recognizes the never-strategy form', () => {
622
+ expect(
623
+ hasPyreonPatterns(`island(() => import('./X'), { name: 'X', hydrate: 'never' })`),
624
+ ).toBe(true)
625
+ })
626
+ })
331
627
  })
@@ -0,0 +1,159 @@
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
+ })
@@ -0,0 +1,138 @@
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
+ })