@barefootjs/jsx 0.17.1 → 0.18.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 (79) hide show
  1. package/dist/adapters/interface.d.ts +20 -4
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer-context.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +19 -0
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/expression-parser.d.ts +1 -0
  11. package/dist/expression-parser.d.ts.map +1 -1
  12. package/dist/index.d.ts +19 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +815 -251
  15. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  24. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +22 -1
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/jsx-to-ir.d.ts.map +1 -1
  30. package/dist/loop-destructure.d.ts +55 -18
  31. package/dist/loop-destructure.d.ts.map +1 -1
  32. package/dist/lowering-registry.d.ts +13 -0
  33. package/dist/lowering-registry.d.ts.map +1 -1
  34. package/dist/relocate.d.ts +28 -0
  35. package/dist/relocate.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/types.d.ts +68 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  41. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  42. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  43. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  44. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  45. package/src/__tests__/destructured-map-params.test.ts +11 -1
  46. package/src/__tests__/expression-parser.test.ts +63 -2
  47. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  48. package/src/__tests__/loop-destructure.test.ts +313 -0
  49. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  50. package/src/__tests__/props-destructuring.test.ts +110 -0
  51. package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
  52. package/src/__tests__/ssr-defaults.test.ts +20 -0
  53. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  54. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  55. package/src/__tests__/unsupported-expression.test.ts +194 -7
  56. package/src/adapters/interface.ts +20 -4
  57. package/src/adapters/parsed-expr-emitter.ts +19 -2
  58. package/src/analyzer-context.ts +20 -0
  59. package/src/analyzer.ts +74 -1
  60. package/src/augment-inherited-props.ts +139 -9
  61. package/src/compiler.ts +4 -0
  62. package/src/expression-parser.ts +237 -56
  63. package/src/index.ts +23 -1
  64. package/src/ir-to-client-js/collect-elements.ts +15 -1
  65. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  66. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  68. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  69. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  70. package/src/ir-to-client-js/emit-registration.ts +4 -2
  71. package/src/ir-to-client-js/html-template.ts +198 -1
  72. package/src/ir-to-client-js/index.ts +1 -0
  73. package/src/ir-to-client-js/types.ts +22 -0
  74. package/src/jsx-to-ir.ts +409 -24
  75. package/src/loop-destructure.ts +89 -36
  76. package/src/lowering-registry.ts +16 -0
  77. package/src/relocate.ts +201 -14
  78. package/src/ssr-defaults.ts +34 -32
  79. package/src/types.ts +65 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Regression tests for #2106: the templatePrimitives V2 widening (#2069)
