@barefootjs/jsx 0.25.0 → 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.
Files changed (41) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +52 -24
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/index.js +248 -110
  5. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  6. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -1
  7. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +9 -6
  9. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +11 -5
  11. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +27 -1
  13. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  15. package/dist/ir-to-client-js/reactivity.d.ts +24 -2
  16. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/types.d.ts +13 -0
  18. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  19. package/dist/to-locale-date-lowering.d.ts +31 -10
  20. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  21. package/dist/types.d.ts +8 -4
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +2 -2
  24. package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
  25. package/src/__tests__/nested-loop-conditional.test.ts +133 -0
  26. package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
  27. package/src/__tests__/reactive-factory-cross-file.test.ts +252 -1
  28. package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
  29. package/src/adapters/dangerous-inner-html.ts +101 -48
  30. package/src/analyzer.ts +222 -58
  31. package/src/ir-to-client-js/collect-elements.ts +28 -2
  32. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
  33. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
  34. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
  35. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
  36. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
  37. package/src/ir-to-client-js/reactivity.ts +61 -7
  38. package/src/ir-to-client-js/types.ts +13 -0
  39. package/src/rich-type-refusal.ts +3 -2
  40. package/src/to-locale-date-lowering.ts +50 -13
  41. package/src/types.ts +8 -4
@@ -381,3 +381,136 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
381
381
  expect(outsideInitChildCount).toBe(0)
382
382
  })
383
383
  })
