@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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +344 -215
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-branch-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-component-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts +11 -0
- package/dist/ir-to-client-js/control-flow/plan/build-lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts +52 -0
- package/dist/ir-to-client-js/control-flow/plan/build-plain-row.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +2 -0
- package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts +6 -7
- package/dist/ir-to-client-js/control-flow/plan/lazy-conditional.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts +5 -7
- package/dist/ir-to-client-js/control-flow/plan/lazy-preamble.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/lazy-row-eligibility.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +10 -6
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/shared.d.ts +11 -9
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/composite-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/lazy-row.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +16 -0
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +2 -2
- package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
- package/dist/ir-to-client-js/imports.d.ts +2 -2
- package/dist/ir-to-client-js/imports.d.ts.map +1 -1
- package/dist/ir-to-client-js/index.d.ts.map +1 -1
- package/dist/ir-to-client-js/markup-slots.d.ts +21 -0
- package/dist/ir-to-client-js/markup-slots.d.ts.map +1 -0
- package/dist/ir-to-client-js/safe-html.d.ts +158 -0
- package/dist/ir-to-client-js/safe-html.d.ts.map +1 -0
- package/dist/ir-to-client-js/utils.d.ts +34 -1
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/prop-rewrite.d.ts +8 -3
- package/dist/prop-rewrite.d.ts.map +1 -1
- package/dist/props-binding.d.ts +22 -1
- package/dist/props-binding.d.ts.map +1 -1
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/types.d.ts +5 -10
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +10 -12
- package/src/__tests__/build-plain-row-consolidation.test.ts +120 -0
- package/src/__tests__/client-template-escape-soundness.test.ts +538 -0
- package/src/__tests__/csr-template-loop-shadowing.test.ts +4 -2
- package/src/__tests__/flatmap-segments.test.ts +1 -1
- package/src/__tests__/lazy-conditional.test.ts +52 -12
- package/src/__tests__/lazy-preamble.test.ts +11 -11
- package/src/__tests__/lazy-row-eligibility.test.ts +8 -3
- package/src/__tests__/loop-child-reactive-attr-const-shadow.test.ts +25 -3
- package/src/__tests__/loop-item-conditional-codegen.test.ts +4 -2
- package/src/__tests__/map-arbitrary-body.test.ts +1 -1
- package/src/__tests__/nested-loop-index-param.test.ts +14 -3
- package/src/__tests__/nested-loop-reactive-attrs.test.ts +4 -2
- package/src/__tests__/reactive-attrs-in-map.test.ts +13 -10
- package/src/__tests__/rewrite-destructured-props.test.ts +37 -0
- package/src/__tests__/safe-html-splice-door.test.ts +61 -0
- package/src/__tests__/ssr-defaults.test.ts +59 -0
- package/src/analyzer.ts +4 -0
- package/src/index.ts +8 -2
- package/src/ir-to-client-js/collect-elements.ts +18 -2
- package/src/ir-to-client-js/control-flow/plan/build-branch-loop.ts +20 -28
- package/src/ir-to-client-js/control-flow/plan/build-component-loop.ts +9 -7
- package/src/ir-to-client-js/control-flow/plan/build-composite-loop.ts +10 -6
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +8 -8
- package/src/ir-to-client-js/control-flow/plan/build-lazy-row.ts +18 -2
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +3 -3
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +35 -35
- package/src/ir-to-client-js/control-flow/plan/build-plain-row.ts +110 -0
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +5 -2
- package/src/ir-to-client-js/control-flow/plan/lazy-conditional.ts +11 -10
- package/src/ir-to-client-js/control-flow/plan/lazy-preamble.ts +11 -13
- package/src/ir-to-client-js/control-flow/plan/lazy-row-eligibility.ts +13 -3
- package/src/ir-to-client-js/control-flow/plan/loop.ts +10 -6
- package/src/ir-to-client-js/control-flow/shared.ts +40 -17
- package/src/ir-to-client-js/control-flow/stringify/composite-loop.ts +2 -1
- package/src/ir-to-client-js/control-flow/stringify/lazy-row.ts +10 -0
- package/src/ir-to-client-js/csr-substitute.ts +29 -0
- package/src/ir-to-client-js/emit-reactive.ts +4 -1
- package/src/ir-to-client-js/emit-registration.ts +2 -4
- package/src/ir-to-client-js/html-template.ts +58 -244
- package/src/ir-to-client-js/imports.ts +3 -0
- package/src/ir-to-client-js/index.ts +4 -4
- package/src/ir-to-client-js/markup-slots.ts +25 -0
- package/src/ir-to-client-js/safe-html.ts +243 -0
- package/src/ir-to-client-js/utils.ts +58 -10
- package/src/jsx-to-ir.ts +115 -20
- package/src/prop-rewrite.ts +8 -4
- package/src/props-binding.ts +37 -1
- package/src/ssr-defaults.ts +45 -0
- package/src/types.ts +5 -10
- 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
|
-
|
|
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\(
|
|
40
|
+
expect(cj).toMatch(/escapeText\(t\)/)
|
|
41
41
|
expect(cj).not.toMatch(/__BF_JSX_/)
|
|
42
42
|
})
|
|
43
43
|
|