@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.
Files changed (51) hide show
  1. package/dist/analyzer-context.d.ts +8 -1
  2. package/dist/analyzer-context.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/combine-client-js.d.ts.map +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +239 -65
  9. package/dist/ir-to-client-js/collect-elements.d.ts +31 -9
  10. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  13. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/imports.d.ts +2 -2
  17. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  19. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/types.d.ts +26 -4
  21. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/utils.d.ts +19 -1
  23. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  24. package/dist/types.d.ts +6 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  28. package/src/__tests__/child-components-in-map.test.ts +376 -0
  29. package/src/__tests__/combine-client-js.test.ts +47 -0
  30. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  31. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  32. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  33. package/src/analyzer-context.ts +59 -13
  34. package/src/analyzer.ts +8 -0
  35. package/src/combine-client-js.ts +66 -22
  36. package/src/expression-parser.ts +16 -1
  37. package/src/index.ts +2 -0
  38. package/src/ir-to-client-js/collect-elements.ts +191 -34
  39. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  41. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  43. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  44. package/src/ir-to-client-js/html-template.ts +82 -10
  45. package/src/ir-to-client-js/imports.ts +1 -1
  46. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  47. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  48. package/src/ir-to-client-js/types.ts +27 -4
  49. package/src/ir-to-client-js/utils.ts +41 -1
  50. package/src/scanner/__tests__/js-scanner.fuzz.test.ts +202 -0
  51. 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), not via a `__bfItem` accessor.
207
- expect(clientJs).toMatch(/\$\{it\}/)
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
- expect(materializeBlock).toMatch(/\$\{String\(count\)\}/)
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
+ })