@barefootjs/jsx 0.16.0 → 0.17.1

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 (65) hide show
  1. package/dist/adapters/env-signal.d.ts +73 -15
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/jsx-adapter.d.ts.map +1 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
  5. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  6. package/dist/analyzer-context.d.ts +29 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/builtin-lowering-plugins.d.ts +34 -0
  10. package/dist/builtin-lowering-plugins.d.ts.map +1 -0
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +264 -163
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +10 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +7839 -7019
  17. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  20. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  21. package/dist/jsx-to-ir.d.ts.map +1 -1
  22. package/dist/lowering-registry.d.ts +122 -0
  23. package/dist/lowering-registry.d.ts.map +1 -0
  24. package/dist/query-href-lowering.d.ts +63 -0
  25. package/dist/query-href-lowering.d.ts.map +1 -0
  26. package/dist/ssr-defaults.d.ts.map +1 -1
  27. package/dist/ssr-seed-plan.d.ts +84 -0
  28. package/dist/ssr-seed-plan.d.ts.map +1 -0
  29. package/dist/types.d.ts +180 -11
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  33. package/src/__tests__/analyzer.test.ts +53 -0
  34. package/src/__tests__/expression-parser.test.ts +714 -392
  35. package/src/__tests__/free-identifiers.test.ts +55 -0
  36. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  37. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  38. package/src/__tests__/lowering-registry.test.ts +141 -0
  39. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  40. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  41. package/src/__tests__/query-href-recognition.test.ts +58 -0
  42. package/src/__tests__/serialize-parsed-expr.test.ts +223 -0
  43. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  44. package/src/__tests__/unsupported-expression.test.ts +98 -4
  45. package/src/adapters/env-signal.ts +108 -21
  46. package/src/adapters/jsx-adapter.ts +17 -0
  47. package/src/adapters/parsed-expr-emitter.ts +39 -41
  48. package/src/analyzer-context.ts +72 -27
  49. package/src/analyzer.ts +226 -9
  50. package/src/builtin-lowering-plugins.ts +54 -0
  51. package/src/compiler.ts +6 -1
  52. package/src/expression-parser.ts +1375 -929
  53. package/src/index.ts +31 -3
  54. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  55. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  56. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  57. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  58. package/src/jsx-to-ir.ts +182 -43
  59. package/src/lowering-registry.ts +160 -0
  60. package/src/query-href-lowering.ts +147 -0
  61. package/src/ssr-defaults.ts +5 -1
  62. package/src/ssr-seed-plan.ts +146 -0
  63. package/src/types.ts +182 -12
  64. package/src/__tests__/flatmap-support.test.ts +0 -218
  65. package/src/__tests__/reduce-op.test.ts +0 -201