3
+ * let a call whose RAW callee is an identifier path
4
+ * (`someModuleArray.includes(prop)`) pass `computeInlinability`'s Stage-2
5
+ * classification (`classifyConstantInitial`, compute-inlinability.ts) —
6
+ * e.g. via an adapter with a broad `acceptsTemplateCall` (Hono's SSR
7
+ * runtime is a full JS engine, so it accepts any identifier-path callee).
8
+ * That check runs on the RAW initializer text so it can still see
9
+ * bridged prop args for the #1138 rejection.
10
+ *
11
+ * `populateCsrInlinable` (compute-inlinability.ts) re-runs the same
12
+ * `isInlinableInTemplate` check on the CSR-*substituted* form, where the
13
+ * module-scope array has already been literal-inlined
14
+ * (`['plus','minus'].includes(name)`). The callee is no longer an
15
+ * identifier path at all, so the adapter can't vouch for it, and the
16
+ * bridged-arg rejection correctly fires — recorded as
17
+ * `ctx.csrInlinable.get(name) === null`.
18
+ *
19
+ * Pre-fix, `unsafeLocalNames` (Stage-2-derived) didn't know about this
20
+ * second, stricter refusal, so the CSR template emitter
21
+ * (`generateCsrTemplate` / `transformExpr` in html-template.ts) found
22
+ * neither a substitution (excluded by `csrInlinableConstantsFromCtx`,
23
+ * which skips null entries) NOR an unsafe flag — the bare,
24
+ * module-scope-invisible identifier leaked straight into the emitted
25
+ * template text, throwing `ReferenceError` when the template function
26
+ * actually ran. This is the same bug class (and same shape) as the real
27
+ * repro in `ui/components/ui/icon`'s `linecap` constant.
28
+ *
29
+ * Fix: `generateCsrTemplate` (html-template.ts) folds `ctx.csrInlinable`'s
30
+ * null verdicts into the unsafe-name set it uses internally
31
+ * (`mergeCsrNullUnsafe`), so a name `populateCsrInlinable` refused is
32
+ * always treated as unsafe at the CSR template layer, regardless of what
33
+ * the looser Stage-2 check concluded elsewhere.
34
+ */
35
+ import { describe, test, expect } from 'bun:test'
36
+ import { compileJSX } from '../compiler'
37
+ import { HonoAdapter } from '../../../adapter-hono/src/adapter/hono-adapter'
38
+ import { extractTemplateBody } from './staged-ir/helpers'
39
+ import { RUNTIME_IMPORT_CANDIDATES } from '../ir-to-client-js/imports'
40
+
41
+ const onlyErrors = (errors: { severity?: string }[]) =>
42
+ errors.filter((e) => e.severity === 'error')
43
+
44
+ /**
45
+ * Compile `source` with `HonoAdapter` (broad `acceptsTemplateCall`, the
46
+ * ingredient the real bug needs) and extract the CSR template's
47
+ * back-tick body.
48
+ */
49
+ function compileTemplateBody(source: string, fileName: string): { templateBody: string; clientJs: string; errors: string[] } {
50
+ const result = compileJSX(source, fileName, { adapter: new HonoAdapter() })
51
+ expect(onlyErrors(result.errors)).toHaveLength(0)
52
+ const clientJs = result.files.find((f) => f.type === 'clientJs')!.content
53
+ return { templateBody: extractTemplateBody(clientJs), clientJs, errors: result.errors.map((e) => e.code) }
54
+ }
55
+
56
+ /**
57
+ * Evaluate an extracted CSR template body as a real template-literal
58
+ * function, with every runtime helper the compiler might reference
59
+ * stubbed out. Mirrors what `hydrate()` does at runtime, minus the DOM.
60
+ */
61
+ function evalTemplate(templateBody: string, props: Record<string, unknown>): string {
62
+ const helperNames = [...RUNTIME_IMPORT_CANDIDATES]
63
+ const stubs = helperNames.map((name) => {
64
+ if (name === 'escapeAttr' || name === 'escapeText' || name === 'styleToCss') {
65
+ return (v: unknown) => String(v)
66
+ }
67
+ return (..._args: unknown[]) => ''
68
+ })
69
+ // eslint-disable-next-line no-new-func
70
+ const fn = new Function('_p', ...helperNames, `return \`${templateBody}\``)
71
+ return fn(props, ...stubs)
72
+ }
73
+
74
+ describe('CSR template: Stage-2 / post-substitution inline-safety divergence (#2106)', () => {
75
+ test('a const whose RAW form is adapter-accepted but whose CSR-substituted form is refused falls back to undefined, not a bare identifier', () => {
76
+ const source = `
77
+ 'use client'
78
+ import { createSignal } from '@barefootjs/client'
79
+
80
+ const buttLinecapIcons = ['plus', 'minus']
81
+
82
+ export function CsrNullDivergence({ name }: { name: string }) {
83
+ const [count] = createSignal(0)
84
+ const linecap = (buttLinecapIcons).includes(name) ? 'butt' : 'round'
85
+ return <div data-linecap={linecap}>{count()}</div>
86
+ }
87
+ `
88
+ const { templateBody } = compileTemplateBody(source, 'CsrNullDivergence.tsx')
89
+
90
+ // (a) No bare `linecap` identifier anywhere in the template body.
91
+ // Excludes the HTML attribute name text `data-linecap="` (preceded by
92
+ // `-`, not a real identifier reference) from the check.
93
+ expect(templateBody).not.toMatch(/(?<![-.\w$])linecap(?![\w$])/)
94
+
95
+ // (c) The fallback is the spec'd null-guarded `undefined` shape
96
+ // (`(undefined) != null ? '...' : ''`), the same fallback every other
97
+ // unsafe-template-scope reference degrades to (html-template.ts's
98
+ // `templateAttrExpr` generic path).
99
+ expect(templateBody).toMatch(/\(undefined\)\s*!=\s*null/)
100
+
101
+ // (b) Evaluating the template with stub runtime helpers must not
102
+ // throw `ReferenceError: linecap is not defined` — the pre-fix
103
+ // manifestation of this bug.
104
+ let rendered = ''
105
+ expect(() => { rendered = evalTemplate(templateBody, { name: 'plus', size: 'md', className: '' }) }).not.toThrow()
106
+
107
+ // The dropped-attribute fallback: SSR/CSR-initial HTML omits the
108
+ // attribute entirely rather than emitting a bogus value.
109
+ expect(rendered).not.toContain('linecap=')
110
+ })
111
+
112
+ test('control: a plain object-literal lookup (no call involved) still inlines through the same CSR path', () => {
113
+ // Guards against over-suppression: the fix must only affect names
114
+ // `ctx.csrInlinable` actually refused (`=== null`), not every const
115
+ // whose value happens to reference a module-scope constant.
116
+ const source = `
117
+ 'use client'
118
+ import { createSignal } from '@barefootjs/client'
119
+
120
+ const sizeMap: Record<string, string> = { sm: 'h-4', md: 'h-6' }
121
+
122
+ export function CsrStillInlines({ size }: { size: string }) {
123
+ const [count] = createSignal(0)
124
+ const cls = sizeMap[size]
125
+ return <div data-cls={cls}>{count()}</div>
126
+ }
127
+ `
128
+ const { templateBody } = compileTemplateBody(source, 'CsrStillInlines.tsx')
129
+
130
+ // No bare `cls` identifier left in the template — it was inlined.
131
+ expect(templateBody).not.toMatch(/(?<![-.\w$])cls(?![\w$])/)
132
+ // The module-scope object literal was substituted in place of `sizeMap`,
133
+ // and the prop was correctly bridged to `_p.size` — not marked unsafe.
134
+ expect(templateBody).toContain(`{ sm: 'h-4', md: 'h-6' })[_p.size]`)
135
+ expect(templateBody).not.toContain('undefined')
136
+ })
137
+ })
@@ -51,7 +51,17 @@ describe('destructured .map() param rewriting (#951)', () => {
51
51
  // Binding references rewritten to accessor paths
52
52
  expect(js).toContain('__bfItem().label')
53
53
  expect(js).toContain('__bfItem().done')
54
- expect(js).toContain('__bfItem().id')
54
+ // `id` is referenced ONLY via `key={id}` in this fixture. The loop's
55
+ // `keyFn` argument to `mapArray` evaluates against the raw destructured
56
+ // item (before per-item signal wrapping) — `({ id, label, done }) =>
57
+ // String(id)` — so `id` never needs the `__bfItem()` accessor rewrite at
58
+ // all; `mapArray` stamps the real `data-key` from that keyFn onto every
59
+ // freshly created element itself (see `map-array.ts`). The hoisted
60
+ // shared-template fast path (perf) therefore omits the key interpolation
61
+ // from the once-per-loop skeleton (`data-key=""` placeholder) rather than
62
+ // baking a `__bfItem().id` reference into it — no `__bfItem().id`
63
+ // occurrence anywhere is the CORRECT, not incomplete, output here.
64
+ expect(js).toContain('({ id, label, done }) => String(id)')
55
65
  })
