@barefootjs/jsx 0.34.0 → 0.35.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 (101) hide show
  1. package/dist/analyzer.d.ts.map +1 -1
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +344 -215
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
  10. package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.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/build-plain-row.d.ts +52 -0
  13. package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
  14. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +2 -0
  15. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
  17. package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
  19. package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
  22. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
  24. package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
  26. package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
  28. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  30. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  31. package/dist/ir-to-client-js/html-template.d.ts +2 -2
  32. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  33. package/dist/ir-to-client-js/imports.d.ts +2 -2
  34. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  35. package/dist/ir-to-client-js/index.d.ts.map +1 -1
  36. package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
  37. package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
  38. package/dist/ir-to-client-js/safe-html.d.ts +158 -0
  39. package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
  40. package/dist/ir-to-client-js/utils.d.ts +34 -1
  41. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  42. package/dist/jsx-to-ir.d.ts.map +1 -1
  43. package/dist/prop-rewrite.d.ts +8 -3
  44. package/dist/prop-rewrite.d.ts.map +1 -1
  45. package/dist/props-binding.d.ts +22 -1
  46. package/dist/props-binding.d.ts.map +1 -1
  47. package/dist/ssr-defaults.d.ts.map +1 -1
  48. package/dist/types.d.ts +5 -10
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +2 -2
  51. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +10 -12
  52. package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
  53. package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
  54. package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
  55. package/src/__tests__/flatmap-segments.test.ts +1 -1
  56. package/src/__tests__/lazy-conditional.test.ts +52 -12
  57. package/src/__tests__/lazy-preamble.test.ts +11 -11
  58. package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
  59. package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
  60. package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
  61. package/src/__tests__/map-arbitrary-body.test.ts +1 -1
  62. package/src/__tests__/nested-loop-index-param.test.ts +14 -3
  63. package/src/__tests__/nested-loop-reactive-attrs.test.ts +4 -2
  64. package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
  65. package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
  66. package/src/__tests__/safe-html-splice-door.test.ts +61 -0
  67. package/src/__tests__/ssr-defaults.test.ts +59 -0
  68. package/src/analyzer.ts +4 -0
  69. package/src/index.ts +8 -2
  70. package/src/ir-to-client-js/collect-elements.ts +18 -2
  71. package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
  72. package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
  73. package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +10 -6
  74. package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +8 -8
  75. package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +18 -2
  76. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +3 -3
  77. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
  78. package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +110 -0
  79. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
  80. package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
  81. package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
  82. package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
  83. package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
  84. package/src/ir-to-client-js/control-flow/shared.ts +40 -17
  85. package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
  86. package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +10 -0
  87. package/src/ir-to-client-js/csr-substitute.ts +29 -0
  88. package/src/ir-to-client-js/emit-reactive.ts +4 -1
  89. package/src/ir-to-client-js/emit-registration.ts +2 -4
  90. package/src/ir-to-client-js/html-template.ts +58 -244
  91. package/src/ir-to-client-js/imports.ts +3 -0
  92. package/src/ir-to-client-js/index.ts +4 -4
  93. package/src/ir-to-client-js/markup-slots.ts +25 -0
  94. package/src/ir-to-client-js/safe-html.ts +243 -0
  95. package/src/ir-to-client-js/utils.ts +58 -10
  96. package/src/jsx-to-ir.ts +115 -20
  97. package/src/prop-rewrite.ts +8 -4
  98. package/src/props-binding.ts +37 -1
  99. package/src/ssr-defaults.ts +45 -0
  100. package/src/types.ts +5 -10
  101. package/src/__tests__/text-slot-escaping.test.ts +0 -65
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Regression pin for the `buildPlainRowCore` extraction
3
+ * (`control-flow/plan/build-plain-row.ts`).
4
+ *
5
+ * `buildPlainLoopPlan` (build-loop.ts, `callSite: 'plain'`) and
6
+ * `buildBranchLoopPlan` (build-branch-loop.ts, `callSite: 'branch-plain'`)
7
+ * used to duplicate the "wrap item → decide lazy → wrap index" sequence
8
+ * verbatim. This pins that both call sites now go through the ONE shared
9
+ * implementation and cannot silently diverge again (CLAUDE.md's "one
10
+ * decision, two implementations, no test comparing them"):
11
+ *
12
+ * - A row forced eager for a reason UNRELATED to the index (here, an
13
+ * imperative ref — #2859 follow-on lifted the index-only refusal, so a
14
+ * bare index reference no longer forces eager on its own) still gets
15
+ * its index wrapped into a live accessor call at BOTH call sites.
16
+ * - A lazy-eligible row (index-referencing or not) goes through
17
+ * `mapArrayLazy` at BOTH call sites, with no leaked eager-only index
18
+ * wrap in the shared `mapPreambleWrapped`/`template` strings — the
19
+ * exact shape of the original #2859 CI regression.
20
+ */
21
+ import { describe, test, expect } from 'bun:test'
22
+ import { compileJSX } from '../compiler'
23
+ import { TestAdapter } from '../adapters/test-adapter'
24
+
25
+ const adapter = new TestAdapter()
26
+
27
+ function clientJsFor(source: string): string {
28
+ const result = compileJSX(source, 'Repro.tsx', { adapter })
29
+ const errors = result.errors.filter(e => e.severity === 'error')
30
+ if (errors.length > 0) throw new Error(errors.map(e => e.message).join('\n'))
31
+ return result.files.find(f => f.type === 'clientJs')!.content
32
+ }
33
+
34
+ describe('buildPlainRowCore — shared wrap-item/decide-lazy/wrap-index sequence', () => {
35
+ test('top-level plain loop: a ref forces eager, and the index is wrapped into a live accessor call', () => {
36
+ const js = clientJsFor(`
37
+ 'use client'
38
+ import { createSignal } from '@barefootjs/client'
39
+ type Row = { id: number; label: string }
40
+ export function Repro() {
41
+ const [rows] = createSignal<Row[]>([])
42
+ const refs: HTMLLIElement[] = []
43
+ return <ul>{rows().map((row, i) => <li key={row.id} ref={el => { refs[i] = el! }}>{String(i + 1)}. {row.label}</li>)}</ul>
44
+ }
45
+ `)
46
+ expect(js).not.toContain('mapArrayLazy(')
47
+ expect(js).toMatch(/i\(\)\s*\+\s*1/)
48
+ })
49
+
50
+ test('branch-plain loop (ternary): the SAME ref-bearing shape wraps the index identically', () => {
51
+ const js = clientJsFor(`
52
+ 'use client'
53
+ import { createSignal } from '@barefootjs/client'
54
+ type Row = { id: number; label: string }
55
+ export function Repro() {
56
+ const [active, setActive] = createSignal(true)
57
+ const [rows] = createSignal<Row[]>([])
58
+ const refs: HTMLLIElement[] = []
59
+ return <div>{active() ? rows().map((row, i) => <li key={row.id} ref={el => { refs[i] = el! }}>{String(i + 1)}. {row.label}</li>) : <span>Empty</span>}</div>
60
+ }
61
+ `)
62
+ expect(js).not.toContain('mapArrayLazy(')
63
+ expect(js).toMatch(/i\(\)\s*\+\s*1/)
64
+ })
65
+
66
+ test('top-level plain loop: a lazy-eligible row (no index reference) goes lazy with no leaked index wrap', () => {
67
+ const js = clientJsFor(`
68
+ 'use client'
69
+ import { createSignal } from '@barefootjs/client'
70
+ type Row = { id: number; label: string }
71
+ export function Repro() {
72
+ const [rows] = createSignal<Row[]>([{ id: 1, label: 'alpha' }])
73
+ const [selected, setSelected] = createSignal(1)
74
+ return (
75
+ <ul>
76
+ {rows().map(row => (
77
+ <li key={row.id} onClick={() => setSelected(row.id)}>{String(selected() === row.id ? 'YES' : 'NO')}</li>
78
+ ))}
79
+ </ul>
80
+ )
81
+ }
82
+ `)
83
+ expect(js).toContain('mapArrayLazy(')
84
+ })
85
+
86
+ test('branch-plain loop (ternary): the SAME lazy-eligible shape also goes lazy', () => {
87
+ const js = clientJsFor(`
88
+ 'use client'
89
+ import { createSignal } from '@barefootjs/client'
90
+ type Row = { id: number; label: string }
91
+ export function Repro() {
92
+ const [active] = createSignal(true)
93
+ const [rows] = createSignal<Row[]>([{ id: 1, label: 'alpha' }])
94
+ const [selected, setSelected] = createSignal(1)
95
+ return (
96
+ <div>
97
+ {active() ? rows().map(row => (
98
+ <li key={row.id} onClick={() => setSelected(row.id)}>{String(selected() === row.id ? 'YES' : 'NO')}</li>
99
+ )) : <span>Empty</span>}
100
+ </div>
101
+ )
102
+ }
103
+ `)
104
+ expect(js).toContain('mapArrayLazy(')
105
+ })
106
+
107
+ test('a ref callback closing over the loop index reads the live accessor at the top-level call site (#2859)', () => {
108
+ const js = clientJsFor(`
109
+ 'use client'
110
+ import { createSignal } from '@barefootjs/client'
111
+ type Row = { id: number; label: string }
112
+ export function Repro() {
113
+ const [rows] = createSignal<Row[]>([])
114
+ const refs: HTMLLIElement[] = []
115
+ return <ul>{rows().map((row, i) => <li key={row.id} ref={el => { refs[i] = el! }}>{row.label}</li>)}</ul>
116
+ }
117
+ `)
118
+ expect(js).toMatch(/refs\[i\(\)\]/)
119
+ })
120
+ })
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Escape-by-default soundness for client HTML templates (#2795), made
3
+ * executable — the escaping twin of `map-body-no-silent-divergence.test.ts`.
4
+ *
5
+ * For EVERY shape below, exactly one of these must hold:
6
+ * 1. it compiles clean AND every `${...}` hole in every HTML template
7
+ * literal of the emitted client JS is an ALLOWED FORM — an escaping
8
+ * runtime call, a named trusted producer, or a structural form whose
9
+ * own nested templates are checked in turn — and no escaping call wraps
10
+ * another (double escape, the failure a half-finished migration
11
+ * produces); or
12
+ * 2. the compiler raises a BF error (loud, actionable refusal).
13
+ *
14
+ * A shape that compiles clean but splices a bare expression into HTML is a
15
+ * SILENT HOLE — the class #1694 and #2765/#2792 belonged to. Both are pinned
16
+ * below as executable assertions, plus the inlined-constant shape the #2795
17
+ * design predicted and this migration closed.
18
+ *
19
+ * The check parses the emitted JS with the TS AST (never regex over code —
20
+ * CLAUDE.md), walks every template literal whose static text contains an
21
+ * HTML tag/comment opener, and classifies each hole. Nested row/branch
22
+ * templates that carry no static text of their own are reached through the
23
+ * structural forms that own them (a conditional's branches, a mapped-rows
24
+ * arrow's return). Argument subtrees of an allowed call are not treated as
25
+ * holes — a user expression inside `escapeText(...)` may legitimately be a
26
+ * template literal — but the walk still visits any HTML template nested in
27
+ * them (a `bfMarkup(\`<strong>...\`)` prop, say).
28
+ *
29
+ * `KNOWN_HOLES` pins the exact current reality and may only shrink.
30
+ *
31
+ * Alongside the output ratchet, two source-level pins keep the emitter's
32
+ * structure honest: the exact count of hand-written `\${` in
33
+ * `html-template.ts` (every child-position hole goes through `interp`;
34
+ * only the attribute-side holes remain, see `safe-html.ts`'s header), and
35
+ * the initial-render `escapeTextOrMarkup` slot set equalling the reactive
36
+ * `kind: 'markup'` claim set (`markup-slots.ts`).
37
+ */
38
+
39
+ import { describe, test, expect } from 'bun:test'
40
+ import ts from 'typescript'
41
+ import { readFileSync } from 'node:fs'
42
+ import { join } from 'node:path'
43
+ import { compileJSX } from '../compiler'
44
+ import { TestAdapter } from '../adapters/test-adapter'
45
+
46
+ interface Shape {
47
+ id: string
48
+ source: string
49
+ /** Shape-specific pins on the emitted client JS (only run for sound shapes). */
50
+ pin?: (clientJs: string) => void
51
+ }
52
+
53
+ /** Shapes that are silent holes today. Shrink-only — never add entries. */
54
+ const KNOWN_HOLES: ReadonlySet<string> = new Set([])
55
+
56
+ const shapes: Shape[] = [
57
+ {
58
+ // #1694 — the first member of the family: a plain prop in text position
59
+ // shipped as `${_p.text}`. Pinned via the static-template builder.
60
+ id: '1694-prop-text-static-template',
61
+ source: `
62
+ export function Label({ text }: { text: string }) {
63
+ return <span>{text}</span>
64
+ }`,
65
+ pin: (js) => {
66
+ expect(js).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
67
+ },
68
+ },
69
+ {
70
+ id: '1694-prop-text-use-client',
71
+ source: `
72
+ 'use client'
73
+ export function Label({ text }: { text: string }) {
74
+ return <span>{text}</span>
75
+ }`,
76
+ pin: (js) => {
77
+ expect(js).toMatch(/<!--bf:\w+-->\$\{escapeTextOrMarkup\(_p\.text\)\}<!--\/-->/)
78
+ },
79
+ },
80
+ {
81
+ // #2765 — a controlled <textarea>'s value, lowered into element content,
82
+ // spliced raw by the loop-row builder (`irToHtmlTemplate`).
83
+ id: '2765-textarea-value-loop-row',
84
+ source: `
85
+ 'use client'
86
+ import { createSignal } from '@barefootjs/client'
87
+ export function Notes() {
88
+ const [items] = createSignal([{ id: '1', t: 'x' }])
89
+ return <ul>{items().map(i => <li key={i.id}><textarea value={i.t} /></li>)}</ul>
90
+ }`,
91
+ pin: (js) => expectEveryTextareaEscaped(js),
92
+ },
93
+ {
94
+ // #2792 — the identical miss in the composite-row builder
95
+ // (`irToPlaceholderTemplate`): a row that also hosts a child component
96
+ // renders through placeholders, a second code path the #2765 fix missed.
97
+ id: '2792-textarea-value-composite-row',
98
+ source: `
99
+ 'use client'
100
+ import { createSignal } from '@barefootjs/client'
101
+ import { Child } from './Child'
102
+ export function Notes() {
103
+ const [items] = createSignal([{ id: '1', t: 'x' }])
104
+ return <ul>{items().map(i => <li key={i.id}><Child /><textarea value={i.t} /></li>)}</ul>
105
+ }`,
106
+ pin: (js) => {
107
+ expect(js).toContain('data-bf-ph=')
108
+ expectEveryTextareaEscaped(js)
109
+ },
110
+ },
111
+ {
112
+ id: 'textarea-value-static-template',
113
+ source: `
114
+ 'use client'
115
+ import { createSignal } from '@barefootjs/client'
116
+ export function NoteBox() {
117
+ const [note, setNote] = createSignal('hi')
118
+ return <textarea value={note()} onInput={(e) => setNote(e.target.value)} />
119
+ }`,
120
+ pin: (js) => expectEveryTextareaEscaped(js),
121
+ },
122
+ {
123
+ // #2795 §8.4 — a module-scope string constant gets no slot and is
124
+ // constant-inlined into the bare-splice fallthrough; before this
125
+ // migration it reached the template as `${('a<b')}`, raw.
126
+ id: '2795-inlined-constant-text',
127
+ source: `
128
+ const LABEL = 'a<b'
129
+ export function C() {
130
+ return <div>{LABEL}</div>
131
+ }`,
132
+ pin: (js) => {
133
+ expect(js).toContain("${escapeText(('a<b'))}")
134
+ expect(js).not.toContain("${('a<b')}")
135
+ },
136
+ },
137
+ {
138
+ // A string literal in a conditional branch has no slot and used to
139
+ // reach the template raw (`${'ON'}`) — a `'<b>'` literal would have
140
+ // been markup on the client and text on the server. Now escaped.
141
+ id: 'conditional-string-literal-branch',
142
+ source: `
143
+ 'use client'
144
+ import { createSignal } from '@barefootjs/client'
145
+ export function Toggle() {
146
+ const [on, setOn] = createSignal(false)
147
+ return <button onClick={() => setOn(!on())}>{on() ? 'ON' : 'OFF'}</button>
148
+ }`,
149
+ pin: (js) => {
150
+ expect(js).toContain("${escapeText('ON')}")
151
+ expect(js).not.toContain("${'ON'}")
152
+ },
153
+ },
154
+ {
155
+ // The trusted opt-out that must survive: `{children}` is already HTML.
156
+ id: 'children-passthrough',
157
+ source: `
158
+ export function Box({ children }: { children?: any }) {
159
+ return <div>{children}</div>
160
+ }`,
161
+ pin: (js) => expect(js).toContain('${markupOrEmpty(_p.children)}'),
162
+ },
163
+ {
164
+ // #2786 — destructured-and-renamed children resolves to `(_p.children)`.
165
+ id: 'children-passthrough-renamed',
166
+ source: `
167
+ export function Box(props: { children?: any }) {
168
+ const { children: kids } = props
169
+ return <div>{kids}</div>
170
+ }`,
171
+ pin: (js) => expect(js).toContain('${markupOrEmpty((_p.children))}'),
172
+ },
173
+ {
174
+ // The author-facing escape hatch, by name.
175
+ id: 'dangerously-set-inner-html',
176
+ source: `
177
+ export function Raw({ h }: { h: string }) {
178
+ return <div dangerouslySetInnerHTML={{ __html: h }} />
179
+ }`,
180
+ pin: (js) => expect(js).toContain(".__html ?? ''}"),
181
+ },
182
+ {
183
+ // A conditional-branch template value goes through `__bfSlot` (raw
184
+ // markers for live Nodes) and must never be wrapped a second time —
185
+ // the regression #1694's fix itself caused (ex `text-slot-escaping`).
186
+ id: 'branch-slot-value',
187
+ source: `
188
+ 'use client'
189
+ import { createSignal } from '@barefootjs/client'
190
+ export function Branch({ show }: { show: boolean }) {
191
+ const [t] = createSignal('hi')
192
+ return <div>{show ? <span>{t()}</span> : null}</div>
193
+ }`,
194
+ pin: (js) => {
195
+ expect(js).toMatch(/\$\{__bfSlot\(/)
196
+ expect(js).not.toMatch(/escapeTextOrMarkup\(\s*__bfSlot/)
197
+ expect(js).not.toMatch(/escapeText\(\s*__bfSlot/)
198
+ },
199
+ },
200
+ {
201
+ // flatMap projection leaf — formerly escaped by the deleted
202
+ // `escapeLeafTextExpressions` rewrite pass, now by the door.
203
+ id: 'flatmap-projection-leaf',
204
+ source: `
205
+ export function Tags({ todos }: { todos: { id: string; tags: string[] }[] }) {
206
+ return <ul>{todos.flatMap(todo => todo.tags.map(tag => <li key={todo.id + tag}>{tag}</li>))}</ul>
207
+ }`,
208
+ pin: (js) => expect(js).toContain('${escapeText(tag)}'),
209
+ },
210
+ {
211
+ // Stage 3 / D4 array-builder preamble: `{out}` is an array of compiled
212
+ // leaves (joined, trusted); the leaves' own text is escaped.
213
+ id: 'preamble-array-child',
214
+ source: `
215
+ export function T({ rows }: { rows: { id: string; cells: string[] }[] }) {
216
+ return <table><tbody>{rows.map((r) => {
217
+ const out = []
218
+ for (const c of r.cells) out.push(<td>{c}</td>)
219
+ return <tr key={r.id}>{out}</tr>
220
+ })}</tbody></table>
221
+ }`,
222
+ pin: (js) => {
223
+ expect(js).toContain('${escapeText(c)}')
224
+ expect(js).toContain('${Array.isArray(out) ? out.join')
225
+ },
226
+ },
227
+ {
228
+ // Loop param text inside a keyed row, plus an attribute hole.
229
+ id: 'loop-row-text-and-attr',
230
+ source: `
231
+ export function List({ items }: { items: { id: string; name: string; cls: string }[] }) {
232
+ return <ul>{items.map(i => <li key={i.id} class={i.cls}>{i.name}</li>)}</ul>
233
+ }`,
234
+ },
235
+ {
236
+ // Whole-item conditional loop: the `<!--bf-loop-i:KEY-->` anchor form.
237
+ // The key is wrapped in `escapeCommentText` (#2795 follow-up) so a
238
+ // hostile key value can't spell `-->` and close the comment early — see
239
+ // `packages/adapter-tests/fixtures/loop-item-conditional-hostile-key.ts`
240
+ // for the end-to-end proof with an actual `-->`-bearing key.
241
+ id: 'anchored-conditional-loop',
242
+ source: `
243
+ export function List({ items }: { items: { id: string; on: boolean; name: string }[] }) {
244
+ return <ul>{items.map(i => i.on ? <li key={i.id}>{i.name}</li> : null)}</ul>
245
+ }`,
246
+ pin: (js) => {
247
+ expect(js).toMatch(/bf-loop-i:\$\{escapeCommentText\(i\.id\)\}/)
248
+ },
249
+ },
250
+ {
251
+ // Nested conditional inside a loop row.
252
+ id: 'loop-row-nested-conditional',
253
+ source: `
254
+ 'use client'
255
+ import { createSignal } from '@barefootjs/client'
256
+ export function List() {
257
+ const [items] = createSignal([{ id: '1', on: true, name: 'a' }])
258
+ return <ul>{items().map(i => <li key={i.id}>{i.on ? <b>{i.name}</b> : <i>off</i>}</li>)}</ul>
259
+ }`,
260
+ },
261
+ {
262
+ // JSX passed at a non-`children` prop is compiler-branded markup.
263
+ id: 'jsx-element-prop',
264
+ source: `
265
+ import { Card } from './Card'
266
+ export function Page({ title }: { title: string }) {
267
+ return <Card header={<strong>{title}</strong>}>body</Card>
268
+ }`,
269
+ },
270
+ ]
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // The soundness predicate
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /** Runtime helpers that escape their own input. Nesting two is a double escape. */
277
+ const ESCAPING_CALLS = new Set(['escapeText', 'escapeTextOrMarkup', '__bfSlot', 'escapeAttr', 'escapeCommentText'])
278
+ /** Runtime helpers whose output is markup by construction (`safe-html.ts`). */
279
+ const TRUSTED_CALLS = new Set(['markupOrEmpty', 'renderChild', 'spreadAttrs'])
280
+
281
+ interface Soundness {
282
+ unsoundHoles: string[]
283
+ doubleEscapes: string[]
284
+ parseErrors: number
285
+ }
286
+
287
+ function strip(e: ts.Expression): ts.Expression {
288
+ while (ts.isParenthesizedExpression(e)) e = e.expression
289
+ return e
290
+ }
291
+
292
+ function isTemplateLike(e: ts.Expression): boolean {
293
+ const s = strip(e)
294
+ return ts.isTemplateExpression(s) || ts.isNoSubstitutionTemplateLiteral(s)
295
+ }
296
+
297
+ function isEmptyString(e: ts.Expression): boolean {
298
+ const s = strip(e)
299
+ return (ts.isStringLiteral(s) || ts.isNoSubstitutionTemplateLiteral(s)) && s.text === ''
300
+ }
301
+
302
+ /** `'lit' + escapeAttr(x) + '"'` chains — the attribute-hole shape. */
303
+ function isAttrConcat(e: ts.Expression): boolean {
304
+ const s = strip(e)
305
+ if (ts.isStringLiteral(s)) return true
306
+ if (ts.isCallExpression(s) && ts.isIdentifier(s.expression) && s.expression.text === 'escapeAttr') return true
307
+ if (ts.isBinaryExpression(s) && s.operatorToken.kind === ts.SyntaxKind.PlusToken) {
308
+ return isAttrConcat(s.left) && isAttrConcat(s.right)
309
+ }
310
+ return false
311
+ }
312
+
313
+ /** Static template text that reads as HTML (a tag, a comment, or a close tag). */
314
+ function isHtmlTemplate(t: ts.TemplateExpression): boolean {
315
+ const statics = [t.head.text, ...t.templateSpans.map(s => s.literal.text)].join('')
316
+ return /<[A-Za-z!\/]/.test(statics)
317
+ }
318
+
319
+ /**
320
+ * Classify one hole. Returns the allowed-form name, or `null` when the hole
321
+ * is a bare (unescaped, untrusted) expression. `nested` receives any
322
+ * template literal the form owns that must be checked even without static
323
+ * HTML text of its own.
324
+ */
325
+ function classifyHole(expr: ts.Expression, nested: ts.TemplateExpression[]): string | null {
326
+ const e = strip(expr)
327
+ if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return 'literal'
328
+ if (ts.isCallExpression(e)) {
329
+ const callee = strip(e.expression)
330
+ if (ts.isIdentifier(callee)) {
331
+ if (ESCAPING_CALLS.has(callee.text)) return `escape:${callee.text}`
332
+ if (TRUSTED_CALLS.has(callee.text)) return `trusted:${callee.text}`
333
+ }
334
+ // `array.method(params => body).join('')` — mappedRowsMarkup.
335
+ if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'join' && ts.isCallExpression(strip(callee.expression))) {
336
+ const inner = strip(callee.expression) as ts.CallExpression
337
+ for (const arg of inner.arguments) {
338
+ const fn = strip(arg)
339
+ if (ts.isArrowFunction(fn)) {
340
+ const body = fn.body
341
+ if (ts.isTemplateExpression(body)) nested.push(body)
342
+ else if (ts.isBlock(body)) {
343
+ for (const st of body.statements) {
344
+ if (ts.isReturnStatement(st) && st.expression && ts.isTemplateExpression(strip(st.expression))) {
345
+ nested.push(strip(st.expression) as ts.TemplateExpression)
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+ return 'rows'
352
+ }
353
+ // `((v) => ...)(styleToCss(x))` — the style attribute IIFE.
354
+ if (ts.isArrowFunction(callee) && e.arguments.length === 1) {
355
+ const arg = strip(e.arguments[0])
356
+ if (ts.isCallExpression(arg) && ts.isIdentifier(arg.expression) && arg.expression.text === 'styleToCss') return 'style-attr'
357
+ }
358
+ return null
359
+ }
360
+ if (ts.isConditionalExpression(e)) {
361
+ const cond = strip(e.condition)
362
+ // `Array.isArray(out) ? out.join('') : (out ?? '')` — joinedMarkup.
363
+ if (ts.isCallExpression(cond) && ts.isPropertyAccessExpression(cond.expression) && cond.expression.name.text === 'isArray') return 'join-array'
364
+ // `cond ? \`...\` : \`...\`` — conditionalMarkup (branches checked in turn).
365
+ if (isTemplateLike(e.whenTrue) && isTemplateLike(e.whenFalse)) {
366
+ for (const b of [e.whenTrue, e.whenFalse]) {
367
+ const s = strip(b)
368
+ if (ts.isTemplateExpression(s)) nested.push(s)
369
+ }
370
+ return 'conditional-markup'
371
+ }
372
+ // `x ? 'attr' : ''` / `x != null ? 'attr="' + escapeAttr(x) + '"' : ''`.
373
+ if (isAttrConcat(e.whenTrue) && (isEmptyString(e.whenFalse) || ts.isStringLiteral(strip(e.whenFalse)))) return 'attr'
374
+ return null
375
+ }
376
+ // `((x) ?? {}).__html ?? ''` — dangerousInnerHtml, the named hatch.
377
+ if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) {
378
+ const left = strip(e.left)
379
+ if (ts.isPropertyAccessExpression(left) && left.name.text === '__html') return 'dangerous-inner-html'
380
+ }
381
+ return null
382
+ }
383
+
384
+ function checkTemplate(t: ts.TemplateExpression, seen: Set<ts.Node>, out: Soundness): void {
385
+ if (seen.has(t)) return
386
+ seen.add(t)
387
+ for (const span of t.templateSpans) {
388
+ const nested: ts.TemplateExpression[] = []
389
+ const form = classifyHole(span.expression, nested)
390
+ const text = span.expression.getText()
391
+ if (form === null) {
392
+ out.unsoundHoles.push(text)
393
+ } else if (form.startsWith('escape:')) {
394
+ const call = strip(span.expression) as ts.CallExpression
395
+ const arg = call.arguments[0] ? strip(call.arguments[0]) : undefined
396
+ if (arg && ts.isCallExpression(arg) && ts.isIdentifier(arg.expression) && ESCAPING_CALLS.has(arg.expression.text)) {
397
+ out.doubleEscapes.push(text)
398
+ }
399
+ }
400
+ for (const n of nested) checkTemplate(n, seen, out)
401
+ }
402
+ }
403
+
404
+ function soundnessOf(clientJs: string): Soundness {
405
+ const sf = ts.createSourceFile('client.js', clientJs, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
406
+ const out: Soundness = { unsoundHoles: [], doubleEscapes: [], parseErrors: (sf as unknown as { parseDiagnostics: unknown[] }).parseDiagnostics.length }
407
+ const seen = new Set<ts.Node>()
408
+ const visit = (n: ts.Node): void => {
409
+ if (ts.isTemplateExpression(n) && isHtmlTemplate(n)) checkTemplate(n, seen, out)
410
+ ts.forEachChild(n, visit)
411
+ }
412
+ visit(sf)
413
+ return out
414
+ }
415
+
416
+ function isSound(s: Soundness): boolean {
417
+ return s.unsoundHoles.length === 0 && s.doubleEscapes.length === 0 && s.parseErrors === 0
418
+ }
419
+
420
+ function expectEveryTextareaEscaped(js: string): void {
421
+ const opens = js.match(/<textarea\b/g) ?? []
422
+ const escaped = js.match(/<textarea\b[^>]*>\$\{escapeText\(/g) ?? []
423
+ expect(opens.length).toBeGreaterThan(0)
424
+ expect(escaped.length).toBe(opens.length)
425
+ }
426
+
427
+ function compileShape(shape: Shape): { clientJs: string; hasBfError: boolean } {
428
+ const result = compileJSX(shape.source, `${shape.id}.tsx`, { adapter: new TestAdapter() })
429
+ const hasBfError = result.errors.some(e => e.severity === 'error' && /^BF\d+/.test(e.code))
430
+ const clientJs = result.files.filter(f => f.type === 'clientJs').map(f => f.content).join('\n')
431
+ return { clientJs, hasBfError }
432
+ }
433
+
434
+ // ---------------------------------------------------------------------------
435
+
436
+ describe('client template escape soundness (#2795)', () => {
437
+ for (const shape of shapes) {
438
+ test(shape.id, () => {
439
+ const { clientJs, hasBfError } = compileShape(shape)
440
+ if (hasBfError) return // loud refusal — trichotomy arm 2
441
+ const s = soundnessOf(clientJs)
442
+ const sound = isSound(s)
443
+ if (KNOWN_HOLES.has(shape.id)) {
444
+ // A listed hole that stops leaking must be removed from the set.
445
+ expect(sound).toBe(false)
446
+ return
447
+ }
448
+ if (!sound) {
449
+ throw new Error(
450
+ `silent hole in shape "${shape.id}":\n` +
451
+ ` unsound holes: ${JSON.stringify(s.unsoundHoles)}\n` +
452
+ ` double escapes: ${JSON.stringify(s.doubleEscapes)}\n` +
453
+ ` parse errors: ${s.parseErrors}\n${clientJs}`,
454
+ )
455
+ }
456
+ shape.pin?.(clientJs)
457
+ })
458
+ }
459
+
460
+ test('KNOWN_HOLES only ever shrinks', () => {
461
+ expect(KNOWN_HOLES.size).toBe(0)
462
+ })
463
+ })
464
+
465
+ describe('markup slot membership: initial render agrees with the reactive claim (#2795 §5)', () => {
466
+ /**
467
+ * `<!--bf:ID-->${escapeTextOrMarkup(...)}` in the static template ⇔ a
468
+ * `lazySlots(__scope, [{ id: 'ID', kind: 'markup' }])` claim in init — the
469
+ * two sides of `markupSlotIdsOf`. Component-scope claims only: loop rows
470
+ * and regions claim against their own row element.
471
+ */
472
+ function slotSets(clientJs: string): { template: Set<string>; claims: Set<string> } {
473
+ const sf = ts.createSourceFile('client.js', clientJs, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
474
+ const template = new Set<string>()
475
+ const claims = new Set<string>()
476
+ const visit = (n: ts.Node): void => {
477
+ if (ts.isTemplateExpression(n)) {
478
+ let preceding = n.head.text
479
+ for (const span of n.templateSpans) {
480
+ // Slot ids may carry the `^` JSX-prop prefix (`header={<b/>}`).
481
+ const m = /<!--bf:([\^\w]+)-->$/.exec(preceding)
482
+ const e = strip(span.expression)
483
+ if (m && ts.isCallExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === 'escapeTextOrMarkup') template.add(m[1])
484
+ preceding = span.literal.text
485
+ }
486
+ }
487
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'lazySlots') {
488
+ const [scope, plan] = n.arguments
489
+ if (scope && ts.isIdentifier(scope) && scope.text === '__scope' && plan && ts.isArrayLiteralExpression(plan)) {
490
+ for (const el of plan.elements) {
491
+ if (!ts.isObjectLiteralExpression(el)) continue
492
+ let id: string | undefined
493
+ let kind: string | undefined
494
+ for (const p of el.properties) {
495
+ if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name) || !ts.isStringLiteral(p.initializer)) continue
496
+ if (p.name.text === 'id') id = p.initializer.text
497
+ if (p.name.text === 'kind') kind = p.initializer.text
498
+ }
499
+ if (id && kind === 'markup') claims.add(id)
500
+ }
501
+ }
502
+ }
503
+ ts.forEachChild(n, visit)
504
+ }
505
+ visit(sf)
506
+ return { template, claims }
507
+ }
508
+
509
+ for (const shape of shapes) {
510
+ test(shape.id, () => {
511
+ const { clientJs, hasBfError } = compileShape(shape)
512
+ if (hasBfError) return
513
+ const { template, claims } = slotSets(clientJs)
514
+ expect([...template].sort()).toEqual([...claims].sort())
515
+ })
516
+ }
517
+ })
518
+
519
+ describe('emitter structure pins (#2795 §4.3)', () => {
520
+ const emitterDir = join(import.meta.dir, '..', 'ir-to-client-js')
521
+ const count = (file: string, needle: string): number => readFileSync(join(emitterDir, file), 'utf8').split(needle).length - 1
522
+
523
+ test('html-template.ts writes no child-position hole by hand — only the attribute-side ones remain', () => {
524
+ // 4 in `templateAttrExpr` (boolean / style / data-key / general attr)
525
+ // + 4 `${spreadAttrs(...)}` sites. Every other hole goes through
526
+ // `interp(...)` in `safe-html.ts`. Exact, not "at most": a lower count
527
+ // is progress and the pin should follow it down.
528
+ expect(count('html-template.ts', '\\${')).toBe(8)
529
+ // `itemAnchorTemplate` builds its `${escapeCommentText(KEY)}` by
530
+ // concatenation — the one hole the substring above cannot see. Escaped
531
+ // (#2795 follow-up) — see `escapeCommentText`'s docstring.
532
+ expect(count('html-template.ts', "'${escapeCommentText(' +")).toBe(1)
533
+ })
534
+
535
+ test('safe-html.ts has exactly one `${` — the `interp` door', () => {
536
+ expect(count('safe-html.ts', '\\${')).toBe(1)
537
+ })
538
+ })
@@ -142,8 +142,10 @@ describe('hydrate template: loop param shadowing an outer name (#2222 bug 2)', (
142
142
  }
143
143
  `))
144
144
 
145
- // Outside the loop: const inlining still applies.
146
- expect(tpl).toContain("${('x')}")
145
+ // Outside the loop: const inlining still applies — and the inlined
146
+ // literal is escaped like any other child-position value (#2795; it
147
+ // used to reach the template raw as `${('x')}`).
148
+ expect(tpl).toContain("${escapeText(('x'))}")
147
149
  // Inside the loop: the shadowing param must survive untouched.
148
150
  expect(tpl).toContain('escapeText(1 + label)')
149
151
  expect(tpl).not.toContain("1 + ('x')")
@@ -37,7 +37,7 @@ export { F }
37
37
  const r = compileJSX(src, 'F.tsx', { adapter: new TestAdapter() })
38
38
  expect(r.errors).toHaveLength(0)
39
39
  const cj = r.files.find(f => f.type === 'clientJs')!.content
40
- expect(cj).toMatch(/escapeText\(\(t\)\)/)
40
+ expect(cj).toMatch(/escapeText\(t\)/)
41
41
  expect(cj).not.toMatch(/__BF_JSX_/)
42
42
  })
43
43