@@ -0,0 +1,223 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { parseExpression, serializeParsedExpr, freeVarsInBody } from '../expression-parser'
3
+
4
+ /**
5
+ * `serializeParsedExpr` emits the minimal JSON the runtime evaluator (Go
6
+ * `eval.go` `EvalNode` / Perl `Evaluator.pm` `evaluate`) reads. These tests pin
7
+ * the field contract (only evaluator-read fields per kind) and the purity gate
8
+ * (folded / non-expression kinds → null). `freeVarsInBody` pins the captured
9
+ * free-var set used to build the evaluator's `base_env`. (#2018)
10
+ */
11
+
12
+ /** Parse a callback-body source and serialize it to the evaluator JSON object. */
13
+ function evalJSON(src: string): unknown {
14
+ const s = serializeParsedExpr(parseExpression(src))
15
+ return s === null ? null : JSON.parse(s)
16
+ }
17
+
18
+ describe('serializeParsedExpr', () => {
19
+ test('literal carries only `value` (no literalType / raw)', () => {
20
+ expect(evalJSON('42')).toEqual({ kind: 'literal', value: 42 })
21
+ expect(evalJSON("'hi'")).toEqual({ kind: 'literal', value: 'hi' })
22
+ expect(evalJSON('true')).toEqual({ kind: 'literal', value: true })
23
+ expect(evalJSON('null')).toEqual({ kind: 'literal', value: null })
24
+ })
25
+
26
+ test('identifier carries `name`', () => {
27
+ expect(evalJSON('acc')).toEqual({ kind: 'identifier', name: 'acc' })
28
+ })
29
+
30
+ test('reducer body: binary over member projection', () => {
31
+ expect(evalJSON('acc + item.price')).toEqual({
32
+ kind: 'binary',
33
+ op: '+',
34
+ left: { kind: 'identifier', name: 'acc' },
35
+ // member carries object + property only (no `computed`).
36
+ right: {
37
+ kind: 'member',
38
+ object: { kind: 'identifier', name: 'item' },
39
+ property: 'price',
40
+ },
41
+ })
42
+ })
43
+
44
+ test('comparator body: 3-way ternary', () => {
45
+ expect(evalJSON('a > b ? 1 : a < b ? -1 : 0')).toEqual({
46
+ kind: 'conditional',
47
+ test: { kind: 'binary', op: '>', left: { kind: 'identifier', name: 'a' }, right: { kind: 'identifier', name: 'b' } },
48
+ consequent: { kind: 'literal', value: 1 },
49
+ alternate: {
50
+ kind: 'conditional',
51
+ test: { kind: 'binary', op: '<', left: { kind: 'identifier', name: 'a' }, right: { kind: 'identifier', name: 'b' } },
52
+ consequent: { kind: 'unary', op: '-', argument: { kind: 'literal', value: 1 } },
53
+ alternate: { kind: 'literal', value: 0 },
54
+ },
55
+ })
56
+ })
57
+
58
+ test('filter body: logical over member access', () => {
59
+ expect(evalJSON('item.done && item.priority > 3')).toEqual({
60
+ kind: 'logical',
61
+ op: '&&',
62
+ left: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'done' },
63
+ right: {
64
+ kind: 'binary',
65
+ op: '>',
66
+ left: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'priority' },
67
+ right: { kind: 'literal', value: 3 },
68
+ },
69
+ })
70
+ })
71
+
72
+ test('index-access carries object + index', () => {
73
+ expect(evalJSON('item[i]')).toEqual({
74
+ kind: 'index-access',
75
+ object: { kind: 'identifier', name: 'item' },
76
+ index: { kind: 'identifier', name: 'i' },
77
+ })
78
+ })
79
+
80
+ test('builtin call carries callee + args', () => {
81
+ expect(evalJSON('Math.max(a, b)')).toEqual({
82
+ kind: 'call',
83
+ callee: { kind: 'member', object: { kind: 'identifier', name: 'Math' }, property: 'max' },
84
+ args: [
85
+ { kind: 'identifier', name: 'a' },
86
+ { kind: 'identifier', name: 'b' },
87
+ ],
88
+ })
89
+ })
90
+
91
+ test('template literal: string + expression parts', () => {
92
+ expect(evalJSON('`n=${acc + 1}`')).toEqual({
93
+ kind: 'template-literal',
94
+ parts: [
95
+ { type: 'string', value: 'n=' },
96
+ { type: 'expression', expr: { kind: 'binary', op: '+', left: { kind: 'identifier', name: 'acc' }, right: { kind: 'literal', value: 1 } } },
97
+ ],
98
+ })
99
+ })
100
+
101
+ test('map body: object literal carries key + value (no keyKind / shorthand)', () => {
102
+ expect(evalJSON('({ id: item.id, n: item.n })')).toEqual({
103
+ kind: 'object-literal',
104
+ properties: [
105
+ { key: 'id', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'id' } },
106
+ { key: 'n', value: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'n' } },
107
+ ],
108
+ })
109
+ })
110
+
111
+ test('array literal', () => {
112
+ expect(evalJSON('[item.a, item.b]')).toEqual({
113
+ kind: 'array-literal',
114
+ elements: [
115
+ { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'a' },
116
+ { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'b' },
117
+ ],
118
+ })
119
+ })
120
+
121
+ test('purity gate: a folded method call in the body → null', () => {
122
+ // `.toUpperCase()` folds to `array-method`, outside the evaluator surface.
123
+ expect(serializeParsedExpr(parseExpression('item.name.toUpperCase()'))).toBeNull()
124
+ // A higher-order call (`.filter`) likewise.
125
+ expect(serializeParsedExpr(parseExpression('item.tags.filter(t => t)'))).toBeNull()
126
+ // An unsupported shape.
127
+ expect(serializeParsedExpr(parseExpression('a instanceof B'))).toBeNull()
128
+ })
129
+
130
+ test('purity gate: a folded subtree anywhere poisons the whole body', () => {
131
+ expect(serializeParsedExpr(parseExpression('acc + item.name.toUpperCase()'))).toBeNull()
132
+ })
133
+
134
+ test('purity gate: a non-builtin call is refused (evaluator would read it as nil)', () => {
135
+ // Bare function call — not on the evaluator allowlist.
136
+ expect(serializeParsedExpr(parseExpression('foo(item)'))).toBeNull()
137
+ // Method call on a value — generic `call` with a non-Math member callee.
138
+ expect(serializeParsedExpr(parseExpression('item.compute(2)'))).toBeNull()
139
+ // `parseInt` etc. are not the allowlisted `Number`/`String`/`Boolean`.
140
+ expect(serializeParsedExpr(parseExpression('parseInt(item.s)'))).toBeNull()
141
+ // A computed builtin reference the evaluator rejects (`Math['max']`).
142
+ expect(serializeParsedExpr(parseExpression("Math['max'](a, b)"))).toBeNull()
143
+ })
144
+
145
+ test('allowlisted builtins serialize (Math.* / String / Number / Boolean)', () => {
146
+ expect(serializeParsedExpr(parseExpression('Math.floor(item.x)'))).not.toBeNull()
147
+ expect(serializeParsedExpr(parseExpression('String(item.n)'))).not.toBeNull()
148
+ expect(serializeParsedExpr(parseExpression('Number(item.s)'))).not.toBeNull()
149
+ expect(serializeParsedExpr(parseExpression('Boolean(item.s)'))).not.toBeNull()
150
+ })
151
+
152
+ test('`.includes(x)` array-method serializes (the one array-method the evaluator executes)', () => {
153
+ expect(evalJSON("item.tags.includes('go')")).toEqual({
154
+ kind: 'array-method',
155
+ method: 'includes',
156
+ object: { kind: 'member', object: { kind: 'identifier', name: 'item' }, property: 'tags' },
157
+ args: [{ kind: 'literal', value: 'go' }],
158
+ })
159
+ })
160
+
161
+ test('every other array-method still folds outside the evaluator surface', () => {
162
+ expect(serializeParsedExpr(parseExpression('item.tags.join(",")'))).toBeNull()
163
+ expect(serializeParsedExpr(parseExpression('item.tags.slice(0, 1)'))).toBeNull()
164
+ })
165
+
166
+ test('a computed member value carries `computed: true` (plain access omits it)', () => {
167
+ // `row['price']` folds to a computed `member`; the flag is preserved so a
168
+ // computed member stays distinguishable. (`row.price` carries no `computed`.)
169
+ expect(evalJSON("row['price']")).toEqual({
170
+ kind: 'member',
171
+ object: { kind: 'identifier', name: 'row' },
172
+ property: 'price',
173
+ computed: true,
174
+ })
175
+ expect(evalJSON('row.price')).toEqual({
176
+ kind: 'member',
177
+ object: { kind: 'identifier', name: 'row' },
178
+ property: 'price',
179
+ })
180
+ })
181
+ })
182
+
183
+ describe('freeVarsInBody', () => {
184
+ test('collects refs minus the callback params, sorted & deduped', () => {
185
+ const body = parseExpression('acc + item.price + acc')
186
+ expect(freeVarsInBody(body, new Set(['acc', 'item']))).toEqual([])
187
+ expect(freeVarsInBody(body, new Set(['acc']))).toEqual(['item'])
188
+ expect(freeVarsInBody(body, new Set())).toEqual(['acc', 'item'])
189
+ })
190
+
191
+ test('captures an outer free var referenced in the body (base_env source)', () => {
192
+ // `taxRate` is neither param — it must travel as a captured free var.
193
+ const body = parseExpression('acc + item.price * taxRate')
194
+ expect(freeVarsInBody(body, new Set(['acc', 'item']))).toEqual(['taxRate'])
195
+ })
196
+
197
+ test('member property names are not references; object-literal values are', () => {
198
+ // `price` (property) is not a free var; `item` and `factor` are.
199
+ const body = parseExpression('({ total: item.price * factor })')
200
+ expect(freeVarsInBody(body, new Set()).sort()).toEqual(['factor', 'item'])
201
+ })
202
+
203
+ test('template + index + call cover their value positions', () => {
204
+ // `Math` is a builtin callee resolved syntactically by the evaluator — it
205
+ // is NOT captured (it would emit an undefined `$Math` / `.Math` base_env
206
+ // entry). The real refs `k`, `n`, `row` are.
207
+ const body = parseExpression('`${row[k]}-${Math.abs(n)}`')
208
+ expect(freeVarsInBody(body, new Set())).toEqual(['k', 'n', 'row'])
209
+ })
210
+
211
+ test('builtin call callees (Math.<fn> / String / Number / Boolean) are not captured', () => {
212
+ // Each builtin is resolved syntactically by the evaluator, so its
213
+ // identifier must not enter base_env (Copilot review #2031). The
214
+ // arguments, however, ARE real references.
215
+ const body = parseExpression('Math.max(a, factor) + Number(label) + String(x) + Boolean(flag)')
216
+ expect(freeVarsInBody(body, new Set(['a']))).toEqual(['factor', 'flag', 'label', 'x'])
217
+ })
218
+
219
+ test('`.includes(x)` array-method: both the receiver and the needle are free vars', () => {
220
+ const body = parseExpression('!tag || p.tags.includes(tag)')
221
+ expect(freeVarsInBody(body, new Set())).toEqual(['p', 'tag'])
222
+ })
223
+ })
@@ -0,0 +1,212 @@
1
+ // Backend-neutral SSR seed plan (`computeSsrSeedPlan`, attached to
2
+ // `IRMetadata.ssrSeedPlan` by `buildMetadata`). The plan ports the
3
+ // derived/opaque/env-reader scope analysis the template adapters' seed paths
4
+ // perform, so these tests pin the decision rules against metadata built by
5
+ // the real pipeline (`analyzeComponent` + `buildMetadata`).
6
+
7
+ import { describe, test, expect } from 'bun:test'
8
+ import { analyzeComponent } from '../analyzer'
9
+ import { buildMetadata } from '../compiler'
10
+ import type { SsrSeedPlan, SsrSeedStep } from '../ssr-seed-plan'
11
+
12
+ function planFor(source: string, componentName?: string): SsrSeedPlan {
13
+ const ctx = analyzeComponent(source, 'test.tsx', componentName)
14
+ const plan = buildMetadata(ctx).ssrSeedPlan
15
+ expect(plan).toBeDefined()
16
+ return plan!
17
+ }
18
+
19
+ function step(plan: SsrSeedPlan, name: string): SsrSeedStep {
20
+ const found = plan.steps.find(s => s.name === name)
21
+ expect(found).toBeDefined()
22
+ return found!
23
+ }
24
+
25
+ describe('computeSsrSeedPlan', () => {
26
+ test('env signal (aliased) → env-reader step; derived memo over it', () => {
27
+ const plan = planFor(`
28
+ 'use client'
29
+ import { createMemo, createSearchParams } from '@barefootjs/client'
30
+ function List() {
31
+ const [sp] = createSearchParams()
32
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
33
+ return <p>{sort()}</p>
34
+ }
35
+ `)
36
+
37
+ const sp = step(plan, 'sp')
38
+ expect(sp.kind).toBe('env-reader')
39
+ if (sp.kind === 'env-reader') {
40
+ expect(sp.reader.canonicalName).toBe('searchParams')
41
+ expect(sp.reader.key).toBe('search')
42
+ }
43
+
44
+ const sort = step(plan, 'sort')
45
+ expect(sort.kind).toBe('derived')
46
+ if (sort.kind === 'derived') {
47
+ expect(sort.origin).toBe('memo')
48
+ expect(sort.frees).toEqual(['sp'])
49
+ expect(sort.expr).toBe("sp().get('sort') ?? 'date'")
50
+ expect(sort.parsed).toBeDefined()
51
+ }
52
+ })
53
+
54
+ test('chained memos in a props-object component: declaration order, scope accumulates', () => {
55
+ const plan = planFor(`
56
+ 'use client'
57
+ import { createMemo, createSearchParams } from '@barefootjs/client'
58
+ function List(props: { items: { tag: string; name: string }[] }) {
59
+ const [sp] = createSearchParams()
60
+ const tag = createMemo(() => sp().get('tag') ?? '')
61
+ const visible = createMemo(() => props.items.filter((p) => p.tag === tag()))
62
+ return <ul>{visible().map((i) => <li>{i.name}</li>)}</ul>
63
+ }
64
+ `)
65
+
66
+ expect(plan.baseScope).toContain('props')
67
+ expect(plan.steps.map(s => s.name)).toEqual(['sp', 'tag', 'visible'])
68
+
69
+ expect(step(plan, 'tag').kind).toBe('derived')
70
+ const visible = step(plan, 'visible')
71
+ expect(visible.kind).toBe('derived')
72
+ if (visible.kind === 'derived') {
73
+ for (const free of visible.frees) {
74
+ expect(['props', 'tag']).toContain(free)
75
+ }
76
+ }
77
+ })
78
+
79
+ test('forward reference to a later memo → opaque; the later memo itself is derived', () => {
80
+ const plan = planFor(`
81
+ 'use client'
82
+ import { createMemo, createSignal } from '@barefootjs/client'
83
+ function C() {
84
+ const [n, setN] = createSignal(1)
85
+ const early = createMemo(() => late() + 1)
86
+ const late = createMemo(() => n() * 2)
87
+ return <p>{early()}</p>
88
+ }
89
+ `)
90
+
91
+ expect(step(plan, 'early').kind).toBe('opaque')
92
+ expect(step(plan, 'late').kind).toBe('derived')
93
+ })
94
+
95
+ test('self reference → opaque (name enters scope only after its own step)', () => {
96
+ const plan = planFor(`
97
+ 'use client'
98
+ import { createMemo } from '@barefootjs/client'
99
+ function C() {
100
+ const loop = createMemo(() => loop())
101
+ return <p>{loop()}</p>
102
+ }
103
+ `)
104
+
105
+ expect(step(plan, 'loop').kind).toBe('opaque')
106
+ })
107
+
108
+ test('shadowed callback param leaking as an outer free identifier → opaque', () => {
109
+ const plan = planFor(`
110
+ 'use client'
111
+ import { createMemo } from '@barefootjs/client'
112
+ function C(props: { items: { ok: boolean }[] }) {
113
+ const bad = createMemo(() => props.items.filter((p) => p.ok) && p)
114
+ return <p>{bad()}</p>
115
+ }
116
+ `)
117
+
118
+ expect(step(plan, 'bad').kind).toBe('opaque')
119
+ })
120
+
121
+ test('module string const counts as base scope; memo referencing it is derived', () => {
122
+ const plan = planFor(`
123
+ 'use client'
124
+ import { createMemo, createSignal } from '@barefootjs/client'
125
+ const activeCls = 'text-bold'
126
+ function C() {
127
+ const [on, setOn] = createSignal(false)
128
+ const cls = createMemo(() => on() ? activeCls : 'text-dim')
129
+ return <p class={cls()}>x</p>
130
+ }
131
+ `, 'C')
132
+
133
+ expect(plan.baseScope).toContain('activeCls')
134
+ const cls = step(plan, 'cls')
135
+ expect(cls.kind).toBe('derived')
136
+ if (cls.kind === 'derived') {
137
+ expect(cls.frees).toContain('on')
138
+ expect(cls.frees).toContain('activeCls')
139
+ }
140
+ })
141
+
142
+ test('block-bodied memo → opaque (v1 gates to expression-bodied memos)', () => {
143
+ const plan = planFor(`
144
+ 'use client'
145
+ import { createMemo, createSignal } from '@barefootjs/client'
146
+ function C() {
147
+ const [on, setOn] = createSignal(false)
148
+ const label = createMemo(() => {
149
+ const v = on()
150
+ return v ? 'yes' : 'no'
151
+ })
152
+ return <p>{label()}</p>
153
+ }
154
+ `)
155
+
156
+ const label = step(plan, 'label')
157
+ expect(label.kind).toBe('opaque')
158
+ if (label.kind === 'opaque') expect(label.origin).toBe('memo')
159
+ })
160
+
161
+ test('unsupported body (object literal) → opaque', () => {
162
+ const plan = planFor(`
163
+ 'use client'
164
+ import { createMemo, createSignal } from '@barefootjs/client'
165
+ function C() {
166
+ const [n, setN] = createSignal(1)
167
+ const obj = createMemo(() => ({ value: n() }))
168
+ return <p>{obj().value}</p>
169
+ }
170
+ `)
171
+
172
+ expect(step(plan, 'obj').kind).toBe('opaque')
173
+ })
174
+
175
+ test('literal signal init → derived with empty frees (constant-skip is emit-side)', () => {
176
+ const plan = planFor(`
177
+ 'use client'
178
+ import { createSignal } from '@barefootjs/client'
179
+ function C() {
180
+ const [v, setV] = createSignal('b')
181
+ return <p>{v()}</p>
182
+ }
183
+ `)
184
+
185
+ const v = step(plan, 'v')
186
+ expect(v.kind).toBe('derived')
187
+ if (v.kind === 'derived') {
188
+ expect(v.origin).toBe('signal')
189
+ expect(v.frees).toEqual([])
190
+ expect(v.expr).toBe("'b'")
191
+ }
192
+ })
193
+
194
+ test('prop-derived signal init → derived with the prop free', () => {
195
+ const plan = planFor(`
196
+ 'use client'
197
+ import { createSignal } from '@barefootjs/client'
198
+ function Toggle(props: { defaultOn?: boolean }) {
199
+ const [on, setOn] = createSignal(props.defaultOn ?? false)
200
+ return <button aria-pressed={on()}>t</button>
201
+ }
202
+ `)
203
+
204
+ expect(plan.baseScope).toContain('props')
205
+ const on = step(plan, 'on')
206
+ expect(on.kind).toBe('derived')
207
+ if (on.kind === 'derived') {
208
+ expect(on.origin).toBe('signal')
209
+ expect(on.frees).toEqual(['props'])
210
+ }
211
+ })
212
+ })
@@ -216,10 +216,11 @@ describe('Unsupported Sort Comparator (BF021)', () => {
216
216
  expect(bf021[0].message).toContain('not a supported shape')
217
217
  })
