@barefootjs/jsx 0.15.2 → 0.17.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 (61) hide show
  1. package/dist/adapters/env-signal.d.ts +38 -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/expression-parser.d.ts +219 -163
  12. package/dist/expression-parser.d.ts.map +1 -1
  13. package/dist/index.d.ts +9 -6
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6892 -6118
  16. package/dist/ir-to-client-js/csr-substitute.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/plan/build-declaration-emit.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/plan/declaration-emit.d.ts +9 -0
  19. package/dist/ir-to-client-js/plan/declaration-emit.d.ts.map +1 -1
  20. package/dist/jsx-to-ir.d.ts.map +1 -1
  21. package/dist/lowering-registry.d.ts +122 -0
  22. package/dist/lowering-registry.d.ts.map +1 -0
  23. package/dist/profiler.d.ts +115 -0
  24. package/dist/profiler.d.ts.map +1 -1
  25. package/dist/query-href-lowering.d.ts +63 -0
  26. package/dist/query-href-lowering.d.ts.map +1 -0
  27. package/dist/ssr-defaults.d.ts.map +1 -1
  28. package/dist/types.d.ts +169 -11
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +2 -2
  31. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  32. package/src/__tests__/analyzer.test.ts +53 -0
  33. package/src/__tests__/expression-parser.test.ts +703 -391
  34. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  35. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  36. package/src/__tests__/lowering-registry.test.ts +141 -0
  37. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  38. package/src/__tests__/profiler.test.ts +149 -0
  39. package/src/__tests__/query-href-recognition.test.ts +58 -0
  40. package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
  41. package/src/__tests__/unsupported-expression.test.ts +98 -4
  42. package/src/adapters/env-signal.ts +60 -21
  43. package/src/adapters/jsx-adapter.ts +17 -0
  44. package/src/adapters/parsed-expr-emitter.ts +39 -41
  45. package/src/analyzer-context.ts +72 -27
  46. package/src/analyzer.ts +226 -9
  47. package/src/builtin-lowering-plugins.ts +54 -0
  48. package/src/expression-parser.ts +1183 -927
  49. package/src/index.ts +35 -3
  50. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  51. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  52. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  53. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  54. package/src/jsx-to-ir.ts +182 -43
  55. package/src/lowering-registry.ts +160 -0
  56. package/src/profiler.ts +328 -0
  57. package/src/query-href-lowering.ts +147 -0
  58. package/src/ssr-defaults.ts +5 -1
  59. package/src/types.ts +171 -12
  60. package/src/__tests__/flatmap-support.test.ts +0 -218
  61. package/src/__tests__/reduce-op.test.ts +0 -201
