@barefootjs/jsx 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +1 -1
  2. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  3. package/dist/combine-client-js.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.js +330 -70
  7. package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  11. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/html-template.d.ts +30 -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/phases/provider-and-child-inits.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  20. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/types.d.ts +36 -4
  22. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/utils.d.ts +19 -1
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  27. package/src/__tests__/child-components-in-map.test.ts +333 -0
  28. package/src/__tests__/combine-client-js.test.ts +47 -0
  29. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  30. package/src/__tests__/expression-parser.test.ts +167 -13
  31. package/src/__tests__/ir-to-client-js/reactivity.test.ts +1 -0
  32. package/src/__tests__/staged-ir/06-multi-stage-soak.test.ts +18 -3
  33. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  34. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  35. package/src/adapters/parsed-expr-emitter.ts +7 -0
  36. package/src/combine-client-js.ts +66 -22
  37. package/src/expression-parser.ts +200 -17
  38. package/src/ir-to-client-js/collect-elements.ts +170 -32
  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/emit-registration.ts +1 -1
  45. package/src/ir-to-client-js/generate-init.ts +16 -1
  46. package/src/ir-to-client-js/html-template.ts +238 -12
  47. package/src/ir-to-client-js/imports.ts +1 -1
  48. package/src/ir-to-client-js/index.ts +1 -0
  49. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +12 -1
  50. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  51. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  52. package/src/ir-to-client-js/types.ts +37 -4
  53. 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
+ })