218
218
 
219
- test('emits BF021 error for multi-statement block-body sort comparator', () => {
220
- // Single-`return` block bodies now lower (#1448 Tier B follow-up),
221
- // but multi-statement / local-var bodies stay refused generalising
222
- // over arbitrary statement sequences isn't tractable in a template.
219
+ test('no BF021 for let-inline block-body sort comparator (#2040)', () => {
220
+ // #2040: a value-producing block body (pure `const` bindings + a terminal
221
+ // `return`) normalises to a single expression via let-inline, so a
222
+ // `{ const x = a.price; return x - b.price }` comparator now lowers exactly
223
+ // like the expression-bodied `(a, b) => a.price - b.price`.
223
224
  const source = `
224
225
  'use client'
225
226
  import { createSignal } from '@barefootjs/client'
@@ -239,6 +240,32 @@ describe('Unsupported Sort Comparator (BF021)', () => {
239
240
  const { errors } = compileToIR(source)
240
241
  const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
241
242
 
243
+ expect(bf021).toHaveLength(0)
244
+ })
245
+
246
+ test('emits BF021 error for imperative block-body sort comparator (#2040)', () => {
247
+ // An imperative comparator (local re-assignment / mutation) has no
248
+ // value-position lowering — `foldBlockToExpr` refuses it, so the arrow stays
249
+ // `unsupported` and the sort extraction surfaces BF021.
250
+ const source = `
251
+ 'use client'
252
+ import { createSignal } from '@barefootjs/client'
253
+
254
+ export function TodoList() {
255
+ const [items, setItems] = createSignal<any[]>([])
256
+ return (
257
+ <ul>
258
+ {items().sort((a, b) => { let r = 0; r = a.price - b.price; return r }).map(t => (
259
+ <li>{t.name}</li>
260
+ ))}
261
+ </ul>
262
+ )
263
+ }
264
+ `
265
+
266
+ const { errors } = compileToIR(source)
267
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
268
+
242
269
  expect(bf021).toHaveLength(1)
243
270
  expect(bf021[0].message).toContain('not a supported shape')
244
271
  })
@@ -583,3 +610,70 @@ describe('Rest Pattern in Filter Predicate (BF021, #1532)', () => {
583
610
  expect(bf021).toHaveLength(0)
584
611
  })
585
612
  })
613
+
614
+ // Block-bodied filter predicates are normalized to a single boolean expression
615
+ // (#2040). A value-producing block lowers like an expression predicate; an
616
+ // imperative block refuses.
617
+ describe('Block-body filter predicate normalization (#2040)', () => {
618
+ function loopFilterIR(predicate: string) {
619
+ const source = `
620
+ 'use client'
621
+ import { createSignal } from '@barefootjs/client'
622
+
623
+ export function TodoList() {
624
+ const [items, setItems] = createSignal<any[]>([])
625
+ const [filter, setFilter] = createSignal('all')
626
+ return (
627
+ <ul>
628
+ {items().filter(${predicate}).map(t => (
629
+ <li>{t.name}</li>
630
+ ))}
631
+ </ul>
632
+ )
633
+ }
634
+ `
635
+ const { ir, errors } = compileToIR(source)
636
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
637
+ // Find the loop node carrying the filterPredicate.
638
+ let found: any = null
639
+ const walk = (n: any) => {
640
+ if (!n || found) return
641
+ if (n.filterPredicate) found = n
642
+ for (const c of n.children ?? []) walk(c)
643
+ }
644
+ walk(ir)
645
+ return { bf021, filterPredicate: found?.filterPredicate }
646
+ }
647
+
648
+ test('value-producing block (let-inline + early return) folds to a predicate', () => {
649
+ const { bf021, filterPredicate } = loopFilterIR(`t => {
650
+ const f = filter()
651
+ if (f === 'active') return !t.done
652
+ if (f === 'completed') return t.done
653
+ return true
654
+ }`)
655
+ expect(bf021).toHaveLength(0)
656
+ // No leftover block shape — a single boolean predicate expression.
657
+ expect(filterPredicate?.predicate).toBeDefined()
658
+ expect((filterPredicate as any)?.blockBody).toBeUndefined()
659
+ })
660
+
661
+ test('signal read on multiple branches still folds (idempotent getter is pure)', () => {
662
+ const { bf021, filterPredicate } = loopFilterIR(`t => {
663
+ const f = filter()
664
+ if (f === 'active') return !t.done
665
+ return f === 'completed' ? t.done : true
666
+ }`)
667
+ expect(bf021).toHaveLength(0)
668
+ expect(filterPredicate?.predicate).toBeDefined()
669
+ })
670
+
671
+ test('imperative block (local re-assignment) refuses with BF021', () => {
672
+ const { bf021 } = loopFilterIR(`t => {
673
+ let keep = false
674
+ keep = !t.done
675
+ return keep
676
+ }`)
677
+ expect(bf021.length).toBeGreaterThan(0)
678
+ })
679
+ })