@barefootjs/jsx 0.24.1 → 0.26.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/adapters/dangerous-inner-html.d.ts +52 -24
- package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +473 -165
- package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
- package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -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-reactive-effects.d.ts +9 -6
- 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/loop-child-arm.d.ts +11 -5
- 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/stringify/loop-child-arm.d.ts +27 -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/reactive-effects.d.ts.map +1 -1
- package/dist/ir-to-client-js/reactivity.d.ts +24 -2
- package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
- package/dist/ir-to-client-js/types.d.ts +13 -0
- package/dist/ir-to-client-js/types.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +31 -10
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +29 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
- package/src/__tests__/nested-loop-conditional.test.ts +133 -0
- package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
- package/src/__tests__/reactive-factory-cross-file.test.ts +833 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +527 -1
- package/src/__tests__/reactive-factory-rename-fidelity.test.ts +318 -0
- package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
- package/src/adapters/dangerous-inner-html.ts +101 -48
- package/src/analyzer.ts +607 -142
- package/src/errors.ts +4 -0
- package/src/ir-to-client-js/collect-elements.ts +28 -2
- package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
- package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
- package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
- package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
- package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
- package/src/ir-to-client-js/reactivity.ts +61 -7
- package/src/ir-to-client-js/types.ts +13 -0
- package/src/rich-type-refusal.ts +3 -2
- package/src/to-locale-date-lowering.ts +50 -13
- package/src/types.ts +30 -5
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for #2341 BUG-1: reactive-factory call-site inlining used
|
|
3
|
+
* to rename identifiers with a whole-body regex (`\bname\b` -> `name_bf0`,
|
|
4
|
+
* applied three times in sequence for suffix-renaming, param substitution,
|
|
5
|
+
* and return->caller renaming). A regex has no notion of AST position, so
|
|
6
|
+
* it also matched string-literal contents, template-literal text chunks,
|
|
7
|
+
* object/property-access keys, and JSX intrinsic tags that merely happened
|
|
8
|
+
* to spell the same identifier — corrupting them.
|
|
9
|
+
*
|
|
10
|
+
* Fix: collect exact identifier-node rename sites once at detection time
|
|
11
|
+
* (`detectReactiveFactory`), then apply a single merged bottom-to-top
|
|
12
|
+
* position splice at inline time (`inlineFactoryCallAtSite`) instead of any
|
|
13
|
+
* text search. This file pins the corruption classes the old regex produced
|
|
14
|
+
* and the BF114 diagnostic for the one shape the new approach must decline
|
|
15
|
+
* rather than silently substitute (a factory parameter re-declared inside
|
|
16
|
+
* the body).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from 'bun:test'
|
|
20
|
+
import { analyzeComponent } from '../analyzer'
|
|
21
|
+
import { compileJSX } from '../compiler'
|
|
22
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
23
|
+
|
|
24
|
+
const adapter = new TestAdapter()
|
|
25
|
+
|
|
26
|
+
describe('AST-position-based rename fidelity (#2341 BUG-1)', () => {
|
|
27
|
+
test('R1: string-literal argument sharing a local-binding name is preserved', () => {
|
|
28
|
+
// `stored` must feed into `createSignal` (not merely sit unused) so the
|
|
29
|
+
// general local-constant retention pass keeps the statement — an
|
|
30
|
+
// unrelated, pre-existing behavior of the component-body pipeline, out
|
|
31
|
+
// of scope for #2341.
|
|
32
|
+
const source = `
|
|
33
|
+
'use client'
|
|
34
|
+
import { createSignal } from '@barefootjs/client'
|
|
35
|
+
|
|
36
|
+
function createTheme(initial: string) {
|
|
37
|
+
const stored = localStorage.getItem('stored') ?? initial
|
|
38
|
+
const [theme, setTheme] = createSignal(stored)
|
|
39
|
+
return [theme, setTheme] as const
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function App() {
|
|
43
|
+
const [theme, setTheme] = createTheme('light')
|
|
44
|
+
return <button onClick={() => setTheme('dark')}>{theme()}</button>
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
49
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
50
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
51
|
+
// The string literal argument to getItem must stay untouched...
|
|
52
|
+
expect(clientJs).toMatch(/localStorage\.getItem\('stored'\)/)
|
|
53
|
+
expect(clientJs).not.toMatch(/'stored_bf/)
|
|
54
|
+
// ...while the local binding declaration is still suffix-renamed.
|
|
55
|
+
expect(clientJs).toMatch(/stored_bf\d+ = localStorage/)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('R2: SSR template also preserves the string literal', () => {
|
|
59
|
+
const source = `
|
|
60
|
+
'use client'
|
|
61
|
+
import { createSignal } from '@barefootjs/client'
|
|
62
|
+
|
|
63
|
+
function createTheme(initial: string) {
|
|
64
|
+
const stored = localStorage.getItem('stored') ?? initial
|
|
65
|
+
const [theme, setTheme] = createSignal(stored)
|
|
66
|
+
return [theme, setTheme] as const
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function App() {
|
|
70
|
+
const [theme, setTheme] = createTheme('light')
|
|
71
|
+
return <button onClick={() => setTheme('dark')}>{theme()}</button>
|
|
72
|
+
}
|
|
73
|
+
`
|
|
74
|
+
|
|
75
|
+
const result = compileJSX(source, 'App.tsx', { adapter })
|
|
76
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
77
|
+
const template = result.files.find(f => f.type === 'markedTemplate')
|
|
78
|
+
expect(template).toBeDefined()
|
|
79
|
+
expect(template!.content).toContain("getItem('stored')")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('R3: template-literal text chunks are preserved, only substitutions rename', () => {
|
|
83
|
+
// Tuple factories require every return element to be destructured at
|
|
84
|
+
// the call site (arity must match), so all three elements are bound.
|
|
85
|
+
const source = `
|
|
86
|
+
'use client'
|
|
87
|
+
import { createSignal } from '@barefootjs/client'
|
|
88
|
+
|
|
89
|
+
function createLabelled(initial: string) {
|
|
90
|
+
const [value, setValue] = createSignal(initial)
|
|
91
|
+
const stored = initial
|
|
92
|
+
const label = \`\${stored} stored\`
|
|
93
|
+
return [value, setValue, label] as const
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function Labelled() {
|
|
97
|
+
const [value, setValue, label] = createLabelled('x')
|
|
98
|
+
return <p>{value()} {label}</p>
|
|
99
|
+
}
|
|
100
|
+
`
|
|
101
|
+
|
|
102
|
+
const result = compileJSX(source, 'Labelled.tsx', { adapter })
|
|
103
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
104
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
105
|
+
// The substitution `${stored}` renames with the local; the literal text
|
|
106
|
+
// chunk ` stored` after it must not.
|
|
107
|
+
expect(clientJs).toMatch(/`\$\{stored_bf\d+\} stored`/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('R4: shorthand property + string-literal argument expands to longhand (Repro B)', () => {
|
|
111
|
+
const source = `
|
|
112
|
+
'use client'
|
|
113
|
+
import { createSignal } from '@barefootjs/client'
|
|
114
|
+
|
|
115
|
+
function createUser(name: string) {
|
|
116
|
+
const [user, setUser] = createSignal({ name, loggedIn: false })
|
|
117
|
+
return [user, setUser] as const
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function Profile() {
|
|
121
|
+
const [user, setUser] = createUser('Alice')
|
|
122
|
+
return <p onClick={() => setUser({ name: 'Bob', loggedIn: true })}>{user().name}</p>
|
|
123
|
+
}
|
|
124
|
+
`
|
|
125
|
+
|
|
126
|
+
const result = compileJSX(source, 'Profile.tsx', { adapter })
|
|
127
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
128
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
129
|
+
expect(clientJs).toMatch(/\{\s*name:\s*'Alice',\s*loggedIn:\s*false\s*\}/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('R5: shorthand property with a suffix-renamed local keeps its key', () => {
|
|
133
|
+
const source = `
|
|
134
|
+
'use client'
|
|
135
|
+
import { createSignal } from '@barefootjs/client'
|
|
136
|
+
|
|
137
|
+
function createBox(initial: number) {
|
|
138
|
+
const size = initial + 1
|
|
139
|
+
const [box, setBox] = createSignal({ size })
|
|
140
|
+
return { box, setBox }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function BoxDisplay() {
|
|
144
|
+
const { box, setBox } = createBox(1)
|
|
145
|
+
return <p onClick={() => setBox({ size: 2 })}>{box().size}</p>
|
|
146
|
+
}
|
|
147
|
+
`
|
|
148
|
+
|
|
149
|
+
const result = compileJSX(source, 'BoxDisplay.tsx', { adapter })
|
|
150
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
151
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
152
|
+
expect(clientJs).toMatch(/\{\s*size:\s*size_bf\d+\s*\}/)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('R6: caller-rename of a return identifier leaves unrelated strings alone (Repro C)', () => {
|
|
156
|
+
const source = `
|
|
157
|
+
'use client'
|
|
158
|
+
import { createSignal } from '@barefootjs/client'
|
|
159
|
+
|
|
160
|
+
function createInput(initial: string) {
|
|
161
|
+
const [value, setValue] = createSignal(initial)
|
|
162
|
+
const update = (next: string) => {
|
|
163
|
+
if (next.length > 10) throw new Error('value too long')
|
|
164
|
+
setValue(next)
|
|
165
|
+
}
|
|
166
|
+
return [value, update] as const
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function Field() {
|
|
170
|
+
const [email, setEmail] = createInput('')
|
|
171
|
+
return <input value={email()} onInput={(e) => setEmail(e.target.value)} />
|
|
172
|
+
}
|
|
173
|
+
`
|
|
174
|
+
|
|
175
|
+
const result = compileJSX(source, 'Field.tsx', { adapter })
|
|
176
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
177
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
178
|
+
expect(clientJs).toContain("'value too long'")
|
|
179
|
+
expect(clientJs).not.toContain("throw new Error('email")
|
|
180
|
+
// The wrapper's declaration is renamed to the caller's setter name.
|
|
181
|
+
expect(clientJs).toMatch(/setEmail\s*=/)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('R7: object-pattern shorthand binding (nested callback parameter) expands and keeps its key', () => {
|
|
185
|
+
// The ObjectBindingPattern shape is exercised on a nested callback's own
|
|
186
|
+
// parameter — `({ pos }) => ...` — rather than a top-level `const { pos }
|
|
187
|
+
// = ...` body statement: the latter is dropped by an unrelated,
|
|
188
|
+
// pre-existing gap in the component-body local-constant pipeline (out of
|
|
189
|
+
// scope for #2341; confirmed to reproduce identically outside any
|
|
190
|
+
// factory). The callback parameter here deliberately shadows the
|
|
191
|
+
// factory's own `pos` binding, pinning the alpha-rename guarantee from
|
|
192
|
+
// the #2341 BUG-1 spec: the walker does not stop at nested-function
|
|
193
|
+
// boundaries, so the shadowing parameter and the outer binding rename
|
|
194
|
+
// identically and consistently.
|
|
195
|
+
const source = `
|
|
196
|
+
'use client'
|
|
197
|
+
import { createSignal } from '@barefootjs/client'
|
|
198
|
+
|
|
199
|
+
function createPoint(seed: number) {
|
|
200
|
+
const [pos, setPos] = createSignal(seed)
|
|
201
|
+
const moveTo = ({ pos }: { pos: number }) => setPos(pos)
|
|
202
|
+
return { pos, moveTo }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function PointDisplay() {
|
|
206
|
+
const { moveTo } = createPoint(0)
|
|
207
|
+
return <p onClick={() => moveTo({ pos: 5 })}>ok</p>
|
|
208
|
+
}
|
|
209
|
+
`
|
|
210
|
+
|
|
211
|
+
const result = compileJSX(source, 'PointDisplay.tsx', { adapter })
|
|
212
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
213
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
214
|
+
expect(clientJs).toMatch(/\{\s*pos:\s*pos_bf\d+\s*\}/)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('R8: property keys and .prop tails sharing a param name are untouched', () => {
|
|
218
|
+
const source = `
|
|
219
|
+
'use client'
|
|
220
|
+
import { createSignal } from '@barefootjs/client'
|
|
221
|
+
|
|
222
|
+
function createConfigured(initial: number) {
|
|
223
|
+
const config = { initial: 0 }
|
|
224
|
+
const [n, setN] = createSignal(config.initial ?? initial)
|
|
225
|
+
return [n, setN] as const
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function Counter() {
|
|
229
|
+
const [n, setN] = createConfigured(5)
|
|
230
|
+
return <button onClick={() => setN(n() + 1)}>{n()}</button>
|
|
231
|
+
}
|
|
232
|
+
`
|
|
233
|
+
|
|
234
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
235
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
236
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
237
|
+
expect(clientJs).toMatch(/\{\s*initial:\s*0\s*\}/)
|
|
238
|
+
expect(clientJs).toMatch(/config_bf\d+\.initial/)
|
|
239
|
+
expect(clientJs).toMatch(/\?\?\s*5/)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('R9: a nested declaration that shadows a factory parameter declines with BF114', () => {
|
|
243
|
+
const source = `
|
|
244
|
+
'use client'
|
|
245
|
+
import { createSignal } from '@barefootjs/client'
|
|
246
|
+
|
|
247
|
+
function createOops(initial: number) {
|
|
248
|
+
const [n, setN] = createSignal(initial)
|
|
249
|
+
const reset = (initial: number) => setN(initial)
|
|
250
|
+
return { n, reset }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function Oops() {
|
|
254
|
+
const { n, reset } = createOops(0)
|
|
255
|
+
return <button onClick={() => reset(0)}>{n()}</button>
|
|
256
|
+
}
|
|
257
|
+
`
|
|
258
|
+
|
|
259
|
+
const ctx = analyzeComponent(source, 'Oops.tsx')
|
|
260
|
+
expect(ctx.signals.length).toBe(0)
|
|
261
|
+
|
|
262
|
+
const result = compileJSX(source, 'Oops.tsx', { adapter })
|
|
263
|
+
const bf114 = result.errors.find(e => e.code === 'BF114')
|
|
264
|
+
expect(bf114).toBeDefined()
|
|
265
|
+
expect(bf114!.message).toContain('initial')
|
|
266
|
+
expect(bf114!.message).toContain('createOops')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('R10: an argument expression is never re-scanned by a later rename pass (cascade fix)', () => {
|
|
270
|
+
const source = `
|
|
271
|
+
'use client'
|
|
272
|
+
import { createSignal } from '@barefootjs/client'
|
|
273
|
+
|
|
274
|
+
function createInput(initial: string) {
|
|
275
|
+
const [value, setValue] = createSignal(initial)
|
|
276
|
+
const update = (next: string) => setValue(next)
|
|
277
|
+
return [value, update] as const
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function Field() {
|
|
281
|
+
const value = 'seed'
|
|
282
|
+
const [email, setEmail] = createInput(value)
|
|
283
|
+
return <input value={email()} onInput={(e) => setEmail(e.target.value)} />
|
|
284
|
+
}
|
|
285
|
+
`
|
|
286
|
+
|
|
287
|
+
const result = compileJSX(source, 'Field.tsx', { adapter })
|
|
288
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
289
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
290
|
+
// `value` (the outer local passed as the argument) must reach
|
|
291
|
+
// createSignal verbatim — a later return->caller rename pass renaming
|
|
292
|
+
// the factory's own `value` return identifier to `email` must not
|
|
293
|
+
// re-scan and corrupt the already-substituted argument text.
|
|
294
|
+
expect(clientJs).toMatch(/createSignal\(\s*value\s*\)/)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('R11: a tuple array-binding pattern in the body is not shorthand-expanded (ArrayBindingPattern trap)', () => {
|
|
298
|
+
const source = `
|
|
299
|
+
'use client'
|
|
300
|
+
import { createSignal } from '@barefootjs/client'
|
|
301
|
+
|
|
302
|
+
function createCounter(initial: number) {
|
|
303
|
+
const [c, s] = createSignal(initial)
|
|
304
|
+
return [c, s] as const
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function Counter() {
|
|
308
|
+
const [count, setCount] = createCounter(0)
|
|
309
|
+
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
|
|
310
|
+
}
|
|
311
|
+
`
|
|
312
|
+
|
|
313
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
314
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
315
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
316
|
+
expect(clientJs).not.toMatch(/\[\s*\w+:\s/)
|
|
317
|
+
})
|
|
318
|
+
})
|
|
@@ -88,14 +88,39 @@ describe('matchToLocaleDateStringCall accept/decline table', () => {
|
|
|
88
88
|
})
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
+
test('a canonical IANA zone timeZone is admitted via the build probe (#2344)', () => {
|
|
92
|
+
const node = match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' })`)
|
|
93
|
+
expect(node).toMatchObject({
|
|
94
|
+
helper: 'format_date',
|
|
95
|
+
args: [
|
|
96
|
+
{ kind: 'identifier', name: 'createdAt' },
|
|
97
|
+
{ kind: 'literal', value: 'YYYY/M/D' },
|
|
98
|
+
{ kind: 'literal', value: 'Asia/Tokyo' },
|
|
99
|
+
{ kind: 'array-literal', elements: [] },
|
|
100
|
+
],
|
|
101
|
+
})
|
|
102
|
+
expect(match(`createdAt.toLocaleDateString('en-US', { timeZone: 'America/New_York' })`)).toMatchObject({
|
|
103
|
+
helper: 'format_date',
|
|
104
|
+
args: [expect.anything(), { value: 'M/D/YYYY' }, { value: 'America/New_York' }, expect.anything()],
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('unverifiable timeZone literals decline (#2344 probe-and-verify)', () => {
|
|
109
|
+
// unknown zone: real toLocaleDateString throws RangeError
|
|
110
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyoo' })`)).toBeNull()
|
|
111
|
+
// non-canonical case: the probe canonicalizes it away, and backend
|
|
112
|
+
// tzdata layers disagree on case-folded spellings
|
|
113
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'asia/tokyo' })`)).toBeNull()
|
|
114
|
+
// host-environment aliases
|
|
115
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'Local' })`)).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
|
|
91
118
|
test('implicit-environment and runtime-value shapes all decline', () => {
|
|
92
119
|
// zero-arg / locale-only: reads host locale and/or timezone
|
|
93
120
|
expect(match(`createdAt.toLocaleDateString()`)).toBeNull()
|
|
94
121
|
expect(match(`createdAt.toLocaleDateString('ja-JP')`)).toBeNull()
|
|
95
122
|
// non-literal locale: no build-time CLDR resolution
|
|
96
123
|
expect(match(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)).toBeNull()
|
|
97
|
-
// IANA zone name: host-tzdata coupling
|
|
98
|
-
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' })`)).toBeNull()
|
|
99
124
|
// non-literal timeZone
|
|
100
125
|
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: tz })`)).toBeNull()
|
|
101
126
|
// out-of-range fixed offsets: real toLocaleDateString throws RangeError
|
|
@@ -1,30 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared `dangerouslySetInnerHTML={{ __html: expr }}` recognition + policy
|
|
3
|
-
* (#2207). Single place every template adapter calls
|
|
4
|
-
* so the injection-safety-relevant policy
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* (#2207, dynamic lowering #2319). Single place every template adapter calls
|
|
4
|
+
* from `renderElement`, so the injection-safety-relevant policy lives in
|
|
5
|
+
* exactly one reviewable spot instead of being re-derived independently in
|
|
6
|
+
* 8 adapters. `resolveDangerousInnerHtml` classifies the `__html` value into
|
|
7
|
+
* three cases the adapter then renders uniformly:
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* runtime raw-output primitive
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* into
|
|
9
|
+
* - `static` — a compile-time string literal. Spliced directly into the
|
|
10
|
+
* adapter's OWN template source as trusted text (same trust domain as
|
|
11
|
+
* hand-writing the HTML into the template), guarded per-adapter against
|
|
12
|
+
* that language's template metacharacters (`dangerousInnerHtmlMetachar
|
|
13
|
+
* Violation`). NOT routed through a runtime raw-output primitive — the
|
|
14
|
+
* value is fully known at compile time, so a runtime sink would reopen a
|
|
15
|
+
* template-source injection surface for no benefit.
|
|
16
|
+
*
|
|
17
|
+
* - `dynamic` — a prop-/signal-derived value (a signal read, prop, template
|
|
18
|
+
* literal WITH a substitution, local const, `??`-fallback, anything
|
|
19
|
+
* non-literal). The adapter serializes the `__html` expression via its own
|
|
20
|
+
* `convertExpressionTo<Lang>` and wraps the result in its runtime
|
|
21
|
+
* raw-output sink (Blade `{!! !!}`, ERB bare `<%= %>`, Go `template.HTML`,
|
|
22
|
+
* Jinja/MiniJinja `|safe`, Twig `|raw`, Mojolicious `<%== %>`, Xslate
|
|
23
|
+
* `mark_raw`). The runtime evaluates the expression at request time — the
|
|
24
|
+
* VALUE is never spliced into template source, so no metachar guard
|
|
25
|
+
* applies. This matches React's contract ("dangerously" = the caller owns
|
|
26
|
+
* the value's safety) and the Hono/CSR path, which already drives a
|
|
27
|
+
* `createEffect`-based `el.innerHTML = …` assignment (see
|
|
28
|
+
* `ir-to-client-js/emit-reactive.ts`).
|
|
29
|
+
*
|
|
30
|
+
* - `unlowerable` — the value is not a `{ __html: <expr> }` object literal
|
|
31
|
+
* at all (bare boolean/spread, or a variable holding the object). No
|
|
32
|
+
* `__html` expression to lower, so the adapter refuses with `BF101`.
|
|
24
33
|
*/
|
|
25
34
|
|
|
26
35
|
import type { CompilerError, IRAttribute, IRElement, SourceLocation } from '../types.ts'
|
|
27
|
-
import { parseExpression, type ParsedExpr } from '../expression-parser.ts'
|
|
36
|
+
import { parseExpression, stringifyParsedExpr, type ParsedExpr } from '../expression-parser.ts'
|
|
28
37
|
|
|
29
38
|
const DANGEROUS_INNER_HTML_ATTR = 'dangerouslySetInnerHTML'
|
|
30
39
|
|
|
@@ -33,8 +42,28 @@ export function isDangerousInnerHtmlAttr(attr: IRAttribute): boolean {
|
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export type DangerousInnerHtmlResolution =
|
|
45
|
+
// A compile-time string literal — spliced directly into the adapter's own
|
|
46
|
+
// template source as trusted text (guarded per-adapter against that
|
|
47
|
+
// language's template metacharacters). #2207.
|
|
36
48
|
| { kind: 'static'; html: string }
|
|
37
|
-
|
|
49
|
+
// A dynamic (prop-/signal-derived) `__html` value. `valueParsed` is the
|
|
50
|
+
// inner expression's IR-parsed tree and `valueExpr` its re-stringified
|
|
51
|
+
// source — both accepted by every adapter's `convertExpressionTo<Lang>`,
|
|
52
|
+
// which serializes the expression so the adapter can wrap the result in
|
|
53
|
+
// its own runtime raw-output sink (Blade `{!! !!}`, ERB bare `<%= %>`,
|
|
54
|
+
// Go `template.HTML`, Jinja/MiniJinja `|safe`, Twig `|raw`, Mojolicious
|
|
55
|
+
// `<%== %>`, Xslate `mark_raw`). NO template-metacharacter guard applies:
|
|
56
|
+
// the value is evaluated at request time by the target runtime, never
|
|
57
|
+
// spliced into template source, so it cannot forge a template construct.
|
|
58
|
+
// This matches React semantics ("dangerously" = the caller owns the
|
|
59
|
+
// safety of the value) and the Hono/CSR path, which already drives a
|
|
60
|
+
// signal-reactive `el.innerHTML = …`. #2319 (successor to #2215).
|
|
61
|
+
| { kind: 'dynamic'; valueExpr: string; valueParsed: ParsedExpr; loc: SourceLocation }
|
|
62
|
+
// The attribute value is not a `{ __html: <expr> }` object literal at all
|
|
63
|
+
// (a bare boolean/spread, or a variable holding the object) — there is no
|
|
64
|
+
// `__html` expression to lower, so the adapter refuses with BF101. `expr`
|
|
65
|
+
// is the raw source for the diagnostic.
|
|
66
|
+
| { kind: 'unlowerable'; expr: string; loc: SourceLocation }
|
|
38
67
|
|
|
39
68
|
/**
|
|
40
69
|
* Resolve `element`'s `dangerouslySetInnerHTML` attribute, if present.
|
|
@@ -57,35 +86,44 @@ export function resolveDangerousInnerHtml(element: IRElement): DangerousInnerHtm
|
|
|
57
86
|
if (!attr) return null
|
|
58
87
|
if (attr.clientOnly) return null
|
|
59
88
|
if (attr.value.kind !== 'expression') {
|
|
60
|
-
return { kind: '
|
|
89
|
+
return { kind: 'unlowerable', expr: '', loc: attr.loc }
|
|
61
90
|
}
|
|
62
91
|
const parsed = attr.value.parsed ?? parseExpression(attr.value.expr.trim())
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
return { kind: '
|
|
92
|
+
const value = htmlPropValue(parsed)
|
|
93
|
+
// Not a `{ __html: <expr> }` object literal — nothing to lower.
|
|
94
|
+
if (value === null) return { kind: 'unlowerable', expr: attr.value.expr, loc: attr.loc }
|
|
95
|
+
// A compile-time string literal (a quoted string or a no-substitution
|
|
96
|
+
// template literal, which the parser normalises to the same
|
|
97
|
+
// `{kind:'literal', literalType:'string'}` shape) is spliced as trusted
|
|
98
|
+
// template text; everything else is a dynamic value the adapter lowers
|
|
99
|
+
// through its raw-output sink.
|
|
100
|
+
if (value.kind === 'literal' && value.literalType === 'string') {
|
|
101
|
+
return { kind: 'static', html: value.value as string }
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
kind: 'dynamic',
|
|
105
|
+
valueExpr: stringifyParsedExpr(value),
|
|
106
|
+
valueParsed: value,
|
|
107
|
+
loc: attr.loc,
|
|
108
|
+
}
|
|
66
109
|
}
|
|
67
110
|
|
|
68
111
|
/**
|
|
69
|
-
* `{ __html:
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* non-literal shape all fall through to `null` — no const-folding, no
|
|
78
|
-
* partial evaluation. Widening this is a single-place change here,
|
|
79
|
-
* deliberately not attempted in v1 (#2207).
|
|
112
|
+
* The `__html` value expression of a `{ __html: <expr> }` object literal, or
|
|
113
|
+
* `null` when the parsed value is not that shape. Exactly one property, key
|
|
114
|
+
* `__html` (identifier or string form — `{ __html: … }` and `{ '__html': … }`
|
|
115
|
+
* are equivalent JS). Shorthand `{ __html }` reads a variable of that name;
|
|
116
|
+
* it is a valid dynamic value (its `value` is the identifier `__html`), so it
|
|
117
|
+
* is admitted the same as `{ __html: __html }` — the caller then classifies
|
|
118
|
+
* literal-vs-dynamic. Spreads, computed keys, and multi-property objects fall
|
|
119
|
+
* through to `null` (the caller refuses them).
|
|
80
120
|
*/
|
|
81
|
-
function
|
|
121
|
+
function htmlPropValue(parsed: ParsedExpr): ParsedExpr | null {
|
|
82
122
|
if (parsed.kind !== 'object-literal') return null
|
|
83
123
|
if (parsed.properties.length !== 1) return null
|
|
84
124
|
const [prop] = parsed.properties
|
|
85
|
-
if (prop.shorthand) return null
|
|
86
125
|
if (prop.key !== '__html') return null
|
|
87
|
-
|
|
88
|
-
return prop.value.value as string
|
|
126
|
+
return prop.value
|
|
89
127
|
}
|
|
90
128
|
|
|
91
129
|
/**
|
|
@@ -162,25 +200,40 @@ export function dangerousInnerHtmlMetacharViolation(html: string, adapterId: str
|
|
|
162
200
|
|
|
163
201
|
/**
|
|
164
202
|
* Purpose-built `BF101` for a `dangerouslySetInnerHTML` value this adapter
|
|
165
|
-
* can't lower
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
203
|
+
* can't lower. Two distinct failure families funnel through here, so the base
|
|
204
|
+
* message is chosen by whether a `reason` is supplied:
|
|
205
|
+
*
|
|
206
|
+
* - WITHOUT a `reason` — the value is not the required SHAPE: it must be an
|
|
207
|
+
* object literal with exactly one `__html` property (no spreads, extra keys,
|
|
208
|
+
* or computed keys). The message states that contract so a near-miss like
|
|
209
|
+
* `{ __html: x, extra: 1 }` (refused as `unlowerable`) doesn't get told it
|
|
210
|
+
* "expects an { __html: … }" it already appears to have.
|
|
211
|
+
* - WITH a `reason` — the value IS a well-formed `{ __html: … }` object
|
|
212
|
+
* literal that this adapter still can't lower: a static literal carrying
|
|
213
|
+
* template metacharacters (`dangerousInnerHtmlMetacharViolation`), or — on
|
|
214
|
+
* the Go adapter — a template-literal / conditional inner expression with no
|
|
215
|
+
* single-argument raw form. The `reason` is the real story; the base states
|
|
216
|
+
* only that the value can't be lowered here, not that the shape is wrong.
|
|
217
|
+
*
|
|
218
|
+
* A genuinely dynamic value with a lowerable inner expression no longer
|
|
219
|
+
* reaches here — it is lowered through the adapter's raw-output sink (#2319).
|
|
169
220
|
*/
|
|
170
221
|
export function dangerousInnerHtmlDiagnostic(
|
|
171
222
|
expr: string,
|
|
172
223
|
loc: SourceLocation,
|
|
173
224
|
reason?: string,
|
|
174
225
|
): CompilerError {
|
|
175
|
-
const
|
|
226
|
+
const base = reason
|
|
227
|
+
? `dangerouslySetInnerHTML value cannot be lowered on this adapter — ${reason}`
|
|
228
|
+
: 'dangerouslySetInnerHTML requires an object literal with a single `__html` property (e.g. { __html: value }) — spreads, extra keys, and computed keys are not supported'
|
|
176
229
|
return {
|
|
177
230
|
code: 'BF101',
|
|
178
231
|
severity: 'error',
|
|
179
|
-
message:
|
|
232
|
+
message: `${base}${expr ? `: ${expr.trim()}` : ''}`,
|
|
180
233
|
loc,
|
|
181
234
|
suggestion: {
|
|
182
235
|
message:
|
|
183
|
-
'
|
|
236
|
+
'Pass an object literal { __html: value } with exactly one `__html` property (a string literal is spliced as trusted template text; a prop/signal value is lowered through the adapter\'s raw-output sink). To force it onto the client instead of SSR, use /* @client */ (e.g. dangerouslySetInnerHTML={/* @client */ { __html: expr }}) so hydration sets it.',
|
|
184
237
|
},
|
|
185
238
|
}
|
|
186
239
|
}
|