@barefootjs/jsx 0.23.0 → 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.
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/format-date-lowering.d.ts +6 -3
- package/dist/format-date-lowering.d.ts.map +1 -1
- package/dist/index.js +696 -108
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +62 -23
- package/dist/to-locale-date-lowering.d.ts.map +1 -1
- package/dist/types.d.ts +47 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +22 -6
- package/src/__tests__/reactive-factory-cross-file.test.ts +845 -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 +203 -2
- package/src/analyzer.ts +730 -131
- package/src/errors.ts +10 -0
- package/src/format-date-lowering.ts +9 -5
- package/src/ir-to-client-js/emit-reactive.ts +14 -4
- package/src/jsx-to-ir.ts +15 -4
- package/src/to-locale-date-lowering.ts +437 -49
- package/src/types.ts +49 -3
|
@@ -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
|
+
})
|
|
@@ -70,6 +70,7 @@ describe('matchToLocaleDateStringCall accept/decline table', () => {
|
|
|
70
70
|
{ kind: 'identifier', name: 'createdAt' },
|
|
71
71
|
{ kind: 'literal', value: 'M/D/YYYY', literalType: 'string' },
|
|
72
72
|
{ kind: 'literal', value: 'UTC', literalType: 'string' },
|
|
73
|
+
{ kind: 'array-literal', elements: [], raw: '[]' },
|
|
73
74
|
],
|
|
74
75
|
})
|
|
75
76
|
})
|
|
@@ -82,6 +83,7 @@ describe('matchToLocaleDateStringCall accept/decline table', () => {
|
|
|
82
83
|
{ kind: 'identifier', name: 'createdAt' },
|
|
83
84
|
{ kind: 'literal', value: 'YYYY/M/D' },
|
|
84
85
|
{ kind: 'literal', value: '+09:00' },
|
|
86
|
+
{ kind: 'array-literal', elements: [] },
|
|
85
87
|
],
|
|
86
88
|
})
|
|
87
89
|
})
|
|
@@ -101,9 +103,10 @@ describe('matchToLocaleDateStringCall accept/decline table', () => {
|
|
|
101
103
|
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+25:00' })`)).toBeNull()
|
|
102
104
|
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+99:99' })`)).toBeNull()
|
|
103
105
|
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '-12:60' })`)).toBeNull()
|
|
104
|
-
// options
|
|
105
|
-
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'UTC', month: 'long' })`)).toBeNull()
|
|
106
|
+
// an options bag WITHOUT timeZone still declines (host-TZ read)
|
|
106
107
|
expect(match(`createdAt.toLocaleDateString('ja-JP', { dateStyle: 'long' })`)).toBeNull()
|
|
108
|
+
// a non-literal option value declines (unprobeable)
|
|
109
|
+
expect(match(`createdAt.toLocaleDateString('en-US', { timeZone: 'UTC', dateStyle: style })`)).toBeNull()
|
|
107
110
|
// unrepresentable locale default
|
|
108
111
|
expect(match(`createdAt.toLocaleDateString('ar-SA', { timeZone: 'UTC' })`)).toBeNull()
|
|
109
112
|
})
|
|
@@ -118,6 +121,204 @@ describe('matchToLocaleDateStringCall accept/decline table', () => {
|
|
|
118
121
|
})
|
|
119
122
|
})
|
|
120
123
|
|
|
124
|
+
describe('name tokens via the options bag (#2334)', () => {
|
|
125
|
+
const md = metadata(DATE_PROP_SRC)
|
|
126
|
+
|
|
127
|
+
function match(expr: string) {
|
|
128
|
+
const { callee, args } = callParts(expr)
|
|
129
|
+
return matchToLocaleDateStringCall(callee, args, md)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test('dateStyle long resolves a named pattern plus the 38-slot table', () => {
|
|
133
|
+
const node = match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'long', timeZone: 'UTC' })`)
|
|
134
|
+
expect(node).toMatchObject({
|
|
135
|
+
helper: 'format_date',
|
|
136
|
+
args: [
|
|
137
|
+
{ kind: 'identifier', name: 'createdAt' },
|
|
138
|
+
{ kind: 'literal', value: 'MMMM D, YYYY' },
|
|
139
|
+
{ kind: 'literal', value: 'UTC' },
|
|
140
|
+
{ kind: 'array-literal' },
|
|
141
|
+
],
|
|
142
|
+
})
|
|
143
|
+
const names = (node as { args: ParsedExpr[] }).args[3] as Extract<ParsedExpr, { kind: 'array-literal' }>
|
|
144
|
+
expect(names.elements).toHaveLength(38)
|
|
145
|
+
expect(names.elements[2]).toEqual({ kind: 'literal', value: 'March', literalType: 'string' })
|
|
146
|
+
expect(names.elements[24]).toEqual({ kind: 'literal', value: 'Sunday', literalType: 'string' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test('dateStyle full adds the weekday token; medium picks abbreviated names', () => {
|
|
150
|
+
expect(match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'full', timeZone: 'UTC' })`)).toMatchObject({
|
|
151
|
+
args: [expect.anything(), { kind: 'literal', value: 'dddd, MMMM D, YYYY' }, expect.anything(), expect.anything()],
|
|
152
|
+
})
|
|
153
|
+
expect(match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'medium', timeZone: 'UTC' })`)).toMatchObject({
|
|
154
|
+
args: [expect.anything(), { kind: 'literal', value: 'MMM D, YYYY' }, expect.anything(), expect.anything()],
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("ja-JP's long form is numeric — the probe ships no table", () => {
|
|
159
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { dateStyle: 'long', timeZone: 'UTC' })`)).toMatchObject({
|
|
160
|
+
args: [
|
|
161
|
+
expect.anything(),
|
|
162
|
+
{ kind: 'literal', value: 'YYYY年M月D日' },
|
|
163
|
+
{ kind: 'literal', value: 'UTC' },
|
|
164
|
+
{ kind: 'array-literal', elements: [] },
|
|
165
|
+
],
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('context-inflected month names pick the form the format actually uses (Copilot, #2336)', () => {
|
|
170
|
+
// Russian inflects month names by date context: dateStyle 'long'
|
|
171
|
+
// renders genitive `марта`, а month-only format nominative `март`.
|
|
172
|
+
// Each call site ships the table whose form ICU uses THERE, verified
|
|
173
|
+
// at a second instant so a probe-index coincidence can't slip through.
|
|
174
|
+
const long = match(`createdAt.toLocaleDateString('ru-RU', { dateStyle: 'long', timeZone: 'UTC' })`)
|
|
175
|
+
// NOTE: read args BEFORE any toMatchObject with expect.anything() —
|
|
176
|
+
// bun 1.3.11's toMatchObject MUTATES the received object, emptying
|
|
177
|
+
// entries an expect.anything() matched (minimal repro pinned in this
|
|
178
|
+
// PR's description; upstream bun bug).
|
|
179
|
+
const longNames = (long as { args: ParsedExpr[] }).args[3] as Extract<ParsedExpr, { kind: 'array-literal' }>
|
|
180
|
+
const longPattern = (long as { args: ParsedExpr[] }).args[1]
|
|
181
|
+
expect(longPattern).toEqual({ kind: 'literal', value: 'D MMMM YYYY г.', literalType: 'string' })
|
|
182
|
+
expect(longNames.elements[2]).toEqual({ kind: 'literal', value: 'марта', literalType: 'string' })
|
|
183
|
+
|
|
184
|
+
const monthOnly = match(`createdAt.toLocaleDateString('ru-RU', { month: 'long', timeZone: 'UTC' })`)
|
|
185
|
+
const standaloneNames = (monthOnly as { args: ParsedExpr[] }).args[3] as Extract<ParsedExpr, { kind: 'array-literal' }>
|
|
186
|
+
expect(standaloneNames.elements[2]).toEqual({ kind: 'literal', value: 'март', literalType: 'string' })
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test('unreproducible forms decline loudly: 2-digit year, era', () => {
|
|
190
|
+
expect(match(`createdAt.toLocaleDateString('en-US', { dateStyle: 'short', timeZone: 'UTC' })`)).toBeNull()
|
|
191
|
+
expect(match(`createdAt.toLocaleDateString('en-US', { era: 'short', year: 'numeric', timeZone: 'UTC' })`)).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('client rewrite carries the names table', () => {
|
|
195
|
+
const result = compile(`
|
|
196
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
197
|
+
return <div>{createdAt.toLocaleDateString('en-US', { dateStyle: 'long', timeZone: 'UTC' })}</div>
|
|
198
|
+
}
|
|
199
|
+
`)
|
|
200
|
+
expect(result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toEqual([])
|
|
201
|
+
const js = result.files.find((f) => f.type === 'clientJs')!.content
|
|
202
|
+
expect(js).toContain('formatDate(_p.createdAt, "MMMM D, YYYY", "UTC", ["January","February"')
|
|
203
|
+
expect(js).not.toContain('toLocaleDateString')
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
describe('union-typed locale (#2324 union stage)', () => {
|
|
208
|
+
const UNION_SRC = `
|
|
209
|
+
function Foo({ createdAt, locale }: { createdAt: Date; locale: 'en-US' | 'ja-JP' }) {
|
|
210
|
+
return <div>{createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
|
|
211
|
+
}
|
|
212
|
+
export { Foo }
|
|
213
|
+
`
|
|
214
|
+
|
|
215
|
+
test('a required closed string-literal union lowers to a ternary pattern', () => {
|
|
216
|
+
const md = metadata(UNION_SRC)
|
|
217
|
+
const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
|
|
218
|
+
expect(matchToLocaleDateStringCall(callee, args, md)).toEqual({
|
|
219
|
+
kind: 'helper-call',
|
|
220
|
+
helper: 'format_date',
|
|
221
|
+
args: [
|
|
222
|
+
{ kind: 'identifier', name: 'createdAt' },
|
|
223
|
+
{
|
|
224
|
+
kind: 'conditional',
|
|
225
|
+
test: {
|
|
226
|
+
kind: 'binary',
|
|
227
|
+
op: '===',
|
|
228
|
+
left: { kind: 'identifier', name: 'locale' },
|
|
229
|
+
right: { kind: 'literal', value: 'en-US', literalType: 'string' },
|
|
230
|
+
},
|
|
231
|
+
consequent: { kind: 'literal', value: 'M/D/YYYY', literalType: 'string' },
|
|
232
|
+
alternate: { kind: 'literal', value: 'YYYY/M/D', literalType: 'string' },
|
|
233
|
+
},
|
|
234
|
+
{ kind: 'literal', value: 'UTC', literalType: 'string' },
|
|
235
|
+
// both members are numeric-only, so the names tables are equal ([])
|
|
236
|
+
// and collapse to a single empty leaf
|
|
237
|
+
{ kind: 'array-literal', elements: [], raw: '[]' },
|
|
238
|
+
],
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('members sharing one pattern collapse the ternary to a literal', () => {
|
|
243
|
+
const md = metadata(`
|
|
244
|
+
function Foo({ createdAt, locale }: { createdAt: Date; locale: 'en-US' | 'en' }) {
|
|
245
|
+
return <div>{createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
|
|
246
|
+
}
|
|
247
|
+
export { Foo }
|
|
248
|
+
`)
|
|
249
|
+
const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
|
|
250
|
+
expect(matchToLocaleDateStringCall(callee, args, md)).toMatchObject({
|
|
251
|
+
helper: 'format_date',
|
|
252
|
+
args: [
|
|
253
|
+
{ kind: 'identifier', name: 'createdAt' },
|
|
254
|
+
{ kind: 'literal', value: 'M/D/YYYY' },
|
|
255
|
+
{ kind: 'literal', value: 'UTC' },
|
|
256
|
+
{ kind: 'array-literal', elements: [] },
|
|
257
|
+
],
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test('object-props mode: accepts props.<name>, declines a bare identifier (never a prop there)', () => {
|
|
262
|
+
const md = metadata(`
|
|
263
|
+
export function Foo(props: { createdAt: Date; locale: 'en-US' | 'ja-JP' }) {
|
|
264
|
+
return <div>{props.createdAt.toLocaleDateString(props.locale, { timeZone: 'UTC' })}</div>
|
|
265
|
+
}
|
|
266
|
+
`)
|
|
267
|
+
const member = callParts(`props.createdAt.toLocaleDateString(props.locale, { timeZone: 'UTC' })`)
|
|
268
|
+
expect(matchToLocaleDateStringCall(member.callee, member.args, md)).toMatchObject({
|
|
269
|
+
helper: 'format_date',
|
|
270
|
+
args: [
|
|
271
|
+
expect.anything(),
|
|
272
|
+
{ kind: 'conditional' },
|
|
273
|
+
{ kind: 'literal', value: 'UTC' },
|
|
274
|
+
{ kind: 'array-literal', elements: [] },
|
|
275
|
+
],
|
|
276
|
+
})
|
|
277
|
+
// A bare `locale` identifier in object-props mode is a LOCAL binding,
|
|
278
|
+
// not the prop — even when a same-named prop exists (Copilot, #2331).
|
|
279
|
+
const bare = callParts(`props.createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
|
|
280
|
+
expect(matchToLocaleDateStringCall(bare.callee, bare.args, md)).toBeNull()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('destructured mode: declines a props.<name> member (no props object exists)', () => {
|
|
284
|
+
const md = metadata(UNION_SRC)
|
|
285
|
+
const { callee, args } = callParts(`createdAt.toLocaleDateString(props.locale, { timeZone: 'UTC' })`)
|
|
286
|
+
expect(matchToLocaleDateStringCall(callee, args, md)).toBeNull()
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('declines an OPTIONAL union prop (undefined would read the host locale)', () => {
|
|
290
|
+
const md = metadata(`
|
|
291
|
+
function Foo({ createdAt, locale }: { createdAt: Date; locale?: 'en-US' | 'ja-JP' }) {
|
|
292
|
+
return <div>{/* @client */ createdAt.toLocaleDateString(locale ?? 'en-US', { timeZone: 'UTC' })}</div>
|
|
293
|
+
}
|
|
294
|
+
export { Foo }
|
|
295
|
+
`)
|
|
296
|
+
const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
|
|
297
|
+
expect(matchToLocaleDateStringCall(callee, args, md)).toBeNull()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('declines a union containing an unrepresentable member', () => {
|
|
301
|
+
const md = metadata(`
|
|
302
|
+
function Foo({ createdAt, locale }: { createdAt: Date; locale: 'en-US' | 'ar-SA' }) {
|
|
303
|
+
return <div>{/* @client */ createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
|
|
304
|
+
}
|
|
305
|
+
export { Foo }
|
|
306
|
+
`)
|
|
307
|
+
const { callee, args } = callParts(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)
|
|
308
|
+
expect(matchToLocaleDateStringCall(callee, args, md)).toBeNull()
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('compiles clean (no BF021) and rewrites client JS to a ternary formatDate', () => {
|
|
312
|
+
const result = compile(UNION_SRC)
|
|
313
|
+
expect(result.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toEqual([])
|
|
314
|
+
const js = result.files.find((f) => f.type === 'clientJs')!.content
|
|
315
|
+
expect(js).toContain(
|
|
316
|
+
'formatDate(_p.createdAt, _p.locale === "en-US" ? "M/D/YYYY" : "YYYY/M/D", "UTC")',
|
|
317
|
+
)
|
|
318
|
+
expect(js).not.toContain('toLocaleDateString')
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
|
|
121
322
|
describe('BF021 exemption round trip (#2273 seam)', () => {
|
|
122
323
|
test('the claimed literal shape compiles clean; the runtime-locale shape still fires BF021', () => {
|
|
123
324
|
const clean = compile(DATE_PROP_SRC)
|