@barefootjs/jsx 0.5.2 → 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/combine-client-js.d.ts.map +1 -1
- package/dist/index.js +176 -51
- package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
- 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/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
- package/src/__tests__/child-components-in-map.test.ts +333 -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/combine-client-js.ts +66 -22
- package/src/ir-to-client-js/collect-elements.ts +170 -32
- 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
|
@@ -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 = `
|
|
@@ -773,6 +781,331 @@ describe('child components inside .map() (#344)', () => {
|
|
|
773
781
|
expect(content).not.toContain('children[__idx]')
|
|
774
782
|
})
|
|
775
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
|
+
|
|
776
1109
|
test('nested .map() with multiple inner components emits unique __compEl bindings (#1664)', () => {
|
|
777
1110
|
const source = `
|
|
778
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
|
+
})
|
package/src/combine-client-js.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* into the parent's file, eliminating the need for separate HTTP requests.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import ts from 'typescript'
|
|
12
|
+
|
|
11
13
|
const CHILD_PLACEHOLDER_RE = /import '\/\* @bf-child:(\w+) \*\/'/g
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -101,33 +103,75 @@ function parseAndMerge(
|
|
|
101
103
|
otherImports: string[],
|
|
102
104
|
codeSections: string[]
|
|
103
105
|
): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
106
|
+
// Parse the client JS so we only ever treat *real* top-level
|
|
107
|
+
// `ImportDeclaration` statements as imports. The predecessor matched
|
|
108
|
+
// raw lines beginning with `import `, which also caught `import …`
|
|
109
|
+
// lines that merely live *inside a string / template literal value*
|
|
110
|
+
// (e.g. a data module exporting a code snippet). Tearing such a line
|
|
111
|
+
// out of its string relocated the component's real runtime import into
|
|
112
|
+
// the literal and left `hydrate` undefined at call time. See
|
|
113
|
+
// piconic-ai/barefootjs#1702.
|
|
114
|
+
// Parent pointers aren't needed here — we only read `statements` and each
|
|
115
|
+
// import's `getStart`/`getEnd` — so skip building them to keep the per-chunk
|
|
116
|
+
// parse cheap when combining many files.
|
|
117
|
+
const sourceFile = ts.createSourceFile(
|
|
118
|
+
'combine.js',
|
|
119
|
+
content,
|
|
120
|
+
ts.ScriptTarget.Latest,
|
|
121
|
+
/*setParentNodes*/ false,
|
|
122
|
+
ts.ScriptKind.JS,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Character spans of the top-level imports to strip from the emitted
|
|
126
|
+
// code, so everything that isn't an import (including literals whose
|
|
127
|
+
// contents look like imports) is preserved verbatim.
|
|
128
|
+
const importSpans: Array<[number, number]> = []
|
|
129
|
+
|
|
130
|
+
for (const stmt of sourceFile.statements) {
|
|
131
|
+
if (!ts.isImportDeclaration(stmt)) continue
|
|
132
|
+
const start = stmt.getStart(sourceFile)
|
|
133
|
+
const end = stmt.getEnd()
|
|
134
|
+
importSpans.push([start, end])
|
|
135
|
+
|
|
136
|
+
const stmtText = content.slice(start, end)
|
|
137
|
+
// `@bf-child:` placeholders are resolved by inlining elsewhere; drop
|
|
138
|
+
// them entirely (neither merged nor kept as code).
|
|
139
|
+
if (stmtText.includes('@bf-child:')) continue
|
|
140
|
+
|
|
141
|
+
const clause = stmt.importClause
|
|
142
|
+
const bindings = clause?.namedBindings
|
|
143
|
+
const specifier = ts.isStringLiteral(stmt.moduleSpecifier)
|
|
144
|
+
? stmt.moduleSpecifier.text
|
|
145
|
+
: ''
|
|
146
|
+
if (clause && !clause.name && bindings && ts.isNamedImports(bindings)) {
|
|
147
|
+
// Pure named import (`import { a, b as c } from '…'`) — merge by source.
|
|
148
|
+
if (!importsBySource.has(specifier)) {
|
|
149
|
+
importsBySource.set(specifier, new Set())
|
|
150
|
+
}
|
|
151
|
+
const set = importsBySource.get(specifier)!
|
|
152
|
+
for (const el of bindings.elements) {
|
|
153
|
+
const name = el.propertyName
|
|
154
|
+
? `${el.propertyName.text} as ${el.name.text}`
|
|
155
|
+
: el.name.text
|
|
156
|
+
set.add(name)
|
|
124
157
|
}
|
|
125
158
|
} else {
|
|
126
|
-
|
|
159
|
+
// default / namespace / side-effect import — keep verbatim.
|
|
160
|
+
if (!otherImports.includes(stmtText)) {
|
|
161
|
+
otherImports.push(stmtText)
|
|
162
|
+
}
|
|
127
163
|
}
|
|
128
164
|
}
|
|
129
165
|
|
|
130
|
-
|
|
166
|
+
// Reconstruct the code with the import spans removed.
|
|
167
|
+
let code = ''
|
|
168
|
+
let cursor = 0
|
|
169
|
+
for (const [start, end] of importSpans) {
|
|
170
|
+
code += content.slice(cursor, start)
|
|
171
|
+
cursor = end
|
|
172
|
+
}
|
|
173
|
+
code += content.slice(cursor)
|
|
174
|
+
code = code.trim()
|
|
131
175
|
if (code) {
|
|
132
176
|
codeSections.push(code)
|
|
133
177
|
}
|