@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.
- package/dist/adapters/env-signal.d.ts +73 -15
- package/dist/adapters/env-signal.d.ts.map +1 -1
- package/dist/adapters/jsx-adapter.d.ts.map +1 -1
- package/dist/adapters/parsed-expr-emitter.d.ts +7 -6
- package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
- package/dist/analyzer-context.d.ts +29 -1
- package/dist/analyzer-context.d.ts.map +1 -1
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/builtin-lowering-plugins.d.ts +34 -0
- package/dist/builtin-lowering-plugins.d.ts.map +1 -0
- package/dist/compiler.d.ts.map +1 -1
- package/dist/expression-parser.d.ts +264 -163
- package/dist/expression-parser.d.ts.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7839 -7019
- package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
- package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
- package/dist/jsx-to-ir.d.ts.map +1 -1
- package/dist/lowering-registry.d.ts +122 -0
- package/dist/lowering-registry.d.ts.map +1 -0
- package/dist/query-href-lowering.d.ts +63 -0
- package/dist/query-href-lowering.d.ts.map +1 -0
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/dist/ssr-seed-plan.d.ts +84 -0
- package/dist/ssr-seed-plan.d.ts.map +1 -0
- package/dist/types.d.ts +180 -11
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
- package/src/__tests__/analyzer.test.ts +53 -0
- package/src/__tests__/expression-parser.test.ts +714 -392
- package/src/__tests__/free-identifiers.test.ts +55 -0
- package/src/__tests__/ir-reduce-op.test.ts +18 -21
- package/src/__tests__/ir-sort-comparator.test.ts +19 -20
- package/src/__tests__/lowering-registry.test.ts +141 -0
- package/src/__tests__/materialize-getter-calls.test.ts +58 -0
- package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
- package/src/__tests__/query-href-recognition.test.ts +58 -0
- package/src/__tests__/serialize-parsed-expr.test.ts +223 -0
- package/src/__tests__/ssr-seed-plan.test.ts +212 -0
- package/src/__tests__/unsupported-expression.test.ts +98 -4
- package/src/adapters/env-signal.ts +108 -21
- package/src/adapters/jsx-adapter.ts +17 -0
- package/src/adapters/parsed-expr-emitter.ts +39 -41
- package/src/analyzer-context.ts +72 -27
- package/src/analyzer.ts +226 -9
- package/src/builtin-lowering-plugins.ts +54 -0
- package/src/compiler.ts +6 -1
- package/src/expression-parser.ts +1375 -929
- package/src/index.ts +31 -3
- package/src/ir-to-client-js/csr-substitute.ts +5 -0
- package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
- package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
- package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
- package/src/jsx-to-ir.ts +182 -43
- package/src/lowering-registry.ts +160 -0
- package/src/query-href-lowering.ts +147 -0
- package/src/ssr-defaults.ts +5 -1
- package/src/ssr-seed-plan.ts +146 -0
- package/src/types.ts +182 -12
- package/src/__tests__/flatmap-support.test.ts +0 -218
- 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('
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
+
})
|