@pyreon/compiler 0.13.1 → 0.15.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 +14 -4
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1330 -409
- package/lib/types/index.d.ts +152 -14
- package/package.json +12 -1
- package/src/event-names.ts +65 -0
- package/src/index.ts +10 -1
- package/src/jsx.ts +974 -784
- package/src/pyreon-intercept.ts +728 -0
- package/src/test-audit.ts +435 -0
- package/src/tests/depth-stress.test.ts +16 -0
- package/src/tests/detector-tag-consistency.test.ts +86 -0
- package/src/tests/jsx.test.ts +1170 -4
- package/src/tests/native-equivalence.test.ts +731 -0
- package/src/tests/project-scanner.test.ts +30 -0
- package/src/tests/pyreon-intercept.test.ts +486 -0
- package/src/tests/react-intercept.test.ts +354 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/src/tests/test-audit.test.ts +549 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -700,3 +700,357 @@ describe('diagnoseError', () => {
|
|
|
700
700
|
expect(diagnoseError('TypeError: Cannot freeze')).toBeNull()
|
|
701
701
|
})
|
|
702
702
|
})
|
|
703
|
+
|
|
704
|
+
// ─── Additional branch coverage for react-intercept ─────────────────────────
|
|
705
|
+
|
|
706
|
+
describe('detectReactPatterns — edge cases for branch coverage', () => {
|
|
707
|
+
test('useState with no arguments', () => {
|
|
708
|
+
const result = detectReactPatterns(`
|
|
709
|
+
import { useState } from 'react'
|
|
710
|
+
function App() {
|
|
711
|
+
const [value, setValue] = useState()
|
|
712
|
+
return <div>{value}</div>
|
|
713
|
+
}
|
|
714
|
+
`)
|
|
715
|
+
expect(result.length).toBeGreaterThan(0)
|
|
716
|
+
const useStateDiag = result.find((d) => d.code === 'use-state')
|
|
717
|
+
expect(useStateDiag).toBeDefined()
|
|
718
|
+
// No argument → init defaults to 'undefined'
|
|
719
|
+
expect(useStateDiag!.suggested).toContain('signal(undefined)')
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
test('useState not destructured (bare call)', () => {
|
|
723
|
+
const result = detectReactPatterns(`
|
|
724
|
+
import { useState } from 'react'
|
|
725
|
+
function App() {
|
|
726
|
+
const state = useState(0)
|
|
727
|
+
return <div>{state[0]}</div>
|
|
728
|
+
}
|
|
729
|
+
`)
|
|
730
|
+
expect(result.length).toBeGreaterThan(0)
|
|
731
|
+
const useStateDiag = result.find((d) => d.code === 'use-state')
|
|
732
|
+
expect(useStateDiag).toBeDefined()
|
|
733
|
+
// Non-destructured useState → generic suggestion
|
|
734
|
+
expect(useStateDiag!.suggested).toContain('signal(initialValue)')
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
test('useEffect with empty deps but no callback', () => {
|
|
738
|
+
const result = detectReactPatterns(`
|
|
739
|
+
import { useEffect } from 'react'
|
|
740
|
+
function App() {
|
|
741
|
+
useEffect(undefined, [])
|
|
742
|
+
return <div />
|
|
743
|
+
}
|
|
744
|
+
`)
|
|
745
|
+
// Should not crash, may or may not detect
|
|
746
|
+
expect(result).toBeDefined()
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
test('non-react import not flagged', () => {
|
|
750
|
+
const result = detectReactPatterns(`
|
|
751
|
+
import { signal } from '@pyreon/reactivity'
|
|
752
|
+
function App() {
|
|
753
|
+
const x = signal(0)
|
|
754
|
+
return <div>{x()}</div>
|
|
755
|
+
}
|
|
756
|
+
`)
|
|
757
|
+
expect(result).toHaveLength(0)
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
test('useEffect with arrow expression body (no block)', () => {
|
|
761
|
+
const result = detectReactPatterns(`
|
|
762
|
+
import { useEffect } from 'react'
|
|
763
|
+
function App() {
|
|
764
|
+
useEffect(() => console.log('hi'), [])
|
|
765
|
+
return <div />
|
|
766
|
+
}
|
|
767
|
+
`)
|
|
768
|
+
const effectDiag = result.find((d) => d.code === 'use-effect-mount')
|
|
769
|
+
expect(effectDiag).toBeDefined()
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
test('useEffect with non-function callback', () => {
|
|
773
|
+
const result = detectReactPatterns(`
|
|
774
|
+
import { useEffect } from 'react'
|
|
775
|
+
function App() {
|
|
776
|
+
useEffect(handler, [])
|
|
777
|
+
return <div />
|
|
778
|
+
}
|
|
779
|
+
`)
|
|
780
|
+
const effectDiag = result.find((d) => d.code === 'use-effect-mount')
|
|
781
|
+
expect(effectDiag).toBeDefined()
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
test('useEffect with deps array (non-empty)', () => {
|
|
785
|
+
const result = detectReactPatterns(`
|
|
786
|
+
import { useEffect } from 'react'
|
|
787
|
+
function App() {
|
|
788
|
+
useEffect(() => { console.log(x) }, [x])
|
|
789
|
+
return <div />
|
|
790
|
+
}
|
|
791
|
+
`)
|
|
792
|
+
const effectDiag = result.find((d) => d.code === 'use-effect-deps')
|
|
793
|
+
expect(effectDiag).toBeDefined()
|
|
794
|
+
})
|
|
795
|
+
|
|
796
|
+
test('useEffect with no deps array', () => {
|
|
797
|
+
const result = detectReactPatterns(`
|
|
798
|
+
import { useEffect } from 'react'
|
|
799
|
+
function App() {
|
|
800
|
+
useEffect(() => { console.log('every render') })
|
|
801
|
+
return <div />
|
|
802
|
+
}
|
|
803
|
+
`)
|
|
804
|
+
const effectDiag = result.find((d) => d.code === 'use-effect-no-deps')
|
|
805
|
+
expect(effectDiag).toBeDefined()
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
test('useMemo with no compute function arg', () => {
|
|
809
|
+
const result = detectReactPatterns(`
|
|
810
|
+
import { useMemo } from 'react'
|
|
811
|
+
function App() {
|
|
812
|
+
const x = useMemo()
|
|
813
|
+
return <div>{x}</div>
|
|
814
|
+
}
|
|
815
|
+
`)
|
|
816
|
+
const memoDiag = result.find((d) => d.code === 'use-memo')
|
|
817
|
+
expect(memoDiag).toBeDefined()
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
test('useCallback with no callback function arg', () => {
|
|
821
|
+
const result = detectReactPatterns(`
|
|
822
|
+
import { useCallback } from 'react'
|
|
823
|
+
function App() {
|
|
824
|
+
const fn = useCallback()
|
|
825
|
+
return <div onClick={fn}>click</div>
|
|
826
|
+
}
|
|
827
|
+
`)
|
|
828
|
+
const cbDiag = result.find((d) => d.code === 'use-callback')
|
|
829
|
+
expect(cbDiag).toBeDefined()
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
test('useRef with no argument (null ref)', () => {
|
|
833
|
+
const result = detectReactPatterns(`
|
|
834
|
+
import { useRef } from 'react'
|
|
835
|
+
function App() {
|
|
836
|
+
const ref = useRef()
|
|
837
|
+
return <div ref={ref}>text</div>
|
|
838
|
+
}
|
|
839
|
+
`)
|
|
840
|
+
const refDiag = result.find((d) => d.code === 'use-ref-dom' || d.code === 'use-ref-box')
|
|
841
|
+
expect(refDiag).toBeDefined()
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
test('memo() with no argument', () => {
|
|
845
|
+
const result = detectReactPatterns(`
|
|
846
|
+
import { memo } from 'react'
|
|
847
|
+
const App = memo()
|
|
848
|
+
`)
|
|
849
|
+
const memoDiag = result.find((d) => d.code === 'memo-wrapper')
|
|
850
|
+
expect(memoDiag).toBeDefined()
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
test('array.map in JSX detected', () => {
|
|
854
|
+
const result = detectReactPatterns(`
|
|
855
|
+
function App() {
|
|
856
|
+
return <ul>{items.map(item => <li>{item}</li>)}</ul>
|
|
857
|
+
}
|
|
858
|
+
`)
|
|
859
|
+
const mapDiag = result.find((d) => d.code === 'array-map-jsx')
|
|
860
|
+
expect(mapDiag).toBeDefined()
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
test('array.map in JSX with no callback argument', () => {
|
|
864
|
+
const result = detectReactPatterns(`
|
|
865
|
+
function App() {
|
|
866
|
+
return <ul>{items.map()}</ul>
|
|
867
|
+
}
|
|
868
|
+
`)
|
|
869
|
+
const mapDiag = result.find((d) => d.code === 'array-map-jsx')
|
|
870
|
+
expect(mapDiag).toBeDefined()
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
test('react-router-dom import detected', () => {
|
|
874
|
+
const result = detectReactPatterns(`
|
|
875
|
+
import { useNavigate, useParams } from 'react-router-dom'
|
|
876
|
+
`)
|
|
877
|
+
expect(result.some((d) => d.code === 'react-router-import')).toBe(true)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('react-dom/client import detected', () => {
|
|
881
|
+
const result = detectReactPatterns(`
|
|
882
|
+
import { createRoot } from 'react-dom/client'
|
|
883
|
+
`)
|
|
884
|
+
expect(result.some((d) => d.code === 'react-dom-import')).toBe(true)
|
|
885
|
+
})
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
describe('migrateReactCode — edge cases for branch coverage', () => {
|
|
889
|
+
test('migrates useState without arguments', () => {
|
|
890
|
+
const result = migrateReactCode(`
|
|
891
|
+
import { useState } from 'react'
|
|
892
|
+
function App() {
|
|
893
|
+
const [count, setCount] = useState()
|
|
894
|
+
return <div>{count}</div>
|
|
895
|
+
}
|
|
896
|
+
`)
|
|
897
|
+
expect(result.code).toContain('signal(undefined)')
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
test('migrates code with no existing imports (inserts at top)', () => {
|
|
901
|
+
const result = migrateReactCode(`const [x, setX] = useState(0)`)
|
|
902
|
+
expect(result.code).toBeDefined()
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
test('dangerouslySetInnerHTML without expression', () => {
|
|
906
|
+
const result = migrateReactCode(`
|
|
907
|
+
import React from 'react'
|
|
908
|
+
function App() {
|
|
909
|
+
return <div dangerouslySetInnerHTML />
|
|
910
|
+
}
|
|
911
|
+
`)
|
|
912
|
+
// Should not crash, attr without value
|
|
913
|
+
expect(result.code).toBeDefined()
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
test('dangerouslySetInnerHTML with non-object expression', () => {
|
|
917
|
+
const result = migrateReactCode(`
|
|
918
|
+
import React from 'react'
|
|
919
|
+
function App() {
|
|
920
|
+
return <div dangerouslySetInnerHTML={getHtml()} />
|
|
921
|
+
}
|
|
922
|
+
`)
|
|
923
|
+
// Non-object expression → not migrated
|
|
924
|
+
expect(result.code).toBeDefined()
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
test('className attribute migrated to class', () => {
|
|
928
|
+
const result = migrateReactCode(`
|
|
929
|
+
import React from 'react'
|
|
930
|
+
function App() {
|
|
931
|
+
return <div className="foo">text</div>
|
|
932
|
+
}
|
|
933
|
+
`)
|
|
934
|
+
expect(result.code).toContain('class=')
|
|
935
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
test('source file with import from non-react module not rewritten', () => {
|
|
939
|
+
const result = migrateReactCode(`
|
|
940
|
+
import { signal } from '@pyreon/reactivity'
|
|
941
|
+
const x = signal(0)
|
|
942
|
+
`)
|
|
943
|
+
expect(result.changes).toHaveLength(0)
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
test('migrates useRef with non-null initial value (mutable box)', () => {
|
|
947
|
+
const result = migrateReactCode(`
|
|
948
|
+
import { useRef } from 'react'
|
|
949
|
+
function App() {
|
|
950
|
+
const ref = useRef(42)
|
|
951
|
+
return <div>{ref.current}</div>
|
|
952
|
+
}
|
|
953
|
+
`)
|
|
954
|
+
expect(result.code).toContain('signal(42)')
|
|
955
|
+
})
|
|
956
|
+
|
|
957
|
+
test('migrates useMemo', () => {
|
|
958
|
+
const result = migrateReactCode(`
|
|
959
|
+
import { useMemo } from 'react'
|
|
960
|
+
function App() {
|
|
961
|
+
const doubled = useMemo(() => count * 2, [count])
|
|
962
|
+
return <div>{doubled}</div>
|
|
963
|
+
}
|
|
964
|
+
`)
|
|
965
|
+
expect(result.code).toContain('computed(')
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
test('migrates useCallback', () => {
|
|
969
|
+
const result = migrateReactCode(`
|
|
970
|
+
import { useCallback } from 'react'
|
|
971
|
+
function App() {
|
|
972
|
+
const handleClick = useCallback(() => console.log('click'), [])
|
|
973
|
+
return <button onClick={handleClick}>click</button>
|
|
974
|
+
}
|
|
975
|
+
`)
|
|
976
|
+
// useCallback → plain function (not needed in Pyreon)
|
|
977
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
test('migrates React.memo wrapper', () => {
|
|
981
|
+
const result = migrateReactCode(`
|
|
982
|
+
import React from 'react'
|
|
983
|
+
const App = React.memo(function App() {
|
|
984
|
+
return <div>hello</div>
|
|
985
|
+
})
|
|
986
|
+
`)
|
|
987
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
test('migrates standalone memo() wrapper', () => {
|
|
991
|
+
const result = migrateReactCode(`
|
|
992
|
+
import { memo } from 'react'
|
|
993
|
+
const App = memo(function App() {
|
|
994
|
+
return <div>hello</div>
|
|
995
|
+
})
|
|
996
|
+
`)
|
|
997
|
+
expect(result.changes.length).toBeGreaterThan(0)
|
|
998
|
+
})
|
|
999
|
+
|
|
1000
|
+
test('migrates code with no existing imports (inserts at beginning)', () => {
|
|
1001
|
+
// No import statement in the source — lastImportEnd === 0 → line 926 false branch
|
|
1002
|
+
const result = migrateReactCode(`
|
|
1003
|
+
const [count, setCount] = useState(0)
|
|
1004
|
+
const x = useMemo(() => count * 2)
|
|
1005
|
+
`)
|
|
1006
|
+
// Should insert pyreon imports at the top
|
|
1007
|
+
expect(result.code).toContain('import {')
|
|
1008
|
+
expect(result.code).toContain('@pyreon/')
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
test('migrates dangerouslySetInnerHTML with __html property', () => {
|
|
1012
|
+
const result = migrateReactCode(`
|
|
1013
|
+
import React from 'react'
|
|
1014
|
+
function App() {
|
|
1015
|
+
return <div dangerouslySetInnerHTML={{ __html: '<b>bold</b>' }} />
|
|
1016
|
+
}
|
|
1017
|
+
`)
|
|
1018
|
+
expect(result.code).toContain('innerHTML')
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
test('migrates className on JSX elements', () => {
|
|
1022
|
+
const result = migrateReactCode(`
|
|
1023
|
+
import React from 'react'
|
|
1024
|
+
function App() {
|
|
1025
|
+
return <div className="foo"><span className="bar">text</span></div>
|
|
1026
|
+
}
|
|
1027
|
+
`)
|
|
1028
|
+
expect(result.code).toContain('class=')
|
|
1029
|
+
expect(result.changes.length).toBeGreaterThanOrEqual(2)
|
|
1030
|
+
})
|
|
1031
|
+
|
|
1032
|
+
test('migrates htmlFor on label elements', () => {
|
|
1033
|
+
const result = migrateReactCode(`
|
|
1034
|
+
import React from 'react'
|
|
1035
|
+
function App() {
|
|
1036
|
+
return <label htmlFor="name">Name</label>
|
|
1037
|
+
}
|
|
1038
|
+
`)
|
|
1039
|
+
expect(result.code).toContain('for=')
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
test('migrates useEffect with cleanup function', () => {
|
|
1043
|
+
const result = migrateReactCode(`
|
|
1044
|
+
import { useEffect } from 'react'
|
|
1045
|
+
function App() {
|
|
1046
|
+
useEffect(() => {
|
|
1047
|
+
const handler = () => {}
|
|
1048
|
+
window.addEventListener('resize', handler)
|
|
1049
|
+
return () => window.removeEventListener('resize', handler)
|
|
1050
|
+
}, [])
|
|
1051
|
+
return <div />
|
|
1052
|
+
}
|
|
1053
|
+
`)
|
|
1054
|
+
expect(result.code).toContain('onMount')
|
|
1055
|
+
})
|
|
1056
|
+
})
|
|
@@ -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
|
+
})
|