@barefootjs/jsx 0.18.4 → 0.18.7
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/adapters/dangerous-inner-html.d.ts +77 -0
- package/dist/adapters/dangerous-inner-html.d.ts.map +1 -0
- package/dist/adapters/loop-bound-names.d.ts +22 -0
- package/dist/adapters/loop-bound-names.d.ts.map +1 -0
- package/dist/adapters/parsed-expr-emitter.d.ts +2 -2
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +2 -1
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1108 -307
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-inner-loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.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/event-delegation.d.ts +10 -0
- package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +9 -0
- package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
- 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 +45 -3
- package/dist/ir-to-client-js/control-flow/shared.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/event-delegation.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts +10 -0
- package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts +38 -0
- package/dist/ir-to-client-js/control-flow/stringify/skeleton-paths.d.ts.map +1 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
- package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
- package/dist/ir-to-client-js/csr-substitute.d.ts +24 -0
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +42 -0
- 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/plan/static-array-child-init.d.ts +20 -1
- package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/stringify/static-array-child-init.d.ts +1 -1
- package/dist/ir-to-client-js/types.d.ts +30 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/ir-to-client-js/utils.d.ts +25 -0
- package/dist/ir-to-client-js/utils.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/signal-init-eval.d.ts +82 -0
- package/dist/signal-init-eval.d.ts.map +1 -0
- package/dist/static-literal.d.ts +45 -0
- package/dist/static-literal.d.ts.map +1 -0
- package/dist/types.d.ts +66 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +19 -13
- package/src/__tests__/create-selector.test.ts +110 -0
- package/src/__tests__/csr-template-loop-shadowing.test.ts +214 -0
- package/src/__tests__/dangerous-inner-html-resolver.test.ts +197 -0
- package/src/__tests__/event-delegation-index-param.test.ts +130 -0
- package/src/__tests__/expression-parser.test.ts +38 -0
- package/src/__tests__/inner-loop-svg-namespace.test.ts +245 -0
- package/src/__tests__/ir-walker.test.ts +1 -0
- package/src/__tests__/loop-bound-names.test.ts +102 -0
- package/src/__tests__/loop-plan-classification.test.ts +2 -0
- package/src/__tests__/map-function-reference.test.ts +303 -0
- package/src/__tests__/materialize-getter-calls.test.ts +1 -0
- package/src/__tests__/nested-loop-index-param.test.ts +285 -0
- package/src/__tests__/signal-init-eval.test.ts +138 -0
- package/src/__tests__/static-array-inner-loop-index-param.test.ts +180 -0
- package/src/__tests__/static-literal.test.ts +126 -0
- package/src/__tests__/string-concat-identifier.test.ts +55 -0
- package/src/adapters/dangerous-inner-html.ts +186 -0
- package/src/adapters/loop-bound-names.ts +80 -0
- package/src/adapters/parsed-expr-emitter.ts +19 -1
- package/src/analyzer.ts +6 -0
- package/src/expression-parser.ts +59 -25
- package/src/index.ts +19 -3
- package/src/ir-to-client-js/collect-elements.ts +18 -3
- package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +6 -0
- package/src/ir-to-client-js/control-flow/plan/build-inner-loop.ts +21 -4
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +13 -1
- package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
- package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +10 -0
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +9 -0
- package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
- package/src/ir-to-client-js/control-flow/shared.ts +103 -6
- package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +31 -7
- package/src/ir-to-client-js/control-flow/stringify/inner-loop.ts +11 -2
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +5 -0
- package/src/ir-to-client-js/control-flow/stringify/loop.ts +53 -10
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +25 -4
- package/src/ir-to-client-js/control-flow/stringify/skeleton-paths.ts +70 -0
- package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +25 -1
- package/src/ir-to-client-js/csr-substitute.ts +54 -1
- package/src/ir-to-client-js/html-template.ts +292 -13
- package/src/ir-to-client-js/imports.ts +1 -0
- package/src/ir-to-client-js/plan/build-static-array-child-init.ts +12 -1
- package/src/ir-to-client-js/plan/static-array-child-init.ts +20 -1
- package/src/ir-to-client-js/reactivity.ts +6 -0
- package/src/ir-to-client-js/stringify/static-array-child-init.ts +11 -6
- package/src/ir-to-client-js/types.ts +31 -0
- package/src/ir-to-client-js/utils.ts +34 -1
- package/src/jsx-to-ir.ts +322 -48
- package/src/signal-init-eval.ts +165 -0
- package/src/static-literal.ts +128 -0
- package/src/types.ts +68 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createSelector` compiler recognition (perf, #2143 gap 5).
|
|
3
|
+
*
|
|
4
|
+
* `createSelector`'s returned accessor is `Reactive<(key) => boolean>`
|
|
5
|
+
* branded exactly like a signal/memo getter, so the existing type-based
|
|
6
|
+
* reactivity analyzer (`reactivity-checker.ts`) recognises a call like
|
|
7
|
+
* `isSelected(row.id)` as reactive with no analyzer changes — the callee's
|
|
8
|
+
* type carries the brand, not the call's return type. These tests pin:
|
|
9
|
+
* - the classic js-framework-benchmark row compiles with `isSelected(row.id)`
|
|
10
|
+
* wired to a single per-row `createEffect` (not the legacy
|
|
11
|
+
* `selected() === row.id` shape, which would still work but subscribes
|
|
12
|
+
* every row to the raw signal)
|
|
13
|
+
* - the loop-param accessor wrap applies only to the call ARGUMENT
|
|
14
|
+
* (`row.id` -> `row().id`), leaving the `isSelected` callee untouched
|
|
15
|
+
* - the hoisted skeleton fast path (#2223) still fires for a selector-driven
|
|
16
|
+
* attr, since it's covered by a loop-child reactive-attr effect like any
|
|
17
|
+
* other dynamic attribute
|
|
18
|
+
* - `createSelector` is pulled into the runtime import line
|
|
19
|
+
* - the attr is auto-deferred out of the SSR-mirror `template:` lambda
|
|
20
|
+
* (mechanism #1638, same as `@barefootjs/form` accessors) — pinned as
|
|
21
|
+
* documented behavior, not a regression: full SSR parity for selector
|
|
22
|
+
* calls is a known follow-up, not in scope here
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, test, expect } from 'bun:test'
|
|
26
|
+
import { compileJSX } from '../compiler'
|
|
27
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
28
|
+
|
|
29
|
+
const adapter = new TestAdapter()
|
|
30
|
+
|
|
31
|
+
function compile(source: string, filename: string): { clientJs: string; template: string; errors: readonly { severity: string }[] } {
|
|
32
|
+
const result = compileJSX(source, filename, { adapter })
|
|
33
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
34
|
+
const template = result.files.find(f => f.type === 'markedTemplate')
|
|
35
|
+
return { clientJs: clientJs?.content ?? '', template: template?.content ?? '', errors: result.errors }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('createSelector (perf, #2143 gap 5)', () => {
|
|
39
|
+
test('isSelected(row.id) in a loop body compiles to one per-row createEffect', () => {
|
|
40
|
+
const source = `
|
|
41
|
+
'use client'
|
|
42
|
+
import { createSignal, createSelector } from '@barefootjs/client'
|
|
43
|
+
|
|
44
|
+
interface RowData { id: number; label: string }
|
|
45
|
+
|
|
46
|
+
export function Bench() {
|
|
47
|
+
const [rows, setRows] = createSignal<RowData[]>([])
|
|
48
|
+
const [selected, setSelected] = createSignal<number>(0)
|
|
49
|
+
const isSelected = createSelector(selected)
|
|
50
|
+
return (
|
|
51
|
+
<table>
|
|
52
|
+
<tbody>
|
|
53
|
+
{rows().map(row => (
|
|
54
|
+
<tr key={row.id} className={isSelected(row.id) ? 'danger' : ''}></tr>
|
|
55
|
+
))}
|
|
56
|
+
</tbody>
|
|
57
|
+
</table>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
`
|
|
61
|
+
const { clientJs, errors } = compile(source, 'Bench.tsx')
|
|
62
|
+
expect(errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
63
|
+
|
|
64
|
+
// Loop-param wrap applies to the call ARGUMENT only.
|
|
65
|
+
expect(clientJs).toContain('isSelected(row().id)')
|
|
66
|
+
// The callee itself is never rewritten to an accessor call.
|
|
67
|
+
expect(clientJs).not.toContain('isSelected()(')
|
|
68
|
+
|
|
69
|
+
// Exactly one createEffect wraps the selector-driven class attribute —
|
|
70
|
+
// not the O(rows) `selected() === row.id` shape.
|
|
71
|
+
const effectCount = (clientJs.match(/createEffect\(/g) ?? []).length
|
|
72
|
+
expect(effectCount).toBe(1)
|
|
73
|
+
expect(clientJs).not.toContain('selected() ===')
|
|
74
|
+
|
|
75
|
+
// The hoisted skeleton fast path (#2223) still fires: a single-line
|
|
76
|
+
// renderItem clones from a once-per-loop template.
|
|
77
|
+
expect(clientJs).toMatch(/const __tpl_\w+ = document\.createElement\('template'\)/)
|
|
78
|
+
|
|
79
|
+
// createSelector is pulled into the runtime import line.
|
|
80
|
+
expect(clientJs).toMatch(/import \{[^}]*\bcreateSelector\b[^}]*\} from '@barefootjs\/client\/runtime'/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('selector-driven attr is deferred out of the SSR-mirror template lambda (documented, not a regression)', () => {
|
|
84
|
+
const source = `
|
|
85
|
+
'use client'
|
|
86
|
+
import { createSignal, createSelector } from '@barefootjs/client'
|
|
87
|
+
|
|
88
|
+
interface RowData { id: number; label: string }
|
|
89
|
+
|
|
90
|
+
export function Bench() {
|
|
91
|
+
const [rows, setRows] = createSignal<RowData[]>([])
|
|
92
|
+
const [selected, setSelected] = createSignal<number>(0)
|
|
93
|
+
const isSelected = createSelector(selected)
|
|
94
|
+
return (
|
|
95
|
+
<ul>
|
|
96
|
+
{rows().map(row => (
|
|
97
|
+
<li key={row.id} className={isSelected(row.id) ? 'danger' : ''}>{row.label}</li>
|
|
98
|
+
))}
|
|
99
|
+
</ul>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
`
|
|
103
|
+
const { clientJs } = compile(source, 'BenchDefer.tsx')
|
|
104
|
+
const csrMirror = clientJs.slice(clientJs.indexOf('hydrate('))
|
|
105
|
+
// The class attribute never appears as a literal `class="..."` write in
|
|
106
|
+
// the SSR-mirror template string — it's covered entirely by the
|
|
107
|
+
// per-row createEffect, matching the documented auto-defer mechanism.
|
|
108
|
+
expect(csrMirror).not.toMatch(/class="danger"/)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSR/client-JS codegen vs loop-param shadowing (#2222).
|
|
3
|
+
*
|
|
4
|
+
* Two related bugs in the `hydrate(...)` `template:` lambda (a
|
|
5
|
+
* module-scope arrow, sibling to `initXxx` — NOT a closure over init's
|
|
6
|
+
* destructured locals):
|
|
7
|
+
*
|
|
8
|
+
* 1. When the loop's array source is a destructured prop, the lambda
|
|
9
|
+
* referenced the bare destructured name (`values.map(...)`) instead of
|
|
10
|
+
* `_p.values` — `ReferenceError: values is not defined` at runtime.
|
|
11
|
+
* Root cause: `transformMapCall`'s plain-`.map()` fallback and the
|
|
12
|
+
* simple-`filter().map()` path bypassed `setArray`, so
|
|
13
|
+
* `IRLoop.templateArray` (the `_p.`-rewritten form the chained paths
|
|
14
|
+
* already produce) stayed `undefined`.
|
|
15
|
+
*
|
|
16
|
+
* 2. Inside a loop callback body in that same lambda, prop/const
|
|
17
|
+
* substitution was scope-blind: an outer destructured prop
|
|
18
|
+
* (`_p.label`) or an inlinable const's literal got substituted at an
|
|
19
|
+
* occurrence that is actually the loop's own shadowing parameter.
|
|
20
|
+
* Same bug class as #2221, different code paths:
|
|
21
|
+
* - prop-shadow: `rewriteBarePropRefs` (IR build) collected prop
|
|
22
|
+
* refs from the expression node alone, never seeing the enclosing
|
|
23
|
+
* loop binding. Fixed scope-accurately via `ctx.loopParams` — the
|
|
24
|
+
* transform position's live loop-param set (includes destructured
|
|
25
|
+
* binding names and the index param).
|
|
26
|
+
* - const-shadow: `irToComponentTemplateWithOpts`'s `transformExpr`
|
|
27
|
+
* regex-inlined `inlinableConstants` across the whole expression
|
|
28
|
+
* text. Fixed by threading the enclosing loops' bound names
|
|
29
|
+
* through `TemplateOptions` and skipping those names.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, test, expect } from 'bun:test'
|
|
33
|
+
import { compileJSX } from '../compiler'
|
|
34
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
35
|
+
|
|
36
|
+
const adapter = new TestAdapter()
|
|
37
|
+
|
|
38
|
+
function clientJsFor(source: string): string {
|
|
39
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
40
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
41
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
42
|
+
expect(clientJs).toBeDefined()
|
|
43
|
+
return clientJs!.content
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function templateLambda(content: string): string {
|
|
47
|
+
const line = content.split('\n').find(l => l.includes('hydrate('))
|
|
48
|
+
expect(line).toBeDefined()
|
|
49
|
+
return line!
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('hydrate template: destructured-prop loop array source (#2222 bug 1)', () => {
|
|
53
|
+
test('plain .map() over a destructured prop references _p.<name>', () => {
|
|
54
|
+
const tpl = templateLambda(clientJsFor(`
|
|
55
|
+
'use client'
|
|
56
|
+
import { createSignal } from '@barefootjs/client'
|
|
57
|
+
export function Widget({ values }: { values: number[] }) {
|
|
58
|
+
const [n, setN] = createSignal(0)
|
|
59
|
+
return (
|
|
60
|
+
<ul data-n={n()} onClick={() => setN(n() + 1)}>
|
|
61
|
+
{values.map((v) => (
|
|
62
|
+
<li key={v}>{v * 2}</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
`))
|
|
68
|
+
|
|
69
|
+
expect(tpl).toContain('_p.values.map((v)')
|
|
70
|
+
expect(tpl).not.toContain('{values.map')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('simple filter().map() over a destructured prop references _p.<name>', () => {
|
|
74
|
+
const tpl = templateLambda(clientJsFor(`
|
|
75
|
+
'use client'
|
|
76
|
+
import { createSignal } from '@barefootjs/client'
|
|
77
|
+
export function Widget({ values }: { values: number[] }) {
|
|
78
|
+
const [n, setN] = createSignal(0)
|
|
79
|
+
return (
|
|
80
|
+
<ul data-n={n()} onClick={() => setN(n() + 1)}>
|
|
81
|
+
{values.filter((v) => v > 0).map((v) => (
|
|
82
|
+
<li key={v}>{v * 2}</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
`))
|
|
88
|
+
|
|
89
|
+
expect(tpl).toContain('_p.values')
|
|
90
|
+
expect(tpl).not.toMatch(/[^.]\bvalues\.filter/)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('hydrate template: loop param shadowing an outer name (#2222 bug 2)', () => {
|
|
95
|
+
test('prop-shadow: the loop body uses the param, not _p.<name>; outside the loop keeps _p.<name>', () => {
|
|
96
|
+
const tpl = templateLambda(clientJsFor(`
|
|
97
|
+
'use client'
|
|
98
|
+
import { createSignal } from '@barefootjs/client'
|
|
99
|
+
export function Widget2({ label, values }: { label: string; values: number[] }) {
|
|
100
|
+
const [n, setN] = createSignal(0)
|
|
101
|
+
return (
|
|
102
|
+
<div data-n={n()} onClick={() => setN(n() + 1)}>
|
|
103
|
+
<p>{label}</p>
|
|
104
|
+
<ul>
|
|
105
|
+
{values.map((label) => (
|
|
106
|
+
<li key={label}>{1 + label}</li>
|
|
107
|
+
))}
|
|
108
|
+
</ul>
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
`))
|
|
113
|
+
|
|
114
|
+
// Outside the loop: the prop rewrite applies.
|
|
115
|
+
expect(tpl).toContain('escapeText(_p.label)')
|
|
116
|
+
// Inside the loop: the shadowing param must survive untouched.
|
|
117
|
+
expect(tpl).toContain('escapeText(1 + label)')
|
|
118
|
+
expect(tpl).not.toContain('1 + _p.label')
|
|
119
|
+
expect(tpl).not.toContain('data-key="${_p.label}"')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('const-shadow: the loop body uses the param, not the inlined literal; outside the loop keeps the inline', () => {
|
|
123
|
+
const tpl = templateLambda(clientJsFor(`
|
|
124
|
+
'use client'
|
|
125
|
+
import { createSignal } from '@barefootjs/client'
|
|
126
|
+
export function Widget3({ values }: { values: number[] }) {
|
|
127
|
+
const label: string = 'x'
|
|
128
|
+
const [n, setN] = createSignal(0)
|
|
129
|
+
return (
|
|
130
|
+
<div data-n={n()} onClick={() => setN(n() + 1)}>
|
|
131
|
+
<p>{label}</p>
|
|
132
|
+
<ul>
|
|
133
|
+
{values.map((label) => (
|
|
134
|
+
<li key={label}>{1 + label}</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
</div>
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
`))
|
|
141
|
+
|
|
142
|
+
// Outside the loop: const inlining still applies.
|
|
143
|
+
expect(tpl).toContain("${('x')}")
|
|
144
|
+
// Inside the loop: the shadowing param must survive untouched.
|
|
145
|
+
expect(tpl).toContain('escapeText(1 + label)')
|
|
146
|
+
expect(tpl).not.toContain("1 + ('x')")
|
|
147
|
+
expect(tpl).not.toContain('data-key="${`x`}"')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test('destructured loop param shadowing a prop is guarded via paramBindings names', () => {
|
|
151
|
+
const tpl = templateLambda(clientJsFor(`
|
|
152
|
+
'use client'
|
|
153
|
+
import { createSignal } from '@barefootjs/client'
|
|
154
|
+
export function Widget4({ name, rows }: { name: string; rows: { name: number }[] }) {
|
|
155
|
+
const [n, setN] = createSignal(0)
|
|
156
|
+
return (
|
|
157
|
+
<ul data-n={n()} onClick={() => setN(n() + 1)}>
|
|
158
|
+
{rows.map(({ name }) => (
|
|
159
|
+
<li key={name}>{1 + name}</li>
|
|
160
|
+
))}
|
|
161
|
+
</ul>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
`))
|
|
165
|
+
|
|
166
|
+
expect(tpl).not.toContain('1 + _p.name')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('a Unicode loop param shadowing a const is still guarded (#2238 Copilot review)', () => {
|
|
170
|
+
const tpl = templateLambda(clientJsFor(`
|
|
171
|
+
'use client'
|
|
172
|
+
import { createSignal } from '@barefootjs/client'
|
|
173
|
+
export function Widget6({ values }: { values: number[] }) {
|
|
174
|
+
const \u03c0: string = 'x'
|
|
175
|
+
const [n, setN] = createSignal(0)
|
|
176
|
+
return (
|
|
177
|
+
<div data-n={n()} onClick={() => setN(n() + 1)}>
|
|
178
|
+
<p>{\u03c0}</p>
|
|
179
|
+
<ul>
|
|
180
|
+
{values.map((\u03c0) => (
|
|
181
|
+
<li key={\u03c0}>{1 + \u03c0}</li>
|
|
182
|
+
))}
|
|
183
|
+
</ul>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
`))
|
|
188
|
+
|
|
189
|
+
// The Unicode param must be recognised as a bare identifier (not a
|
|
190
|
+
// destructure pattern), so the const never substitutes in the body.
|
|
191
|
+
expect(tpl).toContain('1 + \u03c0')
|
|
192
|
+
expect(tpl).not.toContain("1 + ('x')")
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('a non-shadowing prop used inside the loop still rewrites to _p.<name>', () => {
|
|
196
|
+
const tpl = templateLambda(clientJsFor(`
|
|
197
|
+
'use client'
|
|
198
|
+
import { createSignal } from '@barefootjs/client'
|
|
199
|
+
export function Widget5({ prefix, values }: { prefix: string; values: number[] }) {
|
|
200
|
+
const [n, setN] = createSignal(0)
|
|
201
|
+
return (
|
|
202
|
+
<ul data-n={n()} onClick={() => setN(n() + 1)}>
|
|
203
|
+
{values.map((v) => (
|
|
204
|
+
<li key={v}>{prefix + v}</li>
|
|
205
|
+
))}
|
|
206
|
+
</ul>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
`))
|
|
210
|
+
|
|
211
|
+
// A prop NOT shadowed by the loop param keeps its rewrite inside the body.
|
|
212
|
+
expect(tpl).toContain('_p.prefix + v')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { analyzeComponent } from '../analyzer'
|
|
3
|
+
import { jsxToIR } from '../jsx-to-ir'
|
|
4
|
+
import {
|
|
5
|
+
isDangerousInnerHtmlAttr,
|
|
6
|
+
resolveDangerousInnerHtml,
|
|
7
|
+
dangerousInnerHtmlMetacharViolation,
|
|
8
|
+
dangerousInnerHtmlDiagnostic,
|
|
9
|
+
} from '../adapters/dangerous-inner-html'
|
|
10
|
+
|
|
11
|
+
// #2207: the shared `dangerouslySetInnerHTML` resolver — recognition,
|
|
12
|
+
// static-literal extraction, and per-adapter template-metacharacter
|
|
13
|
+
// guarding all live in one place (packages/jsx/src/adapters/dangerous-inner-html.ts)
|
|
14
|
+
// so every template adapter shares identical, single-source-of-truth policy.
|
|
15
|
+
describe('resolveDangerousInnerHtml (#2207)', () => {
|
|
16
|
+
function rootElement(source: string) {
|
|
17
|
+
const ctx = analyzeComponent(source, 'Test.tsx')
|
|
18
|
+
const ir = jsxToIR(ctx)
|
|
19
|
+
if (!ir || ir.type !== 'element') throw new Error('expected a root element')
|
|
20
|
+
return ir
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('absent attribute resolves to null', () => {
|
|
24
|
+
const el = rootElement(`
|
|
25
|
+
function Test() { return <div>hi</div> }
|
|
26
|
+
export { Test }
|
|
27
|
+
`)
|
|
28
|
+
expect(resolveDangerousInnerHtml(el)).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('inline string literal resolves as static', () => {
|
|
32
|
+
const el = rootElement(`
|
|
33
|
+
function Test() { return <div dangerouslySetInnerHTML={{ __html: '<b>bold</b> & safe' }} /> }
|
|
34
|
+
export { Test }
|
|
35
|
+
`)
|
|
36
|
+
const resolution = resolveDangerousInnerHtml(el)
|
|
37
|
+
expect(resolution).toEqual({ kind: 'static', html: '<b>bold</b> & safe' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('string-key form ({ "__html": ... }) resolves identically to the identifier-key form', () => {
|
|
41
|
+
const el = rootElement(`
|
|
42
|
+
function Test() { return <div dangerouslySetInnerHTML={{ '__html': '<i>x</i>' }} /> }
|
|
43
|
+
export { Test }
|
|
44
|
+
`)
|
|
45
|
+
expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '<i>x</i>' })
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('signal/prop-derived value resolves as dynamic', () => {
|
|
49
|
+
const el = rootElement(`
|
|
50
|
+
function Test({ html }: { html: string }) { return <div dangerouslySetInnerHTML={{ __html: html }} /> }
|
|
51
|
+
export { Test }
|
|
52
|
+
`)
|
|
53
|
+
const resolution = resolveDangerousInnerHtml(el)
|
|
54
|
+
expect(resolution?.kind).toBe('dynamic')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('a no-substitution template literal resolves as static (identical to a string literal)', () => {
|
|
58
|
+
const el = rootElement(`
|
|
59
|
+
function Test() { return <div dangerouslySetInnerHTML={{ __html: \`<b>bold</b>\` }} /> }
|
|
60
|
+
export { Test }
|
|
61
|
+
`)
|
|
62
|
+
expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '<b>bold</b>' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('a template literal WITH a substitution resolves as dynamic — no const-folding in v1', () => {
|
|
66
|
+
const el = rootElement(`
|
|
67
|
+
function Test({ tag }: { tag: string }) { return <div dangerouslySetInnerHTML={{ __html: \`<\${tag}>bold</\${tag}>\` }} /> }
|
|
68
|
+
export { Test }
|
|
69
|
+
`)
|
|
70
|
+
expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('shorthand { __html } resolves as dynamic, not the literal named __html', () => {
|
|
74
|
+
const el = rootElement(`
|
|
75
|
+
function Test({ __html }: { __html: string }) { return <div dangerouslySetInnerHTML={{ __html }} /> }
|
|
76
|
+
export { Test }
|
|
77
|
+
`)
|
|
78
|
+
expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('local const reference resolves as dynamic — v1 requires an INLINE literal', () => {
|
|
82
|
+
const el = rootElement(`
|
|
83
|
+
const HTML = '<b>bold</b>'
|
|
84
|
+
function Test() { return <div dangerouslySetInnerHTML={{ __html: HTML }} /> }
|
|
85
|
+
export { Test }
|
|
86
|
+
`)
|
|
87
|
+
expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('extra property alongside __html resolves as dynamic', () => {
|
|
91
|
+
const el = rootElement(`
|
|
92
|
+
function Test() { return <div dangerouslySetInnerHTML={{ __html: '<b>x</b>', extra: 1 }} /> }
|
|
93
|
+
export { Test }
|
|
94
|
+
`)
|
|
95
|
+
expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('isDangerousInnerHtmlAttr only matches the exact prop name', () => {
|
|
99
|
+
const el = rootElement(`
|
|
100
|
+
function Test() { return <div data-foo="bar" dangerouslySetInnerHTML={{ __html: 'x' }} /> }
|
|
101
|
+
export { Test }
|
|
102
|
+
`)
|
|
103
|
+
const names = el.attrs.map(a => a.name)
|
|
104
|
+
expect(names.filter(n => isDangerousInnerHtmlAttr({ name: n } as never))).toEqual(['dangerouslySetInnerHTML'])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Fable review (#2217): a `/* @client */`-deferred value is already a
|
|
108
|
+
// working escape hatch — every adapter's own renderAttributes skips a
|
|
109
|
+
// clientOnly attr, and the client's createEffect-driven innerHTML
|
|
110
|
+
// assignment (emit-reactive.ts) runs regardless of SSR — so this module
|
|
111
|
+
// must treat it as "render normally" (null), not refuse with BF101.
|
|
112
|
+
// Refusing here was a real regression the review caught: it broke the
|
|
113
|
+
// only path that made this module's own suggestion text achievable.
|
|
114
|
+
test('/* @client */-deferred dangerouslySetInnerHTML resolves to null (render normally, defer to hydrate)', () => {
|
|
115
|
+
const el = rootElement(`
|
|
116
|
+
'use client'
|
|
117
|
+
import { createSignal } from '@barefootjs/client'
|
|
118
|
+
function Test() {
|
|
119
|
+
const [html, setHtml] = createSignal('<b>hi</b>')
|
|
120
|
+
return <div dangerouslySetInnerHTML={/* @client */ { __html: html() }} />
|
|
121
|
+
}
|
|
122
|
+
export { Test }
|
|
123
|
+
`)
|
|
124
|
+
expect(resolveDangerousInnerHtml(el)).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('empty-string literal resolves as static with an empty html value', () => {
|
|
128
|
+
const el = rootElement(`
|
|
129
|
+
function Test() { return <div dangerouslySetInnerHTML={{ __html: '' }} /> }
|
|
130
|
+
export { Test }
|
|
131
|
+
`)
|
|
132
|
+
expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '' })
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('dangerousInnerHtmlMetacharViolation (#2207)', () => {
|
|
137
|
+
test('plain HTML is safe on every adapter', () => {
|
|
138
|
+
for (const id of ['blade', 'erb', 'go-template', 'jinja', 'minijinja', 'mojolicious', 'twig', 'xslate']) {
|
|
139
|
+
expect(dangerousInnerHtmlMetacharViolation('<b>bold</b> & safe', id)).toBeNull()
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Fable review (#2217): a 9th template adapter with no entry in the guard
|
|
144
|
+
// table must fail CLOSED (refuse) rather than fail open (splice
|
|
145
|
+
// unguarded) — the safe default when nobody has verified that adapter's
|
|
146
|
+
// template syntax yet.
|
|
147
|
+
test('unknown adapter id fails CLOSED (refuses) rather than open', () => {
|
|
148
|
+
expect(dangerousInnerHtmlMetacharViolation('<b>plain html, no metachars</b>', 'not-a-real-adapter')).not.toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const cases: Array<[adapterId: string, html: string]> = [
|
|
152
|
+
['blade', '<b>{{ $evil }}</b>'],
|
|
153
|
+
['blade', '<b>{!! $evil !!}</b>'],
|
|
154
|
+
['blade', '<?php echo 1; ?>'],
|
|
155
|
+
['blade', '@if(true)x@endif'],
|
|
156
|
+
// Laravel's BladeCompiler compiles component tags by default — <x-foo>
|
|
157
|
+
// resolves and renders a live Blade component, not template
|
|
158
|
+
// substitution. The sharpest "not inert text" case in the table.
|
|
159
|
+
['blade', '<x-alert>evil</x-alert>'],
|
|
160
|
+
['blade', '</x-alert>'],
|
|
161
|
+
['erb', '<b><%= evil %></b>'],
|
|
162
|
+
['go-template', '<b>{{ .Evil }}</b>'],
|
|
163
|
+
['jinja', '<b>{{ evil }}</b>'],
|
|
164
|
+
['jinja', '<b>{% if x %}y{% endif %}</b>'],
|
|
165
|
+
['jinja', '<b>{# comment #}</b>'],
|
|
166
|
+
['minijinja', '<b>{{ evil }}</b>'],
|
|
167
|
+
['twig', '<b>{{ evil }}</b>'],
|
|
168
|
+
['mojolicious', '<b><%= evil %></b>'],
|
|
169
|
+
['mojolicious', '% my $x = 1;\n<b>hi</b>'],
|
|
170
|
+
// Leading whitespace (not just a bare line start) before the line-code
|
|
171
|
+
// marker, and the marker as the very first character of the string.
|
|
172
|
+
['mojolicious', ' % my $x = 1;\n<b>hi</b>'],
|
|
173
|
+
['mojolicious', '% my $x = 1;'],
|
|
174
|
+
['xslate', '<b><: $evil :></b>'],
|
|
175
|
+
['xslate', ': my $x = 1;\n<b>hi</b>'],
|
|
176
|
+
['xslate', ' : my $x = 1;\n<b>hi</b>'],
|
|
177
|
+
['xslate', ': my $x = 1;'],
|
|
178
|
+
// A literal that IS only the metachar sequence, nothing else.
|
|
179
|
+
['blade', '{{'],
|
|
180
|
+
['go-template', '{{'],
|
|
181
|
+
]
|
|
182
|
+
for (const [adapterId, html] of cases) {
|
|
183
|
+
test(`${adapterId} refuses ${JSON.stringify(html)}`, () => {
|
|
184
|
+
expect(dangerousInnerHtmlMetacharViolation(html, adapterId)).not.toBeNull()
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('dangerousInnerHtmlDiagnostic (#2207)', () => {
|
|
190
|
+
test('produces a BF101 with a purpose-built message', () => {
|
|
191
|
+
const diag = dangerousInnerHtmlDiagnostic('html', { file: 'Test.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } })
|
|
192
|
+
expect(diag.code).toBe('BF101')
|
|
193
|
+
expect(diag.severity).toBe('error')
|
|
194
|
+
expect(diag.message).toContain('dangerouslySetInnerHTML requires a compile-time string literal')
|
|
195
|
+
expect(diag.suggestion?.message).toContain('2215')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delegated handlers closing over the `.map((item, i) => ...)` index param
|
|
3
|
+
* (#2189). The dispatcher re-derives the index at dispatch time and binds it
|
|
4
|
+
* under the user's name, so a handler like `() => handle(i)` no longer throws
|
|
5
|
+
* `ReferenceError: i is not defined`. Gating is byte-for-byte free of churn
|
|
6
|
+
* when the handler doesn't reference the index.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from 'bun:test'
|
|
10
|
+
import { compileJSX } from '../compiler'
|
|
11
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
12
|
+
|
|
13
|
+
const adapter = new TestAdapter()
|
|
14
|
+
|
|
15
|
+
function clientJsFor(source: string): string {
|
|
16
|
+
const result = compileJSX(source, 'Repro.tsx', { adapter })
|
|
17
|
+
expect(result.errors).toHaveLength(0)
|
|
18
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
19
|
+
expect(clientJs).toBeDefined()
|
|
20
|
+
return clientJs!.content
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('delegated handler closing over the .map() index (#2189)', () => {
|
|
24
|
+
test('keyed loop: index is re-derived via findIndex and bound', () => {
|
|
25
|
+
const content = clientJsFor(`
|
|
26
|
+
'use client'
|
|
27
|
+
import { createSignal } from '@barefootjs/client'
|
|
28
|
+
interface Item { id: number; label: string }
|
|
29
|
+
export function Repro() {
|
|
30
|
+
const [items] = createSignal<Item[]>([])
|
|
31
|
+
const handle = (index: number) => { console.log(index) }
|
|
32
|
+
return (
|
|
33
|
+
<ul>
|
|
34
|
+
{items().map((item, i) => (
|
|
35
|
+
<li key={item.id}>
|
|
36
|
+
<button onClick={() => handle(i)}>{item.label}</button>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
`)
|
|
43
|
+
|
|
44
|
+
// The index param `i` must be bound inside the delegation dispatcher.
|
|
45
|
+
expect(content).toContain('const i = items().findIndex(item => String(item.id) === key)')
|
|
46
|
+
// ...and the handler is still invoked with the synthetic event.
|
|
47
|
+
expect(content).toContain('(() => handle(i))(__bfEvt)')
|
|
48
|
+
// No dangling reference: `i` is declared before the handler call runs.
|
|
49
|
+
const idxDecl = content.indexOf('const i = items().findIndex')
|
|
50
|
+
const idxCall = content.indexOf('handle(i))(__bfEvt)')
|
|
51
|
+
expect(idxDecl).toBeGreaterThanOrEqual(0)
|
|
52
|
+
expect(idxCall).toBeGreaterThan(idxDecl)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('index binding is omitted when no handler references the index', () => {
|
|
56
|
+
const content = clientJsFor(`
|
|
57
|
+
'use client'
|
|
58
|
+
import { createSignal } from '@barefootjs/client'
|
|
59
|
+
interface Item { id: number; label: string }
|
|
60
|
+
export function Repro() {
|
|
61
|
+
const [items] = createSignal<Item[]>([])
|
|
62
|
+
const handle = (id: number) => { console.log(id) }
|
|
63
|
+
return (
|
|
64
|
+
<ul>
|
|
65
|
+
{items().map((item, i) => (
|
|
66
|
+
<li key={item.id}>
|
|
67
|
+
<button onClick={() => handle(item.id)}>{item.label}</button>
|
|
68
|
+
</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
`)
|
|
74
|
+
|
|
75
|
+
// Nothing references `i`, so the dispatcher must not emit an index binding.
|
|
76
|
+
expect(content).not.toContain('const i = items().findIndex')
|
|
77
|
+
expect(content).toContain('(() => handle(item.id))(__bfEvt)')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('index named like a property is not bound when only the property is used', () => {
|
|
81
|
+
// Index param `id` shadows nothing here — the handler reads `item.id`, a
|
|
82
|
+
// property, not the index. Gating is AST-based (free identifiers), so no
|
|
83
|
+
// spurious binding is emitted.
|
|
84
|
+
const content = clientJsFor(`
|
|
85
|
+
'use client'
|
|
86
|
+
import { createSignal } from '@barefootjs/client'
|
|
87
|
+
interface Item { id: number; label: string }
|
|
88
|
+
export function Repro() {
|
|
89
|
+
const [items] = createSignal<Item[]>([])
|
|
90
|
+
const handle = (n: number) => { console.log(n) }
|
|
91
|
+
return (
|
|
92
|
+
<ul>
|
|
93
|
+
{items().map((item, id) => (
|
|
94
|
+
<li key={item.id}>
|
|
95
|
+
<button onClick={() => handle(item.id)}>{item.label}</button>
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
`)
|
|
102
|
+
|
|
103
|
+
expect(content).not.toContain('.findIndex(')
|
|
104
|
+
expect(content).toContain('(() => handle(item.id))(__bfEvt)')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('index param also works alongside item access in the same handler', () => {
|
|
108
|
+
const content = clientJsFor(`
|
|
109
|
+
'use client'
|
|
110
|
+
import { createSignal } from '@barefootjs/client'
|
|
111
|
+
interface Item { id: number; label: string }
|
|
112
|
+
export function Repro() {
|
|
113
|
+
const [items] = createSignal<Item[]>([])
|
|
114
|
+
const handle = (id: number, index: number) => { console.log(id, index) }
|
|
115
|
+
return (
|
|
116
|
+
<ul>
|
|
117
|
+
{items().map((item, i) => (
|
|
118
|
+
<li key={item.id}>
|
|
119
|
+
<button onClick={() => handle(item.id, i)}>{item.label}</button>
|
|
120
|
+
</li>
|
|
121
|
+
))}
|
|
122
|
+
</ul>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
`)
|
|
126
|
+
|
|
127
|
+
expect(content).toContain('const i = items().findIndex(item => String(item.id) === key)')
|
|
128
|
+
expect(content).toContain('handle(item.id, i))(__bfEvt)')
|
|
129
|
+
})
|
|
130
|
+
})
|