@@ -0,0 +1,204 @@
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('a computed member value carries `computed: true` (plain access omits it)', () => {
153
+ // `row['price']` folds to a computed `member`; the flag is preserved so a
154
+ // computed member stays distinguishable. (`row.price` carries no `computed`.)
155
+ expect(evalJSON("row['price']")).toEqual({
156
+ kind: 'member',
157
+ object: { kind: 'identifier', name: 'row' },
158
+ property: 'price',
159
+ computed: true,
160
+ })
161
+ expect(evalJSON('row.price')).toEqual({
162
+ kind: 'member',
163
+ object: { kind: 'identifier', name: 'row' },
164
+ property: 'price',
165
+ })
166
+ })
167
+ })
168
+
169
+ describe('freeVarsInBody', () => {
170
+ test('collects refs minus the callback params, sorted & deduped', () => {
171
+ const body = parseExpression('acc + item.price + acc')
172
+ expect(freeVarsInBody(body, new Set(['acc', 'item']))).toEqual([])
173
+ expect(freeVarsInBody(body, new Set(['acc']))).toEqual(['item'])
174
+ expect(freeVarsInBody(body, new Set())).toEqual(['acc', 'item'])
175
+ })
176
+
177
+ test('captures an outer free var referenced in the body (base_env source)', () => {
178
+ // `taxRate` is neither param — it must travel as a captured free var.
179
+ const body = parseExpression('acc + item.price * taxRate')
180
+ expect(freeVarsInBody(body, new Set(['acc', 'item']))).toEqual(['taxRate'])
181
+ })
182
+
183
+ test('member property names are not references; object-literal values are', () => {
184
+ // `price` (property) is not a free var; `item` and `factor` are.
185
+ const body = parseExpression('({ total: item.price * factor })')
186
+ expect(freeVarsInBody(body, new Set()).sort()).toEqual(['factor', 'item'])
187
+ })
188
+
189
+ test('template + index + call cover their value positions', () => {
190
+ // `Math` is a builtin callee resolved syntactically by the evaluator — it
191
+ // is NOT captured (it would emit an undefined `$Math` / `.Math` base_env
192
+ // entry). The real refs `k`, `n`, `row` are.
193
+ const body = parseExpression('`${row[k]}-${Math.abs(n)}`')
194
+ expect(freeVarsInBody(body, new Set())).toEqual(['k', 'n', 'row'])
195
+ })
196
+
197
+ test('builtin call callees (Math.<fn> / String / Number / Boolean) are not captured', () => {
198
+ // Each builtin is resolved syntactically by the evaluator, so its
199
+ // identifier must not enter base_env (Copilot review #2031). The
200
+ // arguments, however, ARE real references.
201
+ const body = parseExpression('Math.max(a, factor) + Number(label) + String(x) + Boolean(flag)')
202
+ expect(freeVarsInBody(body, new Set(['a']))).toEqual(['factor', 'flag', 'label', 'x'])
203
+ })
204
+ })
@@ -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
+ })
@@ -10,41 +10,80 @@ import type { ParsedExpr } from '../expression-parser.ts'
10
10
  import type { IRMetadata } from '../types.ts'
11
11
 
12
12
  /**
13
- * The local binding name(s) that `searchParams` from `@barefootjs/client` is
14
- * imported under in this component the same import the analyzer allow-lists
15
- * (`CLIENT_EXPORTS`). Usually the single name `searchParams`, but an aliased
16
- * import (`import { searchParams as sp }`) binds it to `sp`, and the template
17
- * expression then reads `sp()` so adapters must gate + match against the
18
- * LOCAL name(s), not the literal `searchParams`. Empty when the env signal is
19
- * not imported (the component keeps the generic signal lowering).
13
+ * Env-signal key the runtime factory that produces it (`'search'`
14
+ * `createSearchParams`). Single source of truth for the reverse of the
15
+ * analyzer's `ENV_SIGNAL_FACTORIES` (#2057): an env signal is `createSignal`-
16
+ * shaped for analysis, but every backend that re-emits its declaration (client
17
+ * JS, JSX/Hono SSR) must call this factory, not `createSignal`, so the value is
18
+ * the request-scoped reader rather than stored state.
19
+ */
20
+ export const ENV_SIGNAL_CLIENT_FACTORY: Record<string, string> = {
21
+ search: 'createSearchParams',
22
+ }
23
+
24
+ /**
25
+ * The getter name(s) of the `searchParams` env signal in this component.
26
+ *
27
+ * Recognised **structurally** (#2057): the env signal is now declared as a
28
+ * `createSignal`-shaped `const [searchParams, setSearchParams] =
29
+ * createSearchParams()`, so the analyzer collects it into `metadata.signals`
30
+ * with `envReader: 'search'` — exactly like any other signal, but tagged. This
31
+ * function returns those getters (whatever the destructured name is —
32
+ * `searchParams`, or an alias), so adapters match the reader `.get()` call
33
+ * against the binding actually used, with **no `searchParams`-name allow-list**
34
+ * (this supersedes the import-name matching, and the closed #2055).
20
35
  *
21
- * `ImportSpecifier.name` is the exported name and `alias` the local rebinding
22
- * (see `collectImport` in analyzer.ts), so the import is detected by `name ===
23
- * 'searchParams'` and the local binding is `alias ?? name`. Namespace / default
24
- * specifiers bind a different identifier and are excluded.
36
+ * Empty when the component declares no env signal (the component keeps the
37
+ * generic signal lowering).
25
38
  */
