@barefootjs/jsx 0.21.4 → 0.23.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/env-signal.d.ts +8 -0
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +16 -5
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts +10 -4
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts.map +1 -1
- package/dist/date-lowering.d.ts +16 -0
- package/dist/date-lowering.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 +27 -0
- package/dist/format-date-lowering.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +706 -74
- package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
- package/dist/ir-to-client-js/html-template.d.ts +1 -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/jsx-to-ir.d.ts.map +1 -1
- package/dist/to-locale-date-lowering.d.ts +72 -0
- package/dist/to-locale-date-lowering.d.ts.map +1 -0
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/format-date-lowering.test.ts +109 -0
- package/src/__tests__/reactive-factory-cross-file.test.ts +239 -0
- package/src/__tests__/reactive-factory-inlining.test.ts +293 -4
- package/src/__tests__/to-locale-date-lowering.test.ts +181 -0
- package/src/adapters/env-signal.ts +26 -3
- package/src/analyzer-context.ts +19 -4
- package/src/analyzer.ts +712 -91
- package/src/builtin-lowering-plugins.ts +8 -1
- package/src/date-lowering.ts +1 -1
- package/src/errors.ts +13 -0
- package/src/format-date-lowering.ts +51 -0
- package/src/index.ts +1 -1
- package/src/ir-to-client-js/emit-reactive.ts +80 -1
- package/src/ir-to-client-js/html-template.ts +36 -2
- package/src/ir-to-client-js/imports.ts +4 -0
- package/src/jsx-to-ir.ts +79 -1
- package/src/rich-type-refusal.ts +9 -1
- package/src/to-locale-date-lowering.ts +175 -0
- package/src/types.ts +24 -1
|
@@ -176,14 +176,26 @@ describe('Reactive factory inlining (#931)', () => {
|
|
|
176
176
|
})
|
|
177
177
|
|
|
178
178
|
test('non-tuple single-value destructure is NOT treated as a factory call', () => {
|
|
179
|
-
// Guard against false positives: `const { x } = obj()` should be left
|
|
179
|
+
// Guard against false positives: `const { x } = obj()` should be left
|
|
180
|
+
// alone when `obj` is an ordinary (non-reactive) function.
|
|
181
|
+
//
|
|
182
|
+
// Correction (#2325): this fixture previously destructured `createSignal`
|
|
183
|
+
// itself with a TUPLE pattern (`const [count, setCount] = createSignal(0)`),
|
|
184
|
+
// which never exercised an object destructure at all — it was already
|
|
185
|
+
// covered by the `callee === 'createSignal'` skip in
|
|
186
|
+
// `validateReactiveFactoryCalls`. Rewritten to genuinely exercise the
|
|
187
|
+
// object-destructure path now that it performs real factory-shape
|
|
188
|
+
// dispatch (#2325 §4c).
|
|
180
189
|
const source = `
|
|
181
190
|
'use client'
|
|
182
|
-
|
|
191
|
+
|
|
192
|
+
function getConfig() {
|
|
193
|
+
return { x: 1 }
|
|
194
|
+
}
|
|
183
195
|
|
|
184
196
|
export function Comp() {
|
|
185
|
-
const
|
|
186
|
-
return <p>{
|
|
197
|
+
const { x } = getConfig()
|
|
198
|
+
return <p>{x}</p>
|
|
187
199
|
}
|
|
188
200
|
`
|
|
189
201
|
|
|
@@ -193,3 +205,280 @@ describe('Reactive factory inlining (#931)', () => {
|
|
|
193
205
|
expect(bf110).toBeUndefined()
|
|
194
206
|
})
|
|
195
207
|
})
|
|
208
|
+
|
|
209
|
+
describe('Object-return reactive factories (#2325)', () => {
|
|
210
|
+
test('object-return factory: full destructure { count, setCount }', () => {
|
|
211
|
+
const source = `
|
|
212
|
+
'use client'
|
|
213
|
+
import { createSignal } from '@barefootjs/client'
|
|
214
|
+
|
|
215
|
+
function createCounter(initial: number) {
|
|
216
|
+
const [count, setCount] = createSignal(initial)
|
|
217
|
+
return { count, setCount }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function Counter() {
|
|
221
|
+
const { count, setCount } = createCounter(0)
|
|
222
|
+
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
|
|
223
|
+
}
|
|
224
|
+
`
|
|
225
|
+
|
|
226
|
+
const ctx = analyzeComponent(source, 'Counter.tsx')
|
|
227
|
+
expect(ctx.signals.length).toBe(1)
|
|
228
|
+
expect(ctx.signals[0].getter).toBe('count')
|
|
229
|
+
expect(ctx.signals[0].setter).toBe('setCount')
|
|
230
|
+
|
|
231
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
232
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
233
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')
|
|
234
|
+
expect(clientJs).toBeDefined()
|
|
235
|
+
expect(clientJs!.content).toContain('createSignal')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('object-return factory: subset destructure suffix-renames the undestructured setter (C4)', () => {
|
|
239
|
+
const source = `
|
|
240
|
+
'use client'
|
|
241
|
+
import { createSignal } from '@barefootjs/client'
|
|
242
|
+
|
|
243
|
+
function createCounter(initial: number) {
|
|
244
|
+
const [count, setCount] = createSignal(initial)
|
|
245
|
+
return { count, setCount }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function Display() {
|
|
249
|
+
const { count } = createCounter(0)
|
|
250
|
+
return <p>{count()}</p>
|
|
251
|
+
}
|
|
252
|
+
`
|
|
253
|
+
|
|
254
|
+
const ctx = analyzeComponent(source, 'Display.tsx')
|
|
255
|
+
expect(ctx.signals.length).toBe(1)
|
|
256
|
+
expect(ctx.signals[0].getter).toBe('count')
|
|
257
|
+
|
|
258
|
+
const result = compileJSX(source, 'Display.tsx', { adapter })
|
|
259
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
260
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
261
|
+
// The setter was never destructured — it must still be suffix-renamed
|
|
262
|
+
// (not left as a bare, unresolved `setCount`).
|
|
263
|
+
expect(clientJs).toMatch(/setCount_\w+/)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('object-return factory: custom setter wrapper (localStorage-backed signal)', () => {
|
|
267
|
+
// Matches the createPersistentSignal shape from PR #930.
|
|
268
|
+
const source = `
|
|
269
|
+
'use client'
|
|
270
|
+
import { createSignal } from '@barefootjs/client'
|
|
271
|
+
|
|
272
|
+
function createPersistentSignal(key: string, initial: string) {
|
|
273
|
+
const [v, setV] = createSignal(initial)
|
|
274
|
+
const setAndStore = (next: string) => {
|
|
275
|
+
setV(next)
|
|
276
|
+
localStorage.setItem(key, next)
|
|
277
|
+
}
|
|
278
|
+
return { v, setAndStore }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function Store() {
|
|
282
|
+
const { v, setAndStore } = createPersistentSignal('k', 'hello')
|
|
283
|
+
return <button onClick={() => setAndStore('world')}>{v()}</button>
|
|
284
|
+
}
|
|
285
|
+
`
|
|
286
|
+
|
|
287
|
+
const ctx = analyzeComponent(source, 'Store.tsx')
|
|
288
|
+
expect(ctx.signals.length).toBe(1)
|
|
289
|
+
expect(ctx.signals[0].getter).toBe('v')
|
|
290
|
+
|
|
291
|
+
const result = compileJSX(source, 'Store.tsx', { adapter })
|
|
292
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
293
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
294
|
+
expect(clientJs).toContain('createSignal')
|
|
295
|
+
expect(clientJs).toContain("localStorage.setItem('k'")
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('object-return factory: two subset-destructure call sites stay hygienic (C4)', () => {
|
|
299
|
+
const source = `
|
|
300
|
+
'use client'
|
|
301
|
+
import { createSignal } from '@barefootjs/client'
|
|
302
|
+
|
|
303
|
+
function createPair(initial: number) {
|
|
304
|
+
const [count, setCount] = createSignal(initial)
|
|
305
|
+
return { count, setCount }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function Two() {
|
|
309
|
+
const { count } = createPair(0)
|
|
310
|
+
const { setCount } = createPair(10)
|
|
311
|
+
return <p>{count()}</p>
|
|
312
|
+
}
|
|
313
|
+
`
|
|
314
|
+
|
|
315
|
+
const result = compileJSX(source, 'Two.tsx', { adapter })
|
|
316
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
317
|
+
const clientJs = result.files.find(f => f.type === 'clientJs')!.content
|
|
318
|
+
expect(clientJs.match(/createSignal\(/g)?.length).toBe(2)
|
|
319
|
+
// Per-call-site-unique suffixed internal names: the first call site
|
|
320
|
+
// suffix-renames its undestructured `setCount`, the second suffix-
|
|
321
|
+
// renames its undestructured `count` — distinct call sites must not
|
|
322
|
+
// collide on either name.
|
|
323
|
+
expect(clientJs).toMatch(/setCount_bf\d+/)
|
|
324
|
+
expect(clientJs).toMatch(/count_bf\d+/)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('object-return factory: mixed signal + memo body', () => {
|
|
328
|
+
const source = `
|
|
329
|
+
'use client'
|
|
330
|
+
import { createSignal, createMemo } from '@barefootjs/client'
|
|
331
|
+
|
|
332
|
+
function createCounter(initial: number) {
|
|
333
|
+
const [count, setCount] = createSignal(initial)
|
|
334
|
+
const double = createMemo(() => count() * 2)
|
|
335
|
+
return { count, setCount, double }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function Counter() {
|
|
339
|
+
const { count, setCount, double } = createCounter(0)
|
|
340
|
+
return <button onClick={() => setCount(count() + 1)}>{double()}</button>
|
|
341
|
+
}
|
|
342
|
+
`
|
|
343
|
+
|
|
344
|
+
const ctx = analyzeComponent(source, 'Counter.tsx')
|
|
345
|
+
expect(ctx.signals.length).toBe(1)
|
|
346
|
+
expect(ctx.memos.length).toBe(1)
|
|
347
|
+
expect(ctx.memos[0].name).toBe('double')
|
|
348
|
+
|
|
349
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
350
|
+
expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
test('BF110: object destructure of an unknown imported callee emits a diagnostic', () => {
|
|
354
|
+
// Regression-locks the closed silent-failure hole: an unresolvable
|
|
355
|
+
// relative import whose name looks reactive-factory-shaped.
|
|
356
|
+
const source = `
|
|
357
|
+
'use client'
|
|
358
|
+
import { useExternal } from './external'
|
|
359
|
+
|
|
360
|
+
export function Gadget() {
|
|
361
|
+
const { value, setValue } = useExternal('key')
|
|
362
|
+
return <button onClick={() => setValue('x')}>{value()}</button>
|
|
363
|
+
}
|
|
364
|
+
`
|
|
365
|
+
|
|
366
|
+
const result = compileJSX(source, 'Gadget.tsx', { adapter })
|
|
367
|
+
const bf110 = result.errors.find(e => e.code === 'BF110')
|
|
368
|
+
expect(bf110).toBeDefined()
|
|
369
|
+
expect(bf110!.message).toContain('useExternal')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('BF111: non-shorthand factory return emits a diagnostic, no inlining', () => {
|
|
373
|
+
const source = `
|
|
374
|
+
'use client'
|
|
375
|
+
import { createSignal } from '@barefootjs/client'
|
|
376
|
+
|
|
377
|
+
function createCounter(initial: number) {
|
|
378
|
+
const [count, setCount] = createSignal(initial)
|
|
379
|
+
return { count: count, setCount }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function Counter() {
|
|
383
|
+
const { count, setCount } = createCounter(0)
|
|
384
|
+
return <button onClick={() => setCount(count() + 1)}>{count()}</button>
|
|
385
|
+
}
|
|
386
|
+
`
|
|
387
|
+
|
|
388
|
+
const ctx = analyzeComponent(source, 'Counter.tsx')
|
|
389
|
+
expect(ctx.signals.length).toBe(0)
|
|
390
|
+
|
|
391
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
392
|
+
const bf111 = result.errors.find(e => e.code === 'BF111')
|
|
393
|
+
expect(bf111).toBeDefined()
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test('BF111: non-shorthand call-site destructure of a shorthand factory emits a diagnostic, no inlining', () => {
|
|
397
|
+
const source = `
|
|
398
|
+
'use client'
|
|
399
|
+
import { createSignal } from '@barefootjs/client'
|
|
400
|
+
|
|
401
|
+
function createCounter(initial: number) {
|
|
402
|
+
const [count, setCount] = createSignal(initial)
|
|
403
|
+
return { count, setCount }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function Counter() {
|
|
407
|
+
const { count: c, setCount } = createCounter(0)
|
|
408
|
+
return <button onClick={() => setCount(c() + 1)}>{c()}</button>
|
|
409
|
+
}
|
|
410
|
+
`
|
|
411
|
+
|
|
412
|
+
const ctx = analyzeComponent(source, 'Counter.tsx')
|
|
413
|
+
expect(ctx.signals.length).toBe(0)
|
|
414
|
+
|
|
415
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
416
|
+
const bf111 = result.errors.find(e => e.code === 'BF111')
|
|
417
|
+
expect(bf111).toBeDefined()
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
test('BF110 (not BF111): object destructure with a rename of a TUPLE-return factory reports the tuple mismatch', () => {
|
|
421
|
+
// A tuple-return factory destructured as an object is never valid,
|
|
422
|
+
// regardless of whether the object pattern is shorthand or uses a
|
|
423
|
+
// rename/default/rest element — it should always get the "this
|
|
424
|
+
// factory returns a tuple" BF110 message, not the shorthand-only
|
|
425
|
+
// BF111 guidance (which only makes sense for object-return factories).
|
|
426
|
+
const source = `
|
|
427
|
+
'use client'
|
|
428
|
+
import { createSignal } from '@barefootjs/client'
|
|
429
|
+
|
|
430
|
+
function createCounter(initial: number) {
|
|
431
|
+
const [count, setCount] = createSignal(initial)
|
|
432
|
+
return [count, setCount] as const
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function Counter() {
|
|
436
|
+
const { count: c, setCount } = createCounter(0)
|
|
437
|
+
return <button onClick={() => setCount(c() + 1)}>{c()}</button>
|
|
438
|
+
}
|
|
439
|
+
`
|
|
440
|
+
|
|
441
|
+
const ctx = analyzeComponent(source, 'Counter.tsx')
|
|
442
|
+
expect(ctx.signals.length).toBe(0)
|
|
443
|
+
|
|
444
|
+
const result = compileJSX(source, 'Counter.tsx', { adapter })
|
|
445
|
+
const bf110 = result.errors.find(e => e.code === 'BF110')
|
|
446
|
+
expect(bf110).toBeDefined()
|
|
447
|
+
expect(bf110!.message).toContain('returns a tuple')
|
|
448
|
+
expect(result.errors.find(e => e.code === 'BF111')).toBeUndefined()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
test('guard: plain-function object destructure is not flagged (false-positive guard)', () => {
|
|
452
|
+
const source = `
|
|
453
|
+
'use client'
|
|
454
|
+
|
|
455
|
+
function parseConfig() {
|
|
456
|
+
return { data: 42 }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function Comp() {
|
|
460
|
+
const { data } = parseConfig()
|
|
461
|
+
return <p>{data}</p>
|
|
462
|
+
}
|
|
463
|
+
`
|
|
464
|
+
|
|
465
|
+
const result = compileJSX(source, 'Comp.tsx', { adapter })
|
|
466
|
+
expect(result.errors.find(e => e.code === 'BF110')).toBeUndefined()
|
|
467
|
+
expect(result.errors.find(e => e.code === 'BF111')).toBeUndefined()
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
test('guard: object destructure of a @barefootjs-scoped import is not flagged (false-positive guard)', () => {
|
|
471
|
+
const source = `
|
|
472
|
+
'use client'
|
|
473
|
+
import { loadItems } from '@barefootjs/something'
|
|
474
|
+
|
|
475
|
+
export function Comp() {
|
|
476
|
+
const { items } = loadItems()
|
|
477
|
+
return <p>{items}</p>
|
|
478
|
+
}
|
|
479
|
+
`
|
|
480
|
+
|
|
481
|
+
const result = compileJSX(source, 'Comp.tsx', { adapter })
|
|
482
|
+
expect(result.errors.find(e => e.code === 'BF110')).toBeUndefined()
|
|
483
|
+
})
|
|
484
|
+
})
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Literal-locale `toLocaleDateString` sugar (#2324 slice 2). Covers the
|
|
3
|
+
* build-time pattern derivation's structural gate, the matcher's
|
|
4
|
+
* accept/decline table (only the explicit-input literal shape lowers; every
|
|
5
|
+
* implicit-environment or runtime-value shape declines to BF021), the
|
|
6
|
+
* BF021-exemption round trip through the real registry, and the client-JS
|
|
7
|
+
* rewrite to `formatDate(recv, pattern, tz)` (mirrors
|
|
8
|
+
* `date-lowering.test.ts`'s #2292 section).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, test, expect } from 'bun:test'
|
|
11
|
+
import { compileJSX, type ComponentIR } from '../index'
|
|
12
|
+
import { TestAdapter } from '../adapters/test-adapter'
|
|
13
|
+
import { parseExpression, type ParsedExpr } from '../expression-parser'
|
|
14
|
+
import {
|
|
15
|
+
resolveLocaleDatePattern,
|
|
16
|
+
matchToLocaleDateStringCall,
|
|
17
|
+
toLocaleDatePlugin,
|
|
18
|
+
} from '../to-locale-date-lowering'
|
|
19
|
+
import { ErrorCodes } from '../errors'
|
|
20
|
+
|
|
21
|
+
function compile(src: string) {
|
|
22
|
+
return compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter(), outputIR: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function metadata(src: string): ComponentIR['metadata'] {
|
|
26
|
+
const result = compile(src)
|
|
27
|
+
const ir = JSON.parse(result.files.find((f) => f.type === 'ir')!.content) as ComponentIR
|
|
28
|
+
return ir.metadata
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function callParts(expr: string): { callee: ParsedExpr; args: ParsedExpr[] } {
|
|
32
|
+
const parsed = parseExpression(expr)
|
|
33
|
+
if (parsed.kind !== 'call') throw new Error(`expected a call expression, got ${parsed.kind}`)
|
|
34
|
+
return { callee: parsed.callee, args: parsed.args }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DATE_PROP_SRC = `
|
|
38
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
39
|
+
return <div>{createdAt.toLocaleDateString('en-US', { timeZone: 'UTC' })}</div>
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
describe('resolveLocaleDatePattern (build-time derivation)', () => {
|
|
44
|
+
test('derives numeric default patterns per locale', () => {
|
|
45
|
+
expect(resolveLocaleDatePattern('en-US')).toBe('M/D/YYYY')
|
|
46
|
+
expect(resolveLocaleDatePattern('ja-JP')).toBe('YYYY/M/D')
|
|
47
|
+
expect(resolveLocaleDatePattern('en-GB')).toBe('DD/MM/YYYY')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('declines non-gregorian / non-latin-digit defaults (ar-SA) and invalid tags', () => {
|
|
51
|
+
expect(resolveLocaleDatePattern('ar-SA')).toBeNull()
|
|
52
|
+
expect(resolveLocaleDatePattern('not a locale !!')).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('matchToLocaleDateStringCall accept/decline table', () => {
|
|
57
|
+
const md = metadata(DATE_PROP_SRC)
|
|
58
|
+
|
|
59
|
+
function match(expr: string) {
|
|
60
|
+
const { callee, args } = callParts(expr)
|
|
61
|
+
return matchToLocaleDateStringCall(callee, args, md)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('literal locale + literal UTC timeZone lowers to format_date with the frozen pattern', () => {
|
|
65
|
+
const node = match(`createdAt.toLocaleDateString('en-US', { timeZone: 'UTC' })`)
|
|
66
|
+
expect(node).toEqual({
|
|
67
|
+
kind: 'helper-call',
|
|
68
|
+
helper: 'format_date',
|
|
69
|
+
args: [
|
|
70
|
+
{ kind: 'identifier', name: 'createdAt' },
|
|
71
|
+
{ kind: 'literal', value: 'M/D/YYYY', literalType: 'string' },
|
|
72
|
+
{ kind: 'literal', value: 'UTC', literalType: 'string' },
|
|
73
|
+
],
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('a fixed ±HH:MM offset timeZone is admitted', () => {
|
|
78
|
+
const node = match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+09:00' })`)
|
|
79
|
+
expect(node).toMatchObject({
|
|
80
|
+
helper: 'format_date',
|
|
81
|
+
args: [
|
|
82
|
+
{ kind: 'identifier', name: 'createdAt' },
|
|
83
|
+
{ kind: 'literal', value: 'YYYY/M/D' },
|
|
84
|
+
{ kind: 'literal', value: '+09:00' },
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('implicit-environment and runtime-value shapes all decline', () => {
|
|
90
|
+
// zero-arg / locale-only: reads host locale and/or timezone
|
|
91
|
+
expect(match(`createdAt.toLocaleDateString()`)).toBeNull()
|
|
92
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP')`)).toBeNull()
|
|
93
|
+
// non-literal locale: no build-time CLDR resolution
|
|
94
|
+
expect(match(`createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })`)).toBeNull()
|
|
95
|
+
// IANA zone name: host-tzdata coupling
|
|
96
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' })`)).toBeNull()
|
|
97
|
+
// non-literal timeZone
|
|
98
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: tz })`)).toBeNull()
|
|
99
|
+
// out-of-range fixed offsets: real toLocaleDateString throws RangeError
|
|
100
|
+
// on these, so lowering them would diverge from JS semantics
|
|
101
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+25:00' })`)).toBeNull()
|
|
102
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '+99:99' })`)).toBeNull()
|
|
103
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: '-12:60' })`)).toBeNull()
|
|
104
|
+
// options beyond timeZone: the name-table stage, not this slice
|
|
105
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { timeZone: 'UTC', month: 'long' })`)).toBeNull()
|
|
106
|
+
expect(match(`createdAt.toLocaleDateString('ja-JP', { dateStyle: 'long' })`)).toBeNull()
|
|
107
|
+
// unrepresentable locale default
|
|
108
|
+
expect(match(`createdAt.toLocaleDateString('ar-SA', { timeZone: 'UTC' })`)).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a non-Date receiver never activates the plugin', () => {
|
|
112
|
+
const stringMd = metadata(`
|
|
113
|
+
export function Foo({ label }: { label: string }) {
|
|
114
|
+
return <div>{label}</div>
|
|
115
|
+
}
|
|
116
|
+
`)
|
|
117
|
+
expect(toLocaleDatePlugin.prepare(stringMd)).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('BF021 exemption round trip (#2273 seam)', () => {
|
|
122
|
+
test('the claimed literal shape compiles clean; the runtime-locale shape still fires BF021', () => {
|
|
123
|
+
const clean = compile(DATE_PROP_SRC)
|
|
124
|
+
expect(clean.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toEqual([])
|
|
125
|
+
|
|
126
|
+
const refused = compile(`
|
|
127
|
+
export function Foo({ createdAt, locale }: { createdAt: Date; locale: string }) {
|
|
128
|
+
return <div>{createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
|
|
129
|
+
}
|
|
130
|
+
`)
|
|
131
|
+
const bf021 = refused.errors.filter((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
|
|
132
|
+
expect(bf021.length).toBeGreaterThan(0)
|
|
133
|
+
// the refusal now points at the explicit-input forms
|
|
134
|
+
expect(bf021[0].suggestion?.message).toContain('formatDate')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('the zero-arg form (date-method-uncatalogued shape) still fires BF021', () => {
|
|
138
|
+
const refused = compile(`
|
|
139
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
140
|
+
return <div>{createdAt.toLocaleDateString()}</div>
|
|
141
|
+
}
|
|
142
|
+
`)
|
|
143
|
+
expect(refused.errors.some((e) => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
describe('client-JS rewrite (#2292-style parity)', () => {
|
|
148
|
+
function clientJs(src: string): string {
|
|
149
|
+
const result = compileJSX(src.trimStart(), 'T.tsx', { adapter: new TestAdapter() })
|
|
150
|
+
return result.files.find((f) => f.type === 'clientJs')!.content
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test('rewrites the literal shape to formatDate with the frozen pattern and auto-imports it', () => {
|
|
154
|
+
const js = clientJs(DATE_PROP_SRC)
|
|
155
|
+
expect(js).toContain('formatDate(_p.createdAt, "M/D/YYYY", "UTC")')
|
|
156
|
+
expect(js).toMatch(/import\s*\{[^}]*\bformatDate\b[^}]*\}\s*from\s*'@barefootjs\/client\/runtime'/)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('rewrites inside a reactive effect for a signal-conditioned expression', () => {
|
|
160
|
+
const js = clientJs(`
|
|
161
|
+
'use client'
|
|
162
|
+
import { createSignal } from '@barefootjs/client'
|
|
163
|
+
export function Foo({ createdAt }: { createdAt: Date }) {
|
|
164
|
+
const [suffix, setSuffix] = createSignal('')
|
|
165
|
+
return <div onClick={() => setSuffix('!')}>{createdAt.toLocaleDateString('ja-JP', { timeZone: '+09:00' }) + suffix()}</div>
|
|
166
|
+
}
|
|
167
|
+
`)
|
|
168
|
+
expect(js).toContain('formatDate(_p.createdAt, "YYYY/M/D", "+09:00")')
|
|
169
|
+
expect(js).not.toContain('toLocaleDateString')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('leaves the declined runtime-locale shape raw', () => {
|
|
173
|
+
const js = clientJs(`
|
|
174
|
+
export function Foo({ createdAt, locale }: { createdAt: Date; locale: string }) {
|
|
175
|
+
return <div>{/* @client */ createdAt.toLocaleDateString(locale, { timeZone: 'UTC' })}</div>
|
|
176
|
+
}
|
|
177
|
+
`)
|
|
178
|
+
expect(js).toContain('toLocaleDateString(')
|
|
179
|
+
expect(js).not.toContain('formatDate(')
|
|
180
|
+
})
|
|
181
|
+
})
|
|
@@ -117,7 +117,7 @@ export function importsSearchParams(metadata: IRMetadata): boolean {
|
|
|
117
117
|
export function queryHrefLocalNames(metadata: IRMetadata): Set<string> {
|
|
118
118
|
const names = new Set<string>()
|
|
119
119
|
for (const imp of metadata.imports) {
|
|
120
|
-
if (!
|
|
120
|
+
if (!CLIENT_HELPER_SOURCES.has(imp.source) || imp.isTypeOnly) continue
|
|
121
121
|
for (const s of imp.specifiers) {
|
|
122
122
|
if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
|
|
123
123
|
if (s.name === 'queryHref') names.add(s.alias ?? s.name)
|
|
@@ -126,12 +126,35 @@ export function queryHrefLocalNames(metadata: IRMetadata): Set<string> {
|
|
|
126
126
|
return names
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Entry points that re-export the pure client helpers with an SSR lowering
|
|
131
|
+
* (`queryHref` #2042, `formatDate` #2324) — the main entry and the runtime
|
|
132
|
+
* re-export. Importing from either must enable the lowering.
|
|
133
|
+
*/
|
|
134
|
+
const CLIENT_HELPER_SOURCES: ReadonlySet<string> = new Set([
|
|
131
135
|
'@barefootjs/client',
|
|
132
136
|
'@barefootjs/client/runtime',
|
|
133
137
|
])
|
|
134
138
|
|
|
139
|
+
/**
|
|
140
|
+
* The local binding name(s) that `formatDate` is imported under in this
|
|
141
|
+
* component (#2324) — the pure-function date formatter an adapter lowers to
|
|
142
|
+
* its `format_date` helper (spec/template-helpers.md). Same resolution rules
|
|
143
|
+
* as {@link queryHrefLocalNames}: matched by exported name, gated on the LOCAL
|
|
144
|
+
* alias, accepted from both the main entry and the runtime re-export.
|
|
145
|
+
*/
|
|
146
|
+
export function formatDateLocalNames(metadata: IRMetadata): Set<string> {
|
|
147
|
+
const names = new Set<string>()
|
|
148
|
+
for (const imp of metadata.imports) {
|
|
149
|
+
if (!CLIENT_HELPER_SOURCES.has(imp.source) || imp.isTypeOnly) continue
|
|
150
|
+
for (const s of imp.specifiers) {
|
|
151
|
+
if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
|
|
152
|
+
if (s.name === 'formatDate') names.add(s.alias ?? s.name)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return names
|
|
156
|
+
}
|
|
157
|
+
|
|
135
158
|
/**
|
|
136
159
|
* Recognise a `<binding>().<method>(<args>)` env-signal method call from a
|
|
137
160
|
* `call` node's callee + args, where `<binding>` is one of the local names
|
package/src/analyzer-context.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ParamInfo,
|
|
23
23
|
PropertyInfo,
|
|
24
24
|
ReactiveFactoryInfo,
|
|
25
|
+
DeclinedReactiveFactory,
|
|
25
26
|
} from './types.ts'
|
|
26
27
|
import { type ExcludeRange, collectAllTypeRanges, reconstructWithoutTypes } from './strip-types.ts'
|
|
27
28
|
|
|
@@ -146,15 +147,26 @@ export interface AnalyzerContext {
|
|
|
146
147
|
/** Maps multi-return JSX helper functions for conditional inlining at call sites. */
|
|
147
148
|
jsxMultiReturnFunctions: Map<string, MultiReturnJsxInfo>
|
|
148
149
|
/**
|
|
149
|
-
* Maps
|
|
150
|
-
* is a
|
|
151
|
-
* returns a tuple of identifiers, e.g.
|
|
150
|
+
* Maps factory-call-site-local names to reactive-factory info (#931,
|
|
151
|
+
* #2325). A reactive factory is a helper whose body declares reactive
|
|
152
|
+
* primitives and returns a tuple or shorthand-object of identifiers, e.g.
|
|
152
153
|
* `function createCounter(initial) { const [c,s] = createSignal(initial); return [c, s] as const }`.
|
|
153
154
|
* When a component destructures the result of a factory call, the factory
|
|
154
155
|
* body is inlined at the call site so the compiler sees ordinary
|
|
155
|
-
* `createSignal` declarations.
|
|
156
|
+
* `createSignal` declarations. Populated by `analyzeComponent` from the
|
|
157
|
+
* factory prescan (same-file declarations plus relative-imported
|
|
158
|
+
* factories resolved by `prescanImportedReactiveFactories`); consumed by
|
|
159
|
+
* `validateReactiveFactoryCalls`.
|
|
156
160
|
*/
|
|
157
161
|
reactiveFactories: Map<string, ReactiveFactoryInfo>
|
|
162
|
+
/** Factories recognized but declined for inlining, keyed by call-site-local name (#2325). */
|
|
163
|
+
declinedReactiveFactories: Map<string, DeclinedReactiveFactory>
|
|
164
|
+
/** Module-scope helpers (same-file or resolved import) whose body contains a
|
|
165
|
+
* reactive primitive call but whose shape is not an inlinable factory. */
|
|
166
|
+
reactiveShapedHelpers: Set<string>
|
|
167
|
+
/** Imported destructured-callee names whose helper file was resolved, read,
|
|
168
|
+
* and found reactive-free / factory-free — proven safe to leave alone. */
|
|
169
|
+
cleanFactoryImports: Set<string>
|
|
158
170
|
/**
|
|
159
171
|
* Intermediate `const s = createSignal(...)` tuples awaiting `s[0]`/`s[1]`
|
|
160
172
|
* extraction. Flushed into `signals` at the end of visitComponentBody.
|
|
@@ -247,6 +259,9 @@ export function createAnalyzerContext(
|
|
|
247
259
|
jsxFunctions: new Map(),
|
|
248
260
|
jsxMultiReturnFunctions: new Map(),
|
|
249
261
|
reactiveFactories: new Map(),
|
|
262
|
+
declinedReactiveFactories: new Map(),
|
|
263
|
+
reactiveShapedHelpers: new Set(),
|
|
264
|
+
cleanFactoryImports: new Set(),
|
|
250
265
|
signalTupleRefs: new Map(),
|
|
251
266
|
|
|
252
267
|
propsType: null,
|