384
+
385
+ describe('per-item conditional wrapping a dynamic attr/event element binds exactly once (#2347)', () => {
386
+ // A per-item conditional (`cond ? null : <el/>`) whose element itself has a
387
+ // dynamic attribute and/or event handler used to get bound twice: once
388
+ // directly against the loop item's own initial template clone (querying
389
+ // `qsa(__el, ...)` right in the mapArray callback), and again inside the
390
+ // conditional's own `insert()` bindEvents against whatever node it mounts.
391
+ // At runtime this doubled the click handler and left the attribute effect
392
+ // pointed at a detached node once insert() swapped branches.
393
+
394
+ test('Repro 1: reactive className on a conditionally-rendered element binds once, inside bindEvents', () => {
395
+ const source = `
396
+ 'use client'
397
+ import { createSignal } from '@barefootjs/client'
398
+
399
+ interface Item { handle: string; active: 0 | 1 }
400
+
401
+ export function List(props: { viewerHandle: string }) {
402
+ const [items, setItems] = createSignal<Item[]>([])
403
+ return (
404
+ <div>
405
+ {items().map(u => (
406
+ <div key={u.handle}>
407
+ {u.handle === props.viewerHandle ? null : (
408
+ <button className={u.active ? 'btn on' : 'btn'} onClick={() => {}}>
409
+ {u.active ? 'On' : 'Off'}
410
+ </button>
411
+ )}
412
+ </div>
413
+ ))}
414
+ </div>
415
+ )
416
+ }
417
+ `
418
+ const result = compileJSX(source, 'List.tsx', { adapter })
419
+ expect(result.errors).toHaveLength(0)
420
+ const content = result.files.find(f => f.type === 'clientJs')!.content
421
+
422
+ // Exactly one class effect, exactly one click listener.
423
+ expect((content.match(/setAttribute\('class'/g) ?? []).length).toBe(1)
424
+ expect((content.match(/addEventListener\('click'/g) ?? []).length).toBe(1)
425
+
426
+ // The class effect must live inside the conditional's own bindEvents
427
+ // (querying `__branchScope`, the node insert() actually mounts) —
428
+ // not directly against `__el` in the outer mapArray scope, which would
429
+ // go stale the moment insert() swaps in a fresh clone.
430
+ const bindEventsIndex = content.indexOf('bindEvents:')
431
+ const classEffectIndex = content.indexOf("setAttribute('class'")
432
+ expect(bindEventsIndex).toBeGreaterThan(-1)
433
+ expect(classEffectIndex).toBeGreaterThan(bindEventsIndex)
434
+ expect(content).toContain("qsa(__branchScope, '[bf=\"s2\"]')")
435
+ })
436
+
437
+ test('Repro 2: a doubly-nested per-item conditional binds click exactly once, in the innermost arm', () => {
438
+ const source = `
439
+ 'use client'
440
+ import { createSignal } from '@barefootjs/client'
441
+
442
+ interface Item { handle: string; active: 0 | 1 }
443
+
444
+ export function List(props: { viewerHandle: string; signedIn: boolean }) {
445
+ const [items, setItems] = createSignal<Item[]>([])
446
+ const onClick = (u: Item) => {}
447
+ return (
448
+ <div>
449
+ {items().map(u => (
450
+ <div key={u.handle}>
451
+ {u.handle === props.viewerHandle ? null : props.signedIn ? (
452
+ <button className={u.active ? 'btn on' : 'btn'} onClick={() => onClick(u)}>
453
+ {u.active ? 'On' : 'Off'}
454
+ </button>
455
+ ) : (
456
+ <a href="/login" rel="nofollow noreferrer">Off</a>
457
+ )}
458
+ </div>
459
+ ))}
460
+ </div>
461
+ )
462
+ }
463
+ `
464
+ const result = compileJSX(source, 'List.tsx', { adapter })
465
+ expect(result.errors).toHaveLength(0)
466
+ const content = result.files.find(f => f.type === 'clientJs')!.content
467
+
468
+ // A single click bound in the browser used to fire the handler twice —
469
+ // once from the outer arm's own qsa()+addEventListener, once again from
470
+ // the nested insert()'s bindEvents — silently double-toggling state.
471
+ expect((content.match(/addEventListener\('click'/g) ?? []).length).toBe(1)
472
+ expect((content.match(/setAttribute\('class'/g) ?? []).length).toBe(1)
473
+ })
474
+
475
+ test('a JSX-callback prop call as the WHOLE branch content does not get a nested text effect', () => {
476
+ // Regression pin for a fix-of-the-fix: the initial #2347 patch made
477
+ // `summarizeLoopChildBranch` collect reactive texts for any branch,
478
+ // which newly reached a per-item conditional whose branch is a single
479
+ // bare `expression` (no wrapping element) — e.g. a hoisted JSX-callback
480
+ // prop call (`renderNode={(n) => <Pill/>}`-shaped, #1211/#1213). That
481
+ // value is already fully re-evaluated and spliced via `__bfSlot`
482
+ // whenever insert() (re-)mounts the branch; wrapping it in an
483
+ // *additional* createEffect re-invokes the callback (producing a
484
+ // second, independent live element) on the very first run, and the
485
+ // loop-child arm's `$t()`-based anchor lookup can't cleanly displace an
486
+ // already-mounted Element — so the second instance renders beside the
487
+ // first instead of replacing it (surfaced via barefootjs-xyflow's
488
+ // renderNode reference page in CI).
489
+ const source = `
490
+ 'use client'
491
+ import { createSignal } from '@barefootjs/client'
492
+
493
+ interface Node { id: string }
494
+
495
+ export function Flow(props: { renderNode?: (n: Node) => unknown }) {
496
+ const [nodes] = createSignal<Node[]>([])
497
+ return (
498
+ <div>
499
+ {nodes().map(n => (
500
+ <div key={n.id}>
501
+ {props.renderNode ? props.renderNode(n) : <span>{n.id}</span>}
502
+ </div>
503
+ ))}
504
+ </div>
505
+ )
506
+ }
507
+ `
508
+ const result = compileJSX(source, 'Flow.tsx', { adapter })
509
+ expect(result.errors).toHaveLength(0)
510
+ const content = result.files.find(f => f.type === 'clientJs')!.content
511
+
512
+ // No nested createEffect re-invoking `props.renderNode(n)` — the
513
+ // conditional's own template()/insert() already owns this value.
514
+ expect(content).not.toMatch(/createEffect\(\(\) => \{ __rt_\w+ = __bfText\(__rt_\w+, \(?\s*props\.renderNode\(n\(\)\)/)
515
+ })
516
+ })
@@ -111,8 +111,11 @@ describe('nested loop binding ids (#1795 Phase 3)', () => {
111
111
 
112
112
  test('loop-child conditional insert() and its branch text carry ids', () => {
113
113
  const on = clientJs(nestedSource, 'Nested', true)
114
- // The branch-arm text (`{r.label}` inside the conditional's true arm).
115
- expect(on).toMatch(/__rt_s\d+\.textContent = String\(r\(\)\.label\) }, "Nested#binding:s\d+"\)/)
114
+ // The branch-arm text (`{r.label}` inside the conditional's true arm)
115
+ // __bfText (not a naive `.textContent = String(...)`) so a Child-position
116
+ // expression whose value is a live Node splices by identity instead of
117
+ // stringifying (#2347).
118
+ expect(on).toMatch(/__rt_s\d+ = __bfText\(__rt_s\d+, r\(\)\.label\) }, "Nested#binding:s\d+"\)/)
116
119
  // The inner loop's child text (`{t}`).
117
120
  expect(on).toMatch(/String\(t\(\)\) }, "Nested#binding:s\d+"\)/)
118
121
  })
@@ -878,7 +878,7 @@ export function Ticker() {
878
878
  expect(clientJs!.content).toMatch(/import\s*\{[^}]*onMount[^}]*\}\s*from\s*'@barefootjs\/client\/runtime'/)
879
879
  })
880
880
 
881
- test('M18: a type-only helper import does not trigger BF112 and is not re-provisioned', () => {
881
+ test('M18: a type-only helper import does not trigger BF112 and is re-provisioned as `import type` (#2350)', () => {
882
882
  writeFixture('matrix18/todo-types.ts', `export interface Todo {
883
883
  id: number
884
884
  text: string
@@ -906,11 +906,262 @@ export function TodoList() {
906
906
  const result = compileJSX(consumerSource, consumerPath, { adapter })
907
907
  expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
908
908
  expect(result.errors.find(e => e.code === 'BF112')).toBeUndefined()
909
+ // Re-provisioned into the still-typed rewritten source (#2350) so tsc on
910
+ // the compiled output can resolve `Todo` in the inlined `createSignal<Todo[]>` —
911
+ // otherwise this is exactly Sora's SavedList gap (see App.tsx's #2350 comment).
912
+ const template = result.files.find(f => f.type === 'markedTemplate')
913
+ expect(template).toBeDefined()
914
+ expect(template!.content).toMatch(/import\s+type\s*\{\s*Todo\s*\}\s*from\s*'\.\/todo-types'/)
915
+ // But never reaches the runtime bundle — type-only imports are erased
916
+ // before clientJs, same as any other TypeScript type annotation.
909
917
  const clientJs = result.files.find(f => f.type === 'clientJs')
910
918
  expect(clientJs).toBeDefined()
911
919
  expect(clientJs!.content).not.toContain('./todo-types')
912
920
  })
913
921
 
922
+ test('M18b: an already-imported type at the call site is not re-provisioned a second time (#2350)', () => {
923
+ // Mirrors Sora's App.tsx: the compiler doesn't re-provision a factory
924
+ // body's TYPE-only references on its own (extractFreeIdentifiersFromNode
925
+ // stops at type nodes), so Sora's App.tsx carries a manual
926
+ // `import type { SavedList }` for exactly this reason. Once #2350 adds
927
+ // re-provisioning, that manual import must dedupe against it, not
928
+ // produce a second, colliding `import type { SavedList }` line.
929
+ writeFixture('matrix18b/schema.ts', `export interface SavedList {
930
+ id: string
931
+ }
932
+ `)
933
+ writeFixture('matrix18b/useListStore.tsx', `'use client'
934
+ import { createSignal } from '@barefootjs/client'
935
+ import type { SavedList } from './schema'
936
+
937
+ export function useListStore() {
938
+ function emptyCard(): SavedList {
939
+ return { id: 'x' }
940
+ }
941
+ const [lists, setLists] = createSignal<SavedList[]>([emptyCard()])
942
+ return { lists, setLists }
943
+ }
944
+ `)
945
+ const consumerSource = `'use client'
946
+ import { useListStore } from './useListStore'
947
+ import type { SavedList } from './schema'
948
+
949
+ export function App() {
950
+ const { lists, setLists } = useListStore()
951
+ return <button onClick={() => setLists([])}>{lists().length}</button>
952
+ }
953
+ `
954
+ const consumerPath = writeFixture('matrix18b/App.tsx', consumerSource)
955
+
956
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
957
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
958
+ expect(result.errors.find(e => e.code === 'BF113')).toBeUndefined()
959
+ const template = result.files.find(f => f.type === 'markedTemplate')
960
+ expect(template).toBeDefined()
961
+ const matches = template!.content.match(/import\s+type\s*\{\s*SavedList\s*\}/g) ?? []
962
+ expect(matches).toHaveLength(1)
963
+ })
964
+
965
+ test('M18c: a value need is never satisfied by an existing type-only import of the same name (#2350)', () => {
966
+ // The inverse of M18b: the entry file's own `import type { Shared }`
967
+ // has no runtime binding, so a factory needing `Shared` as a VALUE must
968
+ // still decline (BF113) rather than silently treat the type-only import
969
+ // as satisfying it — that would compile clean and crash at hydration
970
+ // (the same dangling-reference direction as #2341 BUG-2), the one
971
+ // failure mode this whole feature exists to avoid.
972
+ writeFixture('matrix18c/shared.ts', `export class Shared {
973
+ static make(): Shared { return new Shared() }
974
+ }
975
+ `)
976
+ writeFixture('matrix18c/useShared.tsx', `'use client'
977
+ import { createSignal } from '@barefootjs/client'
978
+ import { Shared } from './shared'
979
+
980
+ export function useShared() {
981
+ const [value, setValue] = createSignal(Shared.make())
982
+ return { value, setValue }
983
+ }
984
+ `)
985
+ const consumerSource = `'use client'
986
+ import { useShared } from './useShared'
987
+ import type { Shared } from './shared'
988
+
989
+ export function App() {
990
+ const { value, setValue } = useShared()
991
+ return <button onClick={() => setValue(null as unknown as Shared)}>{String(value())}</button>
992
+ }
993
+ `
994
+ const consumerPath = writeFixture('matrix18c/App.tsx', consumerSource)
995
+
996
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
997
+ const bf113 = result.errors.find(e => e.code === 'BF113')
998
+ expect(bf113).toBeDefined()
999
+ expect(bf113!.message).toContain('Shared')
1000
+ })
1001
+
1002
+ test('M18d: a helper VALUE import used only in type position is still re-provisioned (Copilot review, PR #2351)', () => {
1003
+ // `Shape` is a plain value import in the helper file — never wrapped in
1004
+ // `type`/`import type` — but the factory body only ever uses it as a
1005
+ // type annotation. The type-position walk must still find it via
1006
+ // moduleBindings.imported (not just importedTypes), or this regresses
1007
+ // to the exact #2350 gap for names that happen not to be type-only
1008
+ // imports in their OWN file.
1009
+ writeFixture('matrix18d/shape.ts', `export class Shape {
1010
+ area(): number { return 0 }
1011
+ }
1012
+ `)
1013
+ writeFixture('matrix18d/useShape.tsx', `'use client'
1014
+ import { createSignal } from '@barefootjs/client'
1015
+ import { Shape } from './shape'
1016
+
1017
+ export function useShape() {
1018
+ function makeDefault(): Shape {
1019
+ return new Shape()
1020
+ }
1021
+ const [shape, setShape] = createSignal<Shape>(makeDefault())
1022
+ return { shape, setShape }
1023
+ }
1024
+ `)
1025
+ const consumerSource = `'use client'
1026
+ import { useShape } from './useShape'
1027
+
1028
+ export function App() {
1029
+ const { shape, setShape } = useShape()
1030
+ return <button onClick={() => setShape(shape())}>{String(shape())}</button>
1031
+ }
1032
+ `
1033
+ const consumerPath = writeFixture('matrix18d/App.tsx', consumerSource)
1034
+
1035
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
1036
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
1037
+ const template = result.files.find(f => f.type === 'markedTemplate')
1038
+ expect(template).toBeDefined()
1039
+ // Re-provisioned as a normal VALUE import (not `import type`) — a value
1040
+ // import already brings the type into scope, and the value form is what
1041
+ // the helper file itself declared.
1042
+ expect(template!.content).toMatch(/import\s*\{\s*Shape\s*\}\s*from\s*'\.\/shape'/)
1043
+ expect(template!.content).not.toMatch(/import\s+type\s*\{\s*Shape\s*\}/)
1044
+ })
1045
+
1046
+ test('M18e: a generic type parameter is not misclassified as a module-scope type reference (Copilot review, PR #2351)', () => {
1047
+ // The factory's own <Item> type parameter shadows an unrelated
1048
+ // module-scope `Item` type import — referencing the PARAMETER inside
1049
+ // the factory body must not trigger re-provisioning of the import (it
1050
+ // isn't actually referenced at all).
1051
+ writeFixture('matrix18e/item-types.ts', `export interface Item {
1052
+ id: string
1053
+ }
1054
+ `)
1055
+ writeFixture('matrix18e/useBox.tsx', `'use client'
1056
+ import { createSignal } from '@barefootjs/client'
1057
+ import type { Item } from './item-types'
1058
+
1059
+ export function useBox<Item>(initial: Item) {
1060
+ const [value, setValue] = createSignal<Item>(initial)
1061
+ return { value, setValue }
1062
+ }
1063
+ `)
1064
+ const consumerSource = `'use client'
1065
+ import { useBox } from './useBox'
1066
+
1067
+ export function App() {
1068
+ const { value, setValue } = useBox(0)
1069
+ return <button onClick={() => setValue(1)}>{String(value())}</button>
1070
+ }
1071
+ `
1072
+ const consumerPath = writeFixture('matrix18e/App.tsx', consumerSource)
1073
+
1074
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
1075
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
1076
+ const template = result.files.find(f => f.type === 'markedTemplate')
1077
+ expect(template).toBeDefined()
1078
+ expect(template!.content).not.toContain('./item-types')
1079
+ })
1080
+
1081
+ test('M18f: a `typeof` reference to a helper value import is re-provisioned as a value import (Copilot review, PR #2351)', () => {
1082
+ // `typeof DEFAULT_SHAPE` is type position syntactically (a TypeQueryNode),
1083
+ // but the name it names — DEFAULT_SHAPE — is a VALUE import, and only its
1084
+ // TYPE is being borrowed here (the nested makeShape's return type). The
1085
+ // body never reads DEFAULT_SHAPE as a value directly, so this isolates
1086
+ // the TypeQueryNode path from the plain-value-walk path that already
1087
+ // covers a direct `DEFAULT_SHAPE` reference regardless of this fix.
1088
+ writeFixture('matrix18f/shapes.ts', `export const DEFAULT_SHAPE = { kind: 'circle' as const, radius: 1 }
1089
+ `)
1090
+ writeFixture('matrix18f/useShape.tsx', `'use client'
1091
+ import { createSignal } from '@barefootjs/client'
1092
+ import { DEFAULT_SHAPE } from './shapes'
1093
+
1094
+ export function useShape() {
1095
+ function makeShape(): typeof DEFAULT_SHAPE {
1096
+ return { kind: 'circle', radius: 1 }
1097
+ }
1098
+ const [shape, setShape] = createSignal(makeShape())
1099
+ return { shape, setShape }
1100
+ }
1101
+ `)
1102
+ const consumerSource = `'use client'
1103
+ import { useShape } from './useShape'
1104
+
1105
+ export function App() {
1106
+ const { shape, setShape } = useShape()
1107
+ return <button onClick={() => setShape({ kind: 'circle', radius: 2 })}>{shape().kind}</button>
1108
+ }
1109
+ `
1110
+ const consumerPath = writeFixture('matrix18f/App.tsx', consumerSource)
1111
+
1112
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
1113
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
1114
+ const template = result.files.find(f => f.type === 'markedTemplate')
1115
+ expect(template).toBeDefined()
1116
+ expect(template!.content).toMatch(/import\s*\{\s*DEFAULT_SHAPE\s*\}\s*from\s*'\.\/shapes'/)
1117
+ expect(template!.content).not.toMatch(/import\s+type\s*\{\s*DEFAULT_SHAPE\s*\}/)
1118
+ })
1119
+
1120
+ test('M18g: value and type-only re-provisioned imports are globally sorted by specifier, not grouped by kind (Copilot review, PR #2351)', () => {
1121
+ // A type-only need from 'a-types' and a value need from 'z-value' — a
1122
+ // two-separately-sorted-lists implementation would put ALL value lines
1123
+ // before ALL type lines regardless of specifier, landing 'z-value'
1124
+ // before 'a-types'. The correct order interleaves by specifier: 'a-types'
1125
+ // (type-only) first, then 'z-value' (value).
1126
+ writeFixture('matrix18g/a-types.ts', `export interface Early { id: string }
1127
+ `)
1128
+ writeFixture('matrix18g/z-value.ts', `export function makeLate(): number { return 1 }
1129
+ `)
1130
+ writeFixture('matrix18g/useBoth.tsx', `'use client'
1131
+ import { createSignal } from '@barefootjs/client'
1132
+ import type { Early } from './a-types'
1133
+ import { makeLate } from './z-value'
1134
+
1135
+ export function useBoth() {
1136
+ function makeEarly(): Early {
1137
+ return { id: 'x' }
1138
+ }
1139
+ const [n, setN] = createSignal(makeLate())
1140
+ const [e, setE] = createSignal(makeEarly())
1141
+ return { n, setN, e, setE }
1142
+ }
1143
+ `)
1144
+ const consumerSource = `'use client'
1145
+ import { useBoth } from './useBoth'
1146
+
1147
+ export function App() {
1148
+ const { n, setN, e, setE } = useBoth()
1149
+ return <button onClick={() => setN(n() + 1)}>{n()} {e().id}</button>
1150
+ }
1151
+ `
1152
+ const consumerPath = writeFixture('matrix18g/App.tsx', consumerSource)
1153
+
1154
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
1155
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
1156
+ const template = result.files.find(f => f.type === 'markedTemplate')
1157
+ expect(template).toBeDefined()
1158
+ const aTypesIndex = template!.content.indexOf("from './a-types'")
1159
+ const zValueIndex = template!.content.indexOf("from './z-value'")
1160
+ expect(aTypesIndex).toBeGreaterThan(-1)
1161
+ expect(zValueIndex).toBeGreaterThan(-1)
1162
+ expect(aTypesIndex).toBeLessThan(zValueIndex)
1163
+ })
1164
+
914
1165
  test('M19: a colliding binding nested inside a JSX callback still triggers BF113', () => {
915
1166
  // Pins collectEntryBindingNames's depth: the ONLY `doubleIt` binding in
916
1167
  // the consumer file is declared inside an onClick callback, not at any
@@ -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