@barefootjs/jsx 0.24.1 → 0.25.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.
@@ -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
+ })