56
66
 
57
67
  test('tuple destructure with hole: references become __bfItem()[index]', () => {
@@ -1325,6 +1325,45 @@ describe('expression-parser', () => {
1325
1325
  expect(result.level).toBe('L4')
1326
1326
  })
1327
1327
 
1328
+ // #2087: `x ?? {}` — the chart component's `<Ctx.Provider value={{
1329
+ // config: props.config ?? {} }}>` needs an EMPTY object-literal fallback
1330
+ // operand admitted, narrowly, as the right side of `??` only.
1331
+ test('L4: nullish coalescing with an EMPTY object-literal fallback is supported (#2087)', () => {
1332
+ const expr = parseExpression('props.config ?? {}')
1333
+ expect(expr.kind).toBe('logical')
1334
+ const result = isSupported(expr)
1335
+ expect(result.supported).toBe(true)
1336
+ expect(result.level).toBe('L4')
1337
+ })
1338
+
1339
+ test('a NON-EMPTY object-literal fallback of `??` is still refused (#2087 scope)', () => {
1340
+ const expr = parseExpression('props.config ?? ({ a: 1 })')
1341
+ expect(expr.kind).toBe('logical')
1342
+ const result = isSupported(expr)
1343
+ expect(result.supported).toBe(false)
1344
+ })
1345
+
1346
+ test('an empty object-literal fallback of `&&` / `||` is still refused — #2087 scopes the relaxation to `??` only', () => {
1347
+ expect(isSupported(parseExpression('props.config && ({})')).supported).toBe(false)
1348
+ expect(isSupported(parseExpression('props.config || ({})')).supported).toBe(false)
1349
+ })
1350
+
1351
+ test('a standalone (non-fallback) object literal is still refused', () => {
1352
+ const expr = parseExpression('({})')
1353
+ const result = isSupported(expr)
1354
+ expect(result.supported).toBe(false)
1355
+ expect(result.reason).toBe('Unsupported syntax: ObjectLiteralExpression')
1356
+ })
1357
+
1358
+ test('an empty object-literal on the LEFT of `??` does not trigger the fallback relaxation', () => {
1359
+ // The relaxation only inspects `expr.right`; a left-hand object
1360
+ // literal has no fallback role and stays refused via the general
1361
+ // `checkSupport(expr.left)` refusal.
1362
+ const expr = parseExpression('({}) ?? props.config')
1363
+ const result = isSupported(expr)
1364
+ expect(result.supported).toBe(false)
1365
+ })
1366
+
1328
1367
  test('L5: filter() with simple predicate IS supported', () => {
1329
1368
  const expr = parseExpression('items().filter(x => x.done)')
1330
1369
  const result = isSupported(expr)
@@ -1700,14 +1739,30 @@ describe('expression-parser — .flat(depth?) lowering (#1448 Tier C)', () => {
1700
1739
  })
1701
1740
  }
1702
1741
 
1703
- test('non-literal depth refuses (must be resolvable at template time)', () => {
1742
+ test('a non-literal depth that itself resolves becomes a DYNAMIC depth (#2094)', () => {
1743
+ // `n` parses to a supported `identifier` — the depth isn't known until
1744
+ // render time, but it's still an EXPRESSIBLE one, so it's accepted as a
1745
+ // dynamic depth (`depthExpr`) instead of refusing.
1704
1746
  const result = parseExpression('arr.flat(n)')
1747
+ expect(result.kind).toBe('array-method')
1748
+ if (result.kind === 'array-method' && result.method === 'flat') {
1749
+ expect(result.depthExpr).toEqual({ kind: 'identifier', name: 'n' })
1750
+ } else {
1751
+ throw new Error(`expected a flat array-method, got ${result.kind}`)
1752
+ }
1753
+ // An arithmetic / member-access depth resolves too.
1754
+ expect(parseExpression('arr.flat(depth + 1)').kind).toBe('array-method')
1755
+ expect(parseExpression('arr.flat(props.depth)').kind).toBe('array-method')
1756
+ })
1757
+
1758
+ test('a non-literal depth that itself does NOT resolve still refuses', () => {
1759
+ const result = parseExpression('arr.flat(a instanceof B)')
1705
1760
  expect(result.kind).toBe('unsupported')
1706
1761
  if (result.kind === 'unsupported') {
1707
1762
  // Wrong remedy `@client` must not be suggested (doesn't work in
1708
1763
  // attribute / condition position).
1709
1764
  expect(result.reason).not.toContain('@client')
1710
- expect(result.reason).toContain('literal')
1765
+ expect(result.reason).toContain('resolved')
1711
1766
  }
1712
1767
  })
1713
1768
 
@@ -1719,6 +1774,12 @@ describe('expression-parser — .flat(depth?) lowering (#1448 Tier C)', () => {
1719
1774
  expect(exprToString(parseExpression('arr.flat(Infinity)'))).toBe('arr.flat(Infinity)')
1720
1775
  expect(exprToString(parseExpression('arr.flat(0)'))).toBe('arr.flat(0)')
1721
1776
  })
1777
+
1778
+ test('exprToString / stringifyParsedExpr round-trip a dynamic depth (#2094)', () => {
1779
+ expect(exprToString(parseExpression('arr.flat(n)'))).toBe('arr.flat(n)')
1780
+ expect(stringifyParsedExpr(parseExpression('arr.flat(n)'))).toBe('arr.flat(n)')
1781
+ expect(stringifyParsedExpr(parseExpression('arr.flat(depth + 1)'))).toBe('arr.flat(depth + 1)')
1782
+ })
1722
1783
  })
1723
1784
 
1724
1785
  describe('expression-parser — .flatMap(fn) projection (#1448 Tier C)', () => {
@@ -382,3 +382,264 @@ describe('sort().map() / toSorted().map()', () => {
382
382
  }
383
383
  })
384
384
  })
385
+
386
+ // #2090: `.sort(cb)` / `.toSorted(cb)` where `cb` is a bare identifier
387
+ // reference to a same-file arrow/function-declaration comparator, resolved
388
+ // through the analyzer's scope machinery (`findLocalConst` /
389
+ // `findLocalFunction` in jsx-to-ir.ts) one hop, then fed through the SAME
390
+ // `sortComparatorFromArrow` catalogue as an inline comparator — no new
391
+ // comparator shapes, no IR schema change.
392
+ describe('function-reference sort comparator (#2090)', () => {
393
+ test('const arrow reference, ascending', () => {
394
+ const source = `
395
+ 'use client'
396
+ import { createSignal } from '@barefootjs/client'
397
+
398
+ export function ProductList() {
399
+ const [products, setProducts] = createSignal<any[]>([])
400
+ const byPrice = (a, b) => a.price - b.price
401
+ return (
402
+ <ul>
403
+ {products().sort(byPrice).map(p => (
404
+ <li key={p.name}>{p.name}</li>
405
+ ))}
406
+ </ul>
407
+ )
408
+ }
409
+ `
410
+
411
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
412
+ const ir = jsxToIR(ctx)
413
+
414
+ expect(ir).not.toBeNull()
415
+ if (ir!.type === 'element') {
416
+ const loop = ir!.children.find(c => c.type === 'loop')
417
+ expect(loop).toBeDefined()
418
+ if (loop?.type === 'loop') {
419
+ expect(loop.sortComparator).toBeDefined()
420
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
421
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
422
+ ])
423
+ expect(loop.sortComparator!.paramA).toBe('a')
424
+ expect(loop.sortComparator!.paramB).toBe('b')
425
+ expect(loop.sortComparator!.raw).toBe('a.price - b.price')
426
+ expect(loop.array).toBe('products()')
427
+ }
428
+ }
429
+ })
430
+
431
+ test('const arrow reference, descending multi-key (||-chain)', () => {
432
+ const source = `
433
+ 'use client'
434
+ import { createSignal } from '@barefootjs/client'
435
+
436
+ export function ProductList() {
437
+ const [products, setProducts] = createSignal<any[]>([])
438
+ const byPriceThenName = (a, b) => b.price - a.price || a.name.localeCompare(b.name)
439
+ return (
440
+ <ul>
441
+ {products().sort(byPriceThenName).map(p => (
442
+ <li key={p.name}>{p.name}</li>
443
+ ))}
444
+ </ul>
445
+ )
446
+ }
447
+ `
448
+
449
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
450
+ const ir = jsxToIR(ctx)
451
+
452
+ expect(ir).not.toBeNull()
453
+ if (ir!.type === 'element') {
454
+ const loop = ir!.children.find(c => c.type === 'loop')
455
+ if (loop?.type === 'loop') {
456
+ expect(loop.sortComparator).toBeDefined()
457
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
458
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'desc' },
459
+ { key: { kind: 'field', field: 'name' }, type: 'string', direction: 'asc' },
460
+ ])
461
+ expect(loop.sortComparator!.raw).toBe('b.price - a.price || a.name.localeCompare(b.name)')
462
+ }
463
+ }
464
+ })
465
+
466
+ test('function-declaration reference with return-block body', () => {
467
+ const source = `
468
+ 'use client'
469
+ import { createSignal } from '@barefootjs/client'
470
+
471
+ function byPrice(a, b) {
472
+ return a.price - b.price
473
+ }
474
+
475
+ export function ProductList() {
476
+ const [products, setProducts] = createSignal<any[]>([])
477
+ return (
478
+ <ul>
479
+ {products().sort(byPrice).map(p => (
480
+ <li key={p.name}>{p.name}</li>
481
+ ))}
482
+ </ul>
483
+ )
484
+ }
485
+ `
486
+
487
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
488
+ const ir = jsxToIR(ctx)
489
+
490
+ expect(ir).not.toBeNull()
491
+ if (ir!.type === 'element') {
492
+ const loop = ir!.children.find(c => c.type === 'loop')
493
+ if (loop?.type === 'loop') {
494
+ expect(loop.sortComparator).toBeDefined()
495
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
496
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
497
+ ])
498
+ expect(loop.sortComparator!.paramA).toBe('a')
499
+ expect(loop.sortComparator!.paramB).toBe('b')
500
+ expect(loop.sortComparator!.raw).toBe('a.price - b.price')
501
+ }
502
+ }
503
+ })
504
+
505
+ test('.toSorted(ref) resolves the same way as .sort(ref)', () => {
506
+ const source = `
507
+ 'use client'
508
+ import { createSignal } from '@barefootjs/client'
509
+
510
+ export function ProductList() {
511
+ const [products, setProducts] = createSignal<any[]>([])
512
+ const byPrice = (a, b) => a.price - b.price
513
+ return (
514
+ <ul>
515
+ {products().toSorted(byPrice).map(p => (
516
+ <li key={p.name}>{p.name}</li>
517
+ ))}
518
+ </ul>
519
+ )
520
+ }
521
+ `
522
+
523
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
524
+ const ir = jsxToIR(ctx)
525
+
526
+ expect(ir).not.toBeNull()
527
+ if (ir!.type === 'element') {
528
+ const loop = ir!.children.find(c => c.type === 'loop')
529
+ if (loop?.type === 'loop') {
530
+ expect(loop.sortComparator).toBeDefined()
531
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
532
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'asc' },
533
+ ])
534
+ }
535
+ }
536
+ })
537
+
538
+ test('component-scope const shadows a module-scope const of the same name', () => {
539
+ const source = `
540
+ 'use client'
541
+ import { createSignal } from '@barefootjs/client'
542
+
543
+ const byPrice = (a, b) => a.price - b.price
544
+
545
+ export function ProductList() {
546
+ const [products, setProducts] = createSignal<any[]>([])
547
+ const byPrice = (a, b) => b.price - a.price
548
+ return (
549
+ <ul>
550
+ {products().sort(byPrice).map(p => (
551
+ <li key={p.name}>{p.name}</li>
552
+ ))}
553
+ </ul>
554
+ )
555
+ }
556
+ `
557
+
558
+ const ctx = analyzeComponent(source, 'ProductList.tsx')
559
+ const ir = jsxToIR(ctx)
560
+
561
+ expect(ir).not.toBeNull()
562
+ if (ir!.type === 'element') {
563
+ const loop = ir!.children.find(c => c.type === 'loop')
564
+ if (loop?.type === 'loop') {
565
+ expect(loop.sortComparator).toBeDefined()
566
+ // The component-scope binding (descending) wins over the
567
+ // module-scope one (ascending) — distinguishable via `direction`.
568
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
569
+ { key: { kind: 'field', field: 'price' }, type: 'numeric', direction: 'desc' },
570
+ ])
571
+ expect(loop.sortComparator!.raw).toBe('b.price - a.price')
572
+ }
573
+ }
574
+ })
575
+
576
+ test('filter().sort(ref).map() resolves the reference in a chain', () => {
577
+ const source = `
578
+ 'use client'
579
+ import { createSignal } from '@barefootjs/client'
580
+
581
+ export function TodoList() {
582
+ const [todos, setTodos] = createSignal<any[]>([])
583
+ const byPriority = (a, b) => a.priority - b.priority
584
+ return (
585
+ <ul>
586
+ {todos().filter(t => !t.done).sort(byPriority).map(t => (
587
+ <li key={t.text}>{t.text}</li>
588
+ ))}
589
+ </ul>
590
+ )
591
+ }
592
+ `
593
+
594
+ const ctx = analyzeComponent(source, 'TodoList.tsx')
595
+ const ir = jsxToIR(ctx)
596
+
597
+ expect(ir).not.toBeNull()
598
+ if (ir!.type === 'element') {
599
+ const loop = ir!.children.find(c => c.type === 'loop')
600
+ if (loop?.type === 'loop') {
601
+ expect(loop.filterPredicate).toBeDefined()
602
+ expect(loop.sortComparator).toBeDefined()
603
+ expect(loop.chainOrder).toBe('filter-sort')
604
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
605
+ { key: { kind: 'field', field: 'priority' }, type: 'numeric', direction: 'asc' },
606
+ ])
607
+ }
608
+ }
609
+ })
610
+
611
+ test('sort(ref).filter().map() resolves the reference in a chain', () => {
612
+ const source = `
613
+ 'use client'
614
+ import { createSignal } from '@barefootjs/client'
615
+
616
+ export function TodoList() {
617
+ const [todos, setTodos] = createSignal<any[]>([])
618
+ const byPriority = (a, b) => a.priority - b.priority
619
+ return (
620
+ <ul>
621
+ {todos().sort(byPriority).filter(t => !t.done).map(t => (
622
+ <li key={t.text}>{t.text}</li>
623
+ ))}
624
+ </ul>
625
+ )
626
+ }
627
+ `
628
+
629
+ const ctx = analyzeComponent(source, 'TodoList.tsx')
630
+ const ir = jsxToIR(ctx)
631
+
632
+ expect(ir).not.toBeNull()
633
+ if (ir!.type === 'element') {
634
+ const loop = ir!.children.find(c => c.type === 'loop')
635
+ if (loop?.type === 'loop') {
636
+ expect(loop.filterPredicate).toBeDefined()
637
+ expect(loop.sortComparator).toBeDefined()
638
+ expect(loop.chainOrder).toBe('sort-filter')
639
+ expect(sortComparatorFromArrow(loop.sortComparator!.arrow)!.keys).toEqual([
640
+ { key: { kind: 'field', field: 'priority' }, type: 'numeric', direction: 'asc' },
641
+ ])
642
+ }
643
+ }
644
+ })
645
+ })