@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.
Files changed (37) hide show
  1. package/dist/combine-client-js.d.ts.map +1 -1
  2. package/dist/index.js +176 -51
  3. package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
  4. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  5. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  7. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/imports.d.ts +2 -2
  11. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  13. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/types.d.ts +26 -4
  15. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/utils.d.ts +19 -1
  17. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  18. package/package.json +2 -2
  19. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  20. package/src/__tests__/child-components-in-map.test.ts +333 -0
  21. package/src/__tests__/combine-client-js.test.ts +47 -0
  22. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  23. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  24. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  25. package/src/combine-client-js.ts +66 -22
  26. package/src/ir-to-client-js/collect-elements.ts +170 -32
  27. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  28. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  29. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  30. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  31. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  32. package/src/ir-to-client-js/html-template.ts +82 -10
  33. package/src/ir-to-client-js/imports.ts +1 -1
  34. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  35. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  36. package/src/ir-to-client-js/types.ts +27 -4
  37. 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), 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
+ })
@@ -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
- const codeLines: string[] = []
105
-
106
- for (const line of content.split('\n')) {
107
- if (line.startsWith('import ')) {
108
- if (line.includes('@bf-child:')) continue
109
-
110
- const match = line.match(/^import \{ ([^}]+) \} from ['"]([^'"]+)['"]/)
111
- if (match) {
112
- const names = match[1].split(',').map(n => n.trim())
113
- const source = match[2]
114
- if (!importsBySource.has(source)) {
115
- importsBySource.set(source, new Set())
116
- }
117
- for (const name of names) {
118
- importsBySource.get(source)!.add(name)
119
- }
120
- } else {
121
- if (!otherImports.includes(line)) {
122
- otherImports.push(line)
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
- codeLines.push(line)
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
- const code = codeLines.join('\n').trim()
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
  }