@barefootjs/jsx 0.5.1 → 0.5.3
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/dist/analyzer-context.d.ts +8 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/combine-client-js.d.ts.map +1 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +239 -65
- package/dist/ir-to-client-js/collect-elements.d.ts +31 -9
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +26 -4
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +19 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
- package/src/__tests__/child-components-in-map.test.ts +376 -0
- package/src/__tests__/combine-client-js.test.ts +47 -0
- package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
- package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
- package/src/__tests__/text-slot-escaping.test.ts +56 -0
- package/src/analyzer-context.ts +59 -13
- package/src/analyzer.ts +8 -0
- package/src/combine-client-js.ts +66 -22
- package/src/expression-parser.ts +16 -1
- package/src/index.ts +2 -0
- package/src/ir-to-client-js/collect-elements.ts +191 -34
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
- package/src/ir-to-client-js/emit-reactive.ts +9 -0
- package/src/ir-to-client-js/html-template.ts +82 -10
- package/src/ir-to-client-js/imports.ts +1 -1
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
- package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
- package/src/ir-to-client-js/types.ts +27 -4
- package/src/ir-to-client-js/utils.ts +41 -1
- package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
- package/src/types.ts +6 -0
|
@@ -9,6 +9,14 @@ import { HonoAdapter } from '../../../../packages/adapter-hono/src/adapter/hono-
|
|
|
9
9
|
|
|
10
10
|
const adapter = new TestAdapter()
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Collapse all whitespace so offset assertions match the generated index
|
|
14
|
+
* math regardless of the printer's spacing inside array literals
|
|
15
|
+
* (`['a','b']` vs `['a', 'b']`) — the test asserts the offset logic, not
|
|
16
|
+
* formatting.
|
|
17
|
+
*/
|
|
18
|
+
const noWs = (s: string) => s.replace(/\s+/g, '')
|
|
19
|
+
|
|
12
20
|
describe('child components inside .map() (#344)', () => {
|
|
13
21
|
test('static array: nested component inside element wrapper generates initChild', () => {
|
|
14
22
|
const source = `
|
|
@@ -730,6 +738,374 @@ describe('child components inside .map() (#344)', () => {
|
|
|
730
738
|
expect(content).not.toContain('children[__idx + ')
|
|
731
739
|
})
|
|
732
740
|
|
|
741
|
+
test('static array inside a component container with a preceding static sibling uses siblingOffset (#1688)', () => {
|
|
742
|
+
// The loop is a direct child of the <Portaled> component (not an
|
|
743
|
+
// element), with a static <span> sibling before it. Before #1688
|
|
744
|
+
// computeLoopSiblingOffsets only counted siblings under element
|
|
745
|
+
// parents, so the offset was silently zero and the first item's
|
|
746
|
+
// nested child component (Counter) was resolved against the wrong
|
|
747
|
+
// children[idx] — dropping it during hydration.
|
|
748
|
+
const source = `
|
|
749
|
+
'use client'
|
|
750
|
+
|
|
751
|
+
function Portaled(props: { children?: any }) {
|
|
752
|
+
return <div>{props.children}</div>
|
|
753
|
+
}
|
|
754
|
+
function Wrapper(props: { children?: any }) {
|
|
755
|
+
return <div class="wrapper">{props.children}</div>
|
|
756
|
+
}
|
|
757
|
+
function Counter(props: { id: string }) {
|
|
758
|
+
const [n, setN] = createSignal(0)
|
|
759
|
+
return <button data-testid={props.id} onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
760
|
+
}
|
|
761
|
+
export function Repro() {
|
|
762
|
+
return (
|
|
763
|
+
<Portaled>
|
|
764
|
+
<span>static sibling</span>
|
|
765
|
+
{['a', 'b'].map(id => (
|
|
766
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
767
|
+
))}
|
|
768
|
+
</Portaled>
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
`
|
|
772
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
773
|
+
expect(result.errors).toHaveLength(0)
|
|
774
|
+
|
|
775
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
776
|
+
expect(clientJs).toBeDefined()
|
|
777
|
+
const content = clientJs!.content
|
|
778
|
+
|
|
779
|
+
// The nested Counter lookup must skip the preceding static <span>.
|
|
780
|
+
expect(content).toContain('children[__idx + 1]')
|
|
781
|
+
expect(content).not.toContain('children[__idx]')
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
test('a transparent fragment wrapping a .map() inherits the parent container preceding siblings (#1699)', () => {
|
|
785
|
+
// A fragment renders no DOM element wrapper, so a loop inside `<>…</>` is a
|
|
786
|
+
// direct sibling of the fragment's siblings in the real parent element.
|
|
787
|
+
// The offset must skip the two <hr/>s that precede the fragment in <Box>,
|
|
788
|
+
// not reset to the fragment's own (empty) preceding run.
|
|
789
|
+
const source = `
|
|
790
|
+
'use client'
|
|
791
|
+
|
|
792
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
793
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
794
|
+
function Counter(props: { id: string }) {
|
|
795
|
+
const [n, setN] = createSignal(0)
|
|
796
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
797
|
+
}
|
|
798
|
+
export function FragGroup() {
|
|
799
|
+
return (
|
|
800
|
+
<Box>
|
|
801
|
+
<hr />
|
|
802
|
+
<hr />
|
|
803
|
+
<>
|
|
804
|
+
{['a', 'b'].map(id => (
|
|
805
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
806
|
+
))}
|
|
807
|
+
</>
|
|
808
|
+
</Box>
|
|
809
|
+
)
|
|
810
|
+
}
|
|
811
|
+
`
|
|
812
|
+
const result = compileJSX(source, 'FragGroup.tsx', { adapter })
|
|
813
|
+
expect(result.errors).toHaveLength(0)
|
|
814
|
+
|
|
815
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
816
|
+
// Items start past both <hr/>s.
|
|
817
|
+
expect(content).toContain('children[__idx + 2]')
|
|
818
|
+
expect(content).not.toContain('children[__idx]')
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
test('a static sibling inside the fragment still counts toward the offset (#1699 regression guard)', () => {
|
|
822
|
+
// The fragment's OWN preceding children must keep counting too — the
|
|
823
|
+
// parent-inheriting flatten must not drop the fragment-internal <span>.
|
|
824
|
+
const source = `
|
|
825
|
+
'use client'
|
|
826
|
+
|
|
827
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
828
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
829
|
+
function Counter(props: { id: string }) {
|
|
830
|
+
const [n, setN] = createSignal(0)
|
|
831
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
832
|
+
}
|
|
833
|
+
export function MixedFrag() {
|
|
834
|
+
return (
|
|
835
|
+
<Box>
|
|
836
|
+
<hr />
|
|
837
|
+
<>
|
|
838
|
+
<span>label</span>
|
|
839
|
+
{['a', 'b'].map(id => (
|
|
840
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
841
|
+
))}
|
|
842
|
+
</>
|
|
843
|
+
</Box>
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
`
|
|
847
|
+
const result = compileJSX(source, 'MixedFrag.tsx', { adapter })
|
|
848
|
+
expect(result.errors).toHaveLength(0)
|
|
849
|
+
|
|
850
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
851
|
+
// 1 (parent <hr/>) + 1 (fragment-internal <span>) = 2.
|
|
852
|
+
expect(content).toContain('children[__idx + 2]')
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
test('two static + .map() groups inside a component: 2nd group offset skips the 1st group items (#1693)', () => {
|
|
856
|
+
// Follow-up to #1688. With two `<span/> + {arr.map(...)}` groups inside a
|
|
857
|
+
// self-portaling component, the second group's nested child components
|
|
858
|
+
// must be resolved past BOTH the static <span>s AND the first group's
|
|
859
|
+
// mapped items. The static-only offset (#1688) under-counted by the first
|
|
860
|
+
// array's length, leaving the second group inert after hydration.
|
|
861
|
+
const source = `
|
|
862
|
+
'use client'
|
|
863
|
+
|
|
864
|
+
function Box(props: { children?: any }) {
|
|
865
|
+
return <div>{props.children}</div>
|
|
866
|
+
}
|
|
867
|
+
function Wrapper(props: { children?: any }) {
|
|
868
|
+
return <div class="wrapper">{props.children}</div>
|
|
869
|
+
}
|
|
870
|
+
function Counter(props: { id: string }) {
|
|
871
|
+
const [n, setN] = createSignal(0)
|
|
872
|
+
return <button data-testid={props.id} onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
873
|
+
}
|
|
874
|
+
export function TwoGroups() {
|
|
875
|
+
return (
|
|
876
|
+
<Box>
|
|
877
|
+
<span>group 1</span>
|
|
878
|
+
{['a', 'b'].map(id => (
|
|
879
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
880
|
+
))}
|
|
881
|
+
<span>group 2</span>
|
|
882
|
+
{['c', 'd'].map(id => (
|
|
883
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
884
|
+
))}
|
|
885
|
+
</Box>
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
`
|
|
889
|
+
const result = compileJSX(source, 'TwoGroups.tsx', { adapter })
|
|
890
|
+
expect(result.errors).toHaveLength(0)
|
|
891
|
+
|
|
892
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
893
|
+
expect(clientJs).toBeDefined()
|
|
894
|
+
const content = clientJs!.content
|
|
895
|
+
|
|
896
|
+
// First group: one preceding static <span> → `+ 1`.
|
|
897
|
+
expect(content).toContain('children[__idx + 1]')
|
|
898
|
+
// Second group: two static <span>s plus the first group's mapped items →
|
|
899
|
+
// the runtime length of the first array is added to the static count.
|
|
900
|
+
expect(noWs(content)).toContain(noWs("children[__idx + 2 + (['a', 'b']).length]"))
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
test('two consecutive pure .map()s inside a component: 2nd loop offset is the 1st array length (#1693)', () => {
|
|
904
|
+
// No static siblings: the second loop's items still start after the first
|
|
905
|
+
// loop's items, so the offset is purely the first array's runtime length.
|
|
906
|
+
const source = `
|
|
907
|
+
'use client'
|
|
908
|
+
|
|
909
|
+
function Box(props: { children?: any }) {
|
|
910
|
+
return <div>{props.children}</div>
|
|
911
|
+
}
|
|
912
|
+
function Wrapper(props: { children?: any }) {
|
|
913
|
+
return <div class="wrapper">{props.children}</div>
|
|
914
|
+
}
|
|
915
|
+
function Counter(props: { id: string }) {
|
|
916
|
+
const [n, setN] = createSignal(0)
|
|
917
|
+
return <button data-testid={props.id} onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
918
|
+
}
|
|
919
|
+
export function TwoLoops() {
|
|
920
|
+
return (
|
|
921
|
+
<Box>
|
|
922
|
+
{['a', 'b'].map(id => (
|
|
923
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
924
|
+
))}
|
|
925
|
+
{['c', 'd'].map(id => (
|
|
926
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
927
|
+
))}
|
|
928
|
+
</Box>
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
`
|
|
932
|
+
const result = compileJSX(source, 'TwoLoops.tsx', { adapter })
|
|
933
|
+
expect(result.errors).toHaveLength(0)
|
|
934
|
+
|
|
935
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
936
|
+
// First loop: no preceding siblings → bare access.
|
|
937
|
+
expect(content).toContain('children[__idx]')
|
|
938
|
+
// Second loop: offset is the first array's runtime length, no static term.
|
|
939
|
+
expect(noWs(content)).toContain(noWs("children[__idx + (['a', 'b']).length]"))
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
test('conditional (&&) sibling before a static-array loop adds a runtime ternary offset (#1693)', () => {
|
|
943
|
+
// A `{cond && <span/>}` sibling renders 1 element when true but ZERO
|
|
944
|
+
// elements (only comment anchors) when false. Counting it as a static
|
|
945
|
+
// `1` over-counts the false case, so the loop's nested children resolve
|
|
946
|
+
// against the wrong `children[idx]`. The offset must be a runtime
|
|
947
|
+
// `(cond ? 1 : 0)` term that collapses to 0 when the branch is absent.
|
|
948
|
+
const source = `
|
|
949
|
+
'use client'
|
|
950
|
+
|
|
951
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
952
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
953
|
+
function Counter(props: { id: string }) {
|
|
954
|
+
const [n, setN] = createSignal(0)
|
|
955
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
956
|
+
}
|
|
957
|
+
export function CondGroup(props: { show: boolean }) {
|
|
958
|
+
return (
|
|
959
|
+
<Box>
|
|
960
|
+
{props.show && <span>maybe</span>}
|
|
961
|
+
{['a', 'b'].map(id => (
|
|
962
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
963
|
+
))}
|
|
964
|
+
</Box>
|
|
965
|
+
)
|
|
966
|
+
}
|
|
967
|
+
`
|
|
968
|
+
const result = compileJSX(source, 'CondGroup.tsx', { adapter })
|
|
969
|
+
expect(result.errors).toHaveLength(0)
|
|
970
|
+
|
|
971
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
972
|
+
// Runtime ternary — `0` when the conditional renders no element. The
|
|
973
|
+
// condition reuses the same `_p.show` form `insert()` evaluates.
|
|
974
|
+
expect(content).toContain('children[__idx + (_p.show ? 1 : 0)]')
|
|
975
|
+
// Must NOT mis-count the conditional as a static sibling.
|
|
976
|
+
expect(content).not.toContain('children[__idx + 1]')
|
|
977
|
+
})
|
|
978
|
+
|
|
979
|
+
test('ternary sibling with an element in both branches keeps a static offset (#1693)', () => {
|
|
980
|
+
// `{cond ? <a/> : <b/>}` always renders exactly one element, so both
|
|
981
|
+
// branch counts are equal and the offset folds to a static `+ 1` — no
|
|
982
|
+
// runtime ternary needed.
|
|
983
|
+
const source = `
|
|
984
|
+
'use client'
|
|
985
|
+
|
|
986
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
987
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
988
|
+
function Counter(props: { id: string }) {
|
|
989
|
+
const [n, setN] = createSignal(0)
|
|
990
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
991
|
+
}
|
|
992
|
+
export function TernaryGroup(props: { show: boolean }) {
|
|
993
|
+
return (
|
|
994
|
+
<Box>
|
|
995
|
+
{props.show ? <span>a</span> : <em>b</em>}
|
|
996
|
+
{['a', 'b'].map(id => (
|
|
997
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
998
|
+
))}
|
|
999
|
+
</Box>
|
|
1000
|
+
)
|
|
1001
|
+
}
|
|
1002
|
+
`
|
|
1003
|
+
const result = compileJSX(source, 'TernaryGroup.tsx', { adapter })
|
|
1004
|
+
expect(result.errors).toHaveLength(0)
|
|
1005
|
+
|
|
1006
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
1007
|
+
expect(content).toContain('children[__idx + 1]')
|
|
1008
|
+
expect(content).not.toContain('? 1 : 0')
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
test('non-element (text) sibling before a loop produces no offset (#1693)', () => {
|
|
1012
|
+
// A bare text node is NOT in `.children` (element-only), so it must
|
|
1013
|
+
// contribute 0 to the offset — not be counted as a static sibling.
|
|
1014
|
+
const source = `
|
|
1015
|
+
'use client'
|
|
1016
|
+
|
|
1017
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
1018
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
1019
|
+
function Counter(props: { id: string }) {
|
|
1020
|
+
const [n, setN] = createSignal(0)
|
|
1021
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
1022
|
+
}
|
|
1023
|
+
export function TextGroup() {
|
|
1024
|
+
return (
|
|
1025
|
+
<Box>
|
|
1026
|
+
hello
|
|
1027
|
+
{['a', 'b'].map(id => (
|
|
1028
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
1029
|
+
))}
|
|
1030
|
+
</Box>
|
|
1031
|
+
)
|
|
1032
|
+
}
|
|
1033
|
+
`
|
|
1034
|
+
const result = compileJSX(source, 'TextGroup.tsx', { adapter })
|
|
1035
|
+
expect(result.errors).toHaveLength(0)
|
|
1036
|
+
|
|
1037
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
1038
|
+
expect(content).toContain('children[__idx]')
|
|
1039
|
+
expect(content).not.toContain('children[__idx + ')
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
test('nullish/logical (?? / ||) fallback sibling keeps a static offset, not an inverted ternary (#1693)', () => {
|
|
1043
|
+
// `{icon ?? <fallback/>}` transforms to a conditional whose true-branch is
|
|
1044
|
+
// the bare `icon` expression (undecidable element count) and whose false-
|
|
1045
|
+
// branch is the JSX fallback. The element count is statically unknown, so
|
|
1046
|
+
// it must fall back to the legacy flat `1` — NOT emit `(cond ? 0 : 1)`,
|
|
1047
|
+
// which would resolve to 0 and drop the items when `icon` is a real element.
|
|
1048
|
+
const source = `
|
|
1049
|
+
'use client'
|
|
1050
|
+
|
|
1051
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
1052
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
1053
|
+
function Counter(props: { id: string }) {
|
|
1054
|
+
const [n, setN] = createSignal(0)
|
|
1055
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
1056
|
+
}
|
|
1057
|
+
export function NullishGroup(props: { icon?: any }) {
|
|
1058
|
+
return (
|
|
1059
|
+
<Box>
|
|
1060
|
+
{props.icon ?? <span>fallback</span>}
|
|
1061
|
+
{['a', 'b'].map(id => (
|
|
1062
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
1063
|
+
))}
|
|
1064
|
+
</Box>
|
|
1065
|
+
)
|
|
1066
|
+
}
|
|
1067
|
+
`
|
|
1068
|
+
const result = compileJSX(source, 'NullishGroup.tsx', { adapter })
|
|
1069
|
+
expect(result.errors).toHaveLength(0)
|
|
1070
|
+
|
|
1071
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
1072
|
+
expect(content).toContain('children[__idx + 1]')
|
|
1073
|
+
// The inverted-count regression — must not appear.
|
|
1074
|
+
expect(content).not.toContain('? 0 : 1')
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
test('preceding per-item-conditional loop does not contribute a bogus .length offset (#1693)', () => {
|
|
1078
|
+
// A `{arr.map(x => cond ? <el/> : null)}` loop renders 0-or-1 element per
|
|
1079
|
+
// item, so its rendered count is NOT `arr.length`. Emitting `+ arr.length`
|
|
1080
|
+
// would over-count; the undecidable loop falls back to the legacy `0`.
|
|
1081
|
+
const source = `
|
|
1082
|
+
'use client'
|
|
1083
|
+
|
|
1084
|
+
function Box(props: { children?: any }) { return <div>{props.children}</div> }
|
|
1085
|
+
function Wrapper(props: { children?: any }) { return <div>{props.children}</div> }
|
|
1086
|
+
function Counter(props: { id: string }) {
|
|
1087
|
+
const [n, setN] = createSignal(0)
|
|
1088
|
+
return <button onClick={() => setN(v => v + 1)}>{n()}</button>
|
|
1089
|
+
}
|
|
1090
|
+
export function MixedLoops() {
|
|
1091
|
+
return (
|
|
1092
|
+
<Box>
|
|
1093
|
+
{['a', 'b', 'c'].map(x => (x === 'b' ? <span key={x}>{x}</span> : null))}
|
|
1094
|
+
{['a', 'b'].map(id => (
|
|
1095
|
+
<Wrapper key={id}><Counter id={id} /></Wrapper>
|
|
1096
|
+
))}
|
|
1097
|
+
</Box>
|
|
1098
|
+
)
|
|
1099
|
+
}
|
|
1100
|
+
`
|
|
1101
|
+
const result = compileJSX(source, 'MixedLoops.tsx', { adapter })
|
|
1102
|
+
expect(result.errors).toHaveLength(0)
|
|
1103
|
+
|
|
1104
|
+
const content = result.files.find(f => f.type === 'clientJs')!.content
|
|
1105
|
+
// No `.length` term from the undecidable per-item-conditional loop.
|
|
1106
|
+
expect(noWs(content)).not.toContain(noWs("(['a', 'b', 'c']).length"))
|
|
1107
|
+
})
|
|
1108
|
+
|
|
733
1109
|
test('nested .map() with multiple inner components emits unique __compEl bindings (#1664)', () => {
|
|
734
1110
|
const source = `
|
|
735
1111
|
'use client'
|
|
@@ -204,6 +204,53 @@ describe('combineParentChildClientJs', () => {
|
|
|
204
204
|
expect(adminCombined).not.toContain('@bf-child:')
|
|
205
205
|
})
|
|
206
206
|
|
|
207
|
+
test('does not treat import-shaped lines inside a string literal as real imports (#1702)', () => {
|
|
208
|
+
// A data module inlined into the parent exports a code snippet whose
|
|
209
|
+
// *contents* contain a `"use client"` directive and an `import …` line.
|
|
210
|
+
// The combiner must not relocate the parent's real runtime import into
|
|
211
|
+
// that string literal, which previously left `hydrate` undefined.
|
|
212
|
+
const sample = [
|
|
213
|
+
'`"use client"',
|
|
214
|
+
'',
|
|
215
|
+
"import { createSignal } from '@barefootjs/client'",
|
|
216
|
+
'',
|
|
217
|
+
'export function Counter() {}`',
|
|
218
|
+
].join('\n')
|
|
219
|
+
const files = new Map([
|
|
220
|
+
['Parent', [
|
|
221
|
+
"import { hydrate, createSignal } from '@barefootjs/client/runtime'",
|
|
222
|
+
"import '/* @bf-child:Child */'",
|
|
223
|
+
`const __bf_inline_0 = { SAMPLE: ${sample} };`,
|
|
224
|
+
"hydrate('Parent', (el) => {})",
|
|
225
|
+
].join('\n')],
|
|
226
|
+
['Child', [
|
|
227
|
+
"import { hydrate, createSignal } from '@barefootjs/client/runtime'",
|
|
228
|
+
"hydrate('Child', (el) => {})",
|
|
229
|
+
].join('\n')],
|
|
230
|
+
])
|
|
231
|
+
|
|
232
|
+
const result = combineParentChildClientJs(files)
|
|
233
|
+
const combined = result.get('Parent')!
|
|
234
|
+
|
|
235
|
+
// The real runtime import survives as the very first line, with `hydrate`.
|
|
236
|
+
const firstLine = combined.split('\n')[0]
|
|
237
|
+
expect(firstLine).toContain('hydrate')
|
|
238
|
+
expect(firstLine).toContain("from '@barefootjs/client/runtime'")
|
|
239
|
+
// The only real top-level import is the runtime one — the snippet's inner
|
|
240
|
+
// import line must remain *inside* the `__bf_inline_0` declaration, not
|
|
241
|
+
// hoisted above it.
|
|
242
|
+
const inlineIdx = combined.indexOf('const __bf_inline_0')
|
|
243
|
+
const snippetImportIdx = combined.indexOf("import { createSignal } from '@barefootjs/client'")
|
|
244
|
+
expect(snippetImportIdx).toBeGreaterThan(inlineIdx)
|
|
245
|
+
|
|
246
|
+
// The snippet string is preserved verbatim — its inner import line is
|
|
247
|
+
// NOT hoisted out, and `@barefootjs/client` stays intact.
|
|
248
|
+
expect(combined).toContain("import { createSignal } from '@barefootjs/client'")
|
|
249
|
+
expect(combined).toContain('export function Counter() {}')
|
|
250
|
+
expect(combined).toContain("hydrate('Parent',")
|
|
251
|
+
expect(combined).toContain("hydrate('Child',")
|
|
252
|
+
})
|
|
253
|
+
|
|
207
254
|
test('deduplicates imports from shared sources', () => {
|
|
208
255
|
const files = new Map([
|
|
209
256
|
['Parent', [
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side `dangerouslySetInnerHTML` (the raw-HTML escape hatch).
|
|
3
|
+
*
|
|
4
|
+
* The SSR adapters render `dangerouslySetInnerHTML={{ __html: E }}`
|
|
5
|
+
* natively, but the client codegen used to treat it as a generic reactive
|
|
6
|
+
* attribute — emitting a bogus `dangerouslySetInnerHTML="[object Object]"`
|
|
7
|
+
* and never setting `innerHTML`, so a `"use client"` component silently
|
|
8
|
+
* lost the content on the client. These tests pin the corrected emit:
|
|
9
|
+
* - the `{ __html }` object is NEVER serialised as an attribute;
|
|
10
|
+
* - the element's raw HTML is emitted as its content (UNescaped — this
|
|
11
|
+
* is the intentional escape hatch);
|
|
12
|
+
* - a reactive value also drives an `innerHTML` assignment in init.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../compiler'
|
|
17
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
18
|
+
|
|
19
|
+
const adapter = new TestAdapter()
|
|
20
|
+
|
|
21
|
+
function getClientJs(source: string, filename: string): string {
|
|
22
|
+
const result = compileJSX(source, filename, { adapter })
|
|
23
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
24
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
25
|
+
expect(clientJs).toBeDefined()
|
|
26
|
+
return clientJs!.content
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('dangerouslySetInnerHTML (client)', () => {
|
|
30
|
+
test('reactive value: raw content + innerHTML init, no bogus attribute', () => {
|
|
31
|
+
const clientJs = getClientJs(
|
|
32
|
+
`'use client'
|
|
33
|
+
export function Raw({ html }: { html: string }) {
|
|
34
|
+
return <div class="x" dangerouslySetInnerHTML={{ __html: html }} />
|
|
35
|
+
}`,
|
|
36
|
+
'Raw.tsx',
|
|
37
|
+
)
|
|
38
|
+
// Never emitted as an attribute.
|
|
39
|
+
expect(clientJs).not.toMatch(/dangerouslySetInnerHTML="/)
|
|
40
|
+
// Raw `.__html` emitted as element content — not via escapeText.
|
|
41
|
+
expect(clientJs).toMatch(/<div class="x"[^>]*>\$\{.*\.__html.*\}<\/div>/)
|
|
42
|
+
expect(clientJs).not.toMatch(/escapeText\([^)]*__html/)
|
|
43
|
+
// Reactive update assigns innerHTML (not setAttribute).
|
|
44
|
+
expect(clientJs).toMatch(/\.innerHTML = /)
|
|
45
|
+
expect(clientJs).not.toMatch(/setAttribute\('dangerouslySetInnerHTML'/)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('spread + dangerouslySetInnerHTML: kept out of the spreadAttrs merge', () => {
|
|
49
|
+
// An element that also carries a spread switches to the
|
|
50
|
+
// `${spreadAttrs({...})}` merge form; `dangerouslySetInnerHTML` must
|
|
51
|
+
// not be folded into that object (it would serialise as a bogus
|
|
52
|
+
// `dangerouslySetInnerHTML="[object Object]"` attribute).
|
|
53
|
+
const clientJs = getClientJs(
|
|
54
|
+
`'use client'
|
|
55
|
+
export function A({ rest, html }: { rest: Record<string, unknown>; html: string }) {
|
|
56
|
+
return <div {...rest} class="x" dangerouslySetInnerHTML={{ __html: html }} />
|
|
57
|
+
}`,
|
|
58
|
+
'A.tsx',
|
|
59
|
+
)
|
|
60
|
+
expect(clientJs).toContain('spreadAttrs(')
|
|
61
|
+
// The merge object must NOT contain a dangerouslySetInnerHTML key…
|
|
62
|
+
expect(clientJs).not.toMatch(/spreadAttrs\([^)]*dangerouslySetInnerHTML/)
|
|
63
|
+
expect(clientJs).not.toMatch(/dangerouslySetInnerHTML="/)
|
|
64
|
+
// …and the raw HTML is still emitted as the element's content.
|
|
65
|
+
expect(clientJs).toMatch(/<\/div>/)
|
|
66
|
+
expect(clientJs).toMatch(/\.__html/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('static value: raw content emitted in the template (no init needed)', () => {
|
|
70
|
+
const clientJs = getClientJs(
|
|
71
|
+
`'use client'
|
|
72
|
+
export function S() {
|
|
73
|
+
return <div dangerouslySetInnerHTML={{ __html: '<b>hi</b>' }} />
|
|
74
|
+
}`,
|
|
75
|
+
'S.tsx',
|
|
76
|
+
)
|
|
77
|
+
expect(clientJs).not.toMatch(/dangerouslySetInnerHTML="/)
|
|
78
|
+
// The literal HTML survives raw in the template content.
|
|
79
|
+
expect(clientJs).toMatch(/<div[^>]*>\$\{.*__html.*\}<\/div>/)
|
|
80
|
+
expect(clientJs).not.toMatch(/escapeText\([^)]*__html/)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -203,8 +203,9 @@ describe('#1247 — static-loop CSR self-heal', () => {
|
|
|
203
203
|
// the inner `it` param).
|
|
204
204
|
expect(clientJs).toMatch(/items\.map\(\(it, i\) =>/)
|
|
205
205
|
// The inner per-item HTML references the inner destructured param
|
|
206
|
-
// directly (`${it}` in the template
|
|
207
|
-
|
|
206
|
+
// directly (`${escapeText(it)}` in the template — text slots are
|
|
207
|
+
// HTML-escaped, #1694), not via a `__bfItem` accessor.
|
|
208
|
+
expect(clientJs).toMatch(/\$\{escapeText\(it\)\}/)
|
|
208
209
|
expect(clientJs).not.toMatch(/__bfItem\(\)/)
|
|
209
210
|
})
|
|
210
211
|
|
|
@@ -335,7 +336,8 @@ describe('#1247 — static-loop CSR self-heal', () => {
|
|
|
335
336
|
// Preamble is NOT duplicated inside the materialize branch.
|
|
336
337
|
expect(materializeBlock).not.toMatch(/const count = users\.length/)
|
|
337
338
|
// The cloned template still references `count` — now resolved by the
|
|
338
|
-
// forEach-scope `const` introduced just above.
|
|
339
|
-
|
|
339
|
+
// forEach-scope `const` introduced just above. Text slots are
|
|
340
|
+
// HTML-escaped (#1694), so it appears as `${escapeText(String(count))}`.
|
|
341
|
+
expect(materializeBlock).toMatch(/\$\{escapeText\(String\(count\)\)\}/)
|
|
340
342
|
})
|
|
341
343
|
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-slot HTML-escaping emit shape (#1694 + follow-up).
|
|
3
|
+
*
|
|
4
|
+
* Pins which interpolations the client template wraps in `escapeText`:
|
|
5
|
+
* - a plain text slot (`{stringValue}`) IS escaped — it becomes the
|
|
6
|
+
* slot's text content under `innerHTML`;
|
|
7
|
+
* - a branch-slot expression (Child-position value inside a conditional
|
|
8
|
+
* `template()` arrow) is routed through `__bfSlot` and must NOT be
|
|
9
|
+
* wrapped in `escapeText`. `__bfSlot` returns raw `<!--bf-slot:N-->`
|
|
10
|
+
* markers for live nodes; escaping the whole call corrupts them and
|
|
11
|
+
* drops slotted content (the regression that broke `e2e-site-ui`).
|
|
12
|
+
* `__bfSlot` escapes its own plain-string path internally instead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect } from 'bun:test'
|
|
16
|
+
import { compileJSX } from '../compiler'
|
|
17
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
18
|
+
|
|
19
|
+
const adapter = new TestAdapter()
|
|
20
|
+
|
|
21
|
+
function getClientJs(source: string, filename: string): string {
|
|
22
|
+
const result = compileJSX(source, filename, { adapter })
|
|
23
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
24
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
25
|
+
expect(clientJs).toBeDefined()
|
|
26
|
+
return clientJs!.content
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('text-slot escaping', () => {
|
|
30
|
+
test('a plain text slot is wrapped in escapeText', () => {
|
|
31
|
+
const clientJs = getClientJs(
|
|
32
|
+
`'use client'
|
|
33
|
+
export function Label({ text }: { text: string }) {
|
|
34
|
+
return <span>{text}</span>
|
|
35
|
+
}`,
|
|
36
|
+
'Label.tsx',
|
|
37
|
+
)
|
|
38
|
+
expect(clientJs).toMatch(/<!--bf:\w+-->\$\{escapeText\(_p\.text\)\}<!--\/-->/)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('a branch-slot expression is NOT wrapped in escapeText', () => {
|
|
42
|
+
const clientJs = getClientJs(
|
|
43
|
+
`'use client'
|
|
44
|
+
import { createSignal } from '@barefootjs/client'
|
|
45
|
+
export function Branch({ show }: { show: boolean }) {
|
|
46
|
+
const [t] = createSignal('hi')
|
|
47
|
+
return <div>{show ? <span>{t()}</span> : null}</div>
|
|
48
|
+
}`,
|
|
49
|
+
'Branch.tsx',
|
|
50
|
+
)
|
|
51
|
+
// The branch value goes through __bfSlot (raw markers preserved)…
|
|
52
|
+
expect(clientJs).toMatch(/\$\{__bfSlot\(/)
|
|
53
|
+
// …and must never be double-wrapped by the text escape.
|
|
54
|
+
expect(clientJs).not.toMatch(/escapeText\(\s*__bfSlot/)
|
|
55
|
+
})
|
|
56
|
+
})
|