26
39
  export function searchParamsLocalNames(metadata: IRMetadata): Set<string> {
27
40
  const names = new Set<string>()
28
- for (const imp of metadata.imports) {
29
- if (imp.source !== '@barefootjs/client' || imp.isTypeOnly) continue
30
- for (const s of imp.specifiers) {
31
- if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
32
- if (s.name === 'searchParams') names.add(s.alias ?? s.name)
33
- }
41
+ for (const s of metadata.signals) {
42
+ if (s.envReader === 'search') names.add(s.getter)
34
43
  }
35
44
  return names
36
45
  }
37
46
 
38
47
  /**
39
- * True when the component imports the `searchParams` env signal under any local
40
- * name. Convenience for adapters/harnesses that only need to gate on presence
41
- * (the lowering itself needs the {@link searchParamsLocalNames} set to match the
42
- * actual binding in the expression).
48
+ * True when the component declares the `searchParams` env signal. Convenience
49
+ * for adapters/harnesses that only need to gate on presence (the lowering
50
+ * itself needs the {@link searchParamsLocalNames} set to match the actual
51
+ * binding in the expression).
43
52
  */
44
53
  export function importsSearchParams(metadata: IRMetadata): boolean {
45
54
  return searchParamsLocalNames(metadata).size > 0
46
55
  }
47
56
 
57
+ /**
58
+ * The local binding name(s) that `queryHref` is imported under in this component
59
+ * (#2042) — the pure URL-query builder an adapter lowers to its query helper
60
+ * (`bf_query` in go-template). Matched by the exported name `queryHref` and bound
61
+ * to `alias ?? name`, so an aliased import (`import { queryHref as qh }`) is gated
62
+ * against the LOCAL name. Empty when not imported.
63
+ *
64
+ * Both the main `@barefootjs/client` entry and the `@barefootjs/client/runtime`
65
+ * re-export are accepted: `queryHref` is exported from both, so importing it from
66
+ * either must enable SSR lowering — otherwise the call's object-literal arg would
67
+ * hit the support gate (BF101) on the runtime-entry import path.
68
+ */
69
+ export function queryHrefLocalNames(metadata: IRMetadata): Set<string> {
70
+ const names = new Set<string>()
71
+ for (const imp of metadata.imports) {
72
+ if (!QUERY_HREF_SOURCES.has(imp.source) || imp.isTypeOnly) continue
73
+ for (const s of imp.specifiers) {
74
+ if (s.isTypeOnly || s.isNamespace || s.isDefault) continue
75
+ if (s.name === 'queryHref') names.add(s.alias ?? s.name)
76
+ }
77
+ }
78
+ return names
79
+ }
80
+
81
+ /** Entry points that re-export `queryHref` (main + the runtime re-export). */
82
+ const QUERY_HREF_SOURCES: ReadonlySet<string> = new Set([
83
+ '@barefootjs/client',
84
+ '@barefootjs/client/runtime',
85
+ ])
86
+
48
87
  /**
49
88
  * Recognise a `<binding>().<method>(<args>)` env-signal method call from a
50
89
  * `call` node's callee + args, where `<binding>` is one of the local names
@@ -12,6 +12,7 @@ import type {
12
12
  } from '../types.ts'
13
13
  import { BF_SCOPE, BF_SLOT, BF_COND } from '@barefootjs/shared'
14
14
  import { BaseAdapter } from './interface.ts'
15
+ import { ENV_SIGNAL_CLIENT_FACTORY } from './env-signal.ts'
15
16
  import { formatParamWithType, findReachableNames } from '../module-exports.ts'
16
17
 
17
18
  export interface JsxAdapterConfig {
@@ -110,6 +111,22 @@ export abstract class JsxAdapter extends BaseAdapter {
110
111
 
111
112
  for (const signal of ir.metadata.signals) {
112
113
  if (signal.isModule) continue
114
+ if (signal.envReader) {
115
+ // Env signal (#2057): call the real runtime factory so SSR resolves the
116
+ // request query through the installed server env reader, not a static
117
+ // initial value. Emit the factory as written (alias / namespace aware),
118
+ // matching the import re-emitted into the SSR module; fall back to the
119
+ // canonical name if the callee text wasn't captured.
120
+ const factory = signal.envFactory ?? ENV_SIGNAL_CLIENT_FACTORY[signal.envReader]
121
+ if (factory) {
122
+ lines.push(
123
+ signal.setter
124
+ ? ` const [${signal.getter}, ${signal.setter}] = ${factory}()`
125
+ : ` const [${signal.getter}] = ${factory}()`,
126
+ )
127
+ }
128
+ continue
129
+ }
113
130
  // Create a getter that returns the initial value for SSR
114
131
  const rawInitialValue = preserveTypes
115
132
  ? (signal.typedInitialValue ?? signal.initialValue)
@@ -33,7 +33,8 @@
33
33
  * be added in one place.
34
34
  */
35
35
 
36
- import type { ParsedExpr, SortComparator, ReduceOp, FlatDepth, FlatMapOp, TemplatePart } from '../expression-parser.ts'
36
+ import type { ParsedExpr, FlatDepth, TemplatePart, ObjectLiteralProperty } from '../expression-parser.ts'
37
+ import { asCallbackMethodCall } from '../expression-parser.ts'
37
38
 
38
39
  export type HigherOrderMethod = 'filter' | 'every' | 'some' | 'find' | 'findIndex' | 'findLast' | 'findLastIndex'
39
40
 
@@ -139,49 +140,48 @@ export interface ParsedExprEmitter {
139
140
  emit: (e: ParsedExpr) => string,
140
141
  ): string
141
142
  templateLiteral(parts: TemplatePart[], emit: (e: ParsedExpr) => string): string
142
- arrowFn(param: string, body: ParsedExpr, emit: (e: ParsedExpr) => string): string
143
- higherOrder(
144
- method: HigherOrderMethod,
143
+ // A higher-order callback method call (`<object>.<method>(<arrow>, …rest)`,
144
+ // method ∈ `CALLBACK_METHODS`): `.filter`/`.find`/`.every`/`.some`/`.sort`/
145
+ // `.reduce`/`.flatMap`/… (#2018 P5). The adapter serializes the `arrow` body
146
+ // to the runtime evaluator (eval-first) and falls back to a structured
147
+ // lowering when the body is outside the evaluator surface. `restArgs` carries
148
+ // any trailing arguments (e.g. the `.reduce` init).
149
+ callbackMethod(
150
+ method: string,
145
151
  object: ParsedExpr,
146
- param: string,
147
- predicate: ParsedExpr,
152
+ arrow: Extract<ParsedExpr, { kind: 'arrow' }>,
153
+ restArgs: ParsedExpr[],
148
154
  emit: (e: ParsedExpr) => string,
149
155
  ): string
156
+ // A standalone arrow / regex literal. These normally reach an adapter only as
157
+ // a callback argument (handled by `callbackMethod`); emitted standalone they
158
+ // have no template form, so adapters route them to their `unsupported` path.
159
+ arrow(params: string[], body: ParsedExpr, emit: (e: ParsedExpr) => string): string
160
+ regex(raw: string): string
150
161
  arrayLiteral(elements: ParsedExpr[], emit: (e: ParsedExpr) => string): string
162
+ // Emit an object literal `{ a: 1, b: x }`. `raw` is the original
163
+ // expression string so an adapter that doesn't lower object values yet
164
+ // can delegate to `unsupported(raw, …)` and stay byte-identical.
165
+ objectLiteral(
166
+ properties: ObjectLiteralProperty[],
167
+ raw: string,
168
+ emit: (e: ParsedExpr) => string,
169
+ ): string
151
170
  arrayMethod(
152
171
  method: ArrayMethod,
153
172
  object: ParsedExpr,
154
173
  args: ParsedExpr[],
155
174
  emit: (e: ParsedExpr) => string,
156
175
  ): string
157
- sortMethod(
158
- method: SortMethod,
159
- object: ParsedExpr,
160
- comparator: SortComparator,
161
- emit: (e: ParsedExpr) => string,
162
- ): string
163
- reduceMethod(
164
- method: ReduceMethod,
165
- object: ParsedExpr,
166
- reduceOp: ReduceOp,
167
- emit: (e: ParsedExpr) => string,
168
- ): string
169
176
  // `.flat(depth?)` gets its own dispatcher arm (#1448 Tier C): it carries
170
177
  // a structured `FlatDepth` (the validated literal / `'infinity'`) rather
171
- // than a `ParsedExpr[]` args list, same rationale as sort / reduce.
178
+ // than a `ParsedExpr[]` args list. Non-callback, so it is NOT routed
179
+ // through `callbackMethod`.
172
180
  flatMethod(
173
181
  object: ParsedExpr,
174
182
  depth: FlatDepth,
175
183
  emit: (e: ParsedExpr) => string,
176
184
  ): string
177
- // `.flatMap(fn)` value-returning field projection gets its own arm
178
- // (#1448 Tier C): it carries a structured `FlatMapOp` rather than a
179
- // `ParsedExpr[]` args list, same rationale as sort / reduce / flat.
180
- flatMapMethod(
181
- object: ParsedExpr,
182
- op: FlatMapOp,
183
- emit: (e: ParsedExpr) => string,
184
- ): string
185
185
  unsupported(raw: string, reason: string): string
186
186
  }
187
187
 
@@ -201,8 +201,13 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
201
201
  return emitter.identifier(expr.name)
202
202
  case 'literal':
203
203
  return emitter.literal(expr.value, expr.literalType)
204
- case 'call':
204
+ case 'call': {
205
+ // A higher-order callback call (`arr.filter(p)` / `arr.sort(cmp)` / …)
206
+ // routes to the dedicated `callbackMethod` arm; any other call is generic.
207
+ const cb = asCallbackMethodCall(expr)
208
+ if (cb) return emitter.callbackMethod(cb.method, cb.object, cb.arrow, cb.args, emit)
205
209
  return emitter.call(expr.callee, expr.args, emit)
210
+ }
206
211
  case 'member':
207
212
  return emitter.member(expr.object, expr.property, expr.computed, emit)
208
213
  case 'index-access':
@@ -217,25 +222,18 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
217
222
  return emitter.conditional(expr.test, expr.consequent, expr.alternate, emit)
218
223
  case 'template-literal':
219
224
  return emitter.templateLiteral(expr.parts, emit)
220
- case 'arrow-fn':
221
- return emitter.arrowFn(expr.param, expr.body, emit)
222
- case 'higher-order':
223
- return emitter.higherOrder(expr.method, expr.object, expr.param, expr.predicate, emit)
225
+ case 'arrow':
226
+ return emitter.arrow(expr.params, expr.body, emit)
227
+ case 'regex':
228
+ return emitter.regex(expr.raw)
224
229
  case 'array-literal':
225
230
  return emitter.arrayLiteral(expr.elements, emit)
231
+ case 'object-literal':
232
+ return emitter.objectLiteral(expr.properties, expr.raw, emit)
226
233
  case 'array-method':
227
- if (expr.method === 'sort' || expr.method === 'toSorted') {
228
- return emitter.sortMethod(expr.method, expr.object, expr.comparator, emit)
229
- }
230
- if (expr.method === 'reduce' || expr.method === 'reduceRight') {
231
- return emitter.reduceMethod(expr.method, expr.object, expr.reduceOp, emit)
232
- }
233
234
  if (expr.method === 'flat') {
234
235
  return emitter.flatMethod(expr.object, expr.flatDepth, emit)
235
236
  }
236
- if (expr.method === 'flatMap') {
237
- return emitter.flatMapMethod(expr.object, expr.flatMapOp, emit)
238
- }
239
237
  return emitter.arrayMethod(expr.method, expr.object, expr.args, emit)
240
238
  case 'unsupported':
241
239
  return emitter.unsupported(expr.raw, expr.reason)