@barefootjs/jsx 0.16.0 → 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 (57) 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 +7 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6754 -6129
  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/query-href-lowering.d.ts +63 -0
  24. package/dist/query-href-lowering.d.ts.map +1 -0
  25. package/dist/ssr-defaults.d.ts.map +1 -1
  26. package/dist/types.d.ts +169 -11
  27. package/dist/types.d.ts.map +1 -1
  28. package/package.json +2 -2
  29. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +68 -3
  30. package/src/__tests__/analyzer.test.ts +53 -0
  31. package/src/__tests__/expression-parser.test.ts +703 -391
  32. package/src/__tests__/ir-reduce-op.test.ts +18 -21
  33. package/src/__tests__/ir-sort-comparator.test.ts +19 -20
  34. package/src/__tests__/lowering-registry.test.ts +141 -0
  35. package/src/__tests__/primitive-resolver-alias.test.ts +23 -0
  36. package/src/__tests__/query-href-recognition.test.ts +58 -0
  37. package/src/__tests__/serialize-parsed-expr.test.ts +204 -0
  38. package/src/__tests__/unsupported-expression.test.ts +98 -4
  39. package/src/adapters/env-signal.ts +60 -21
  40. package/src/adapters/jsx-adapter.ts +17 -0
  41. package/src/adapters/parsed-expr-emitter.ts +39 -41
  42. package/src/analyzer-context.ts +72 -27
  43. package/src/analyzer.ts +226 -9
  44. package/src/builtin-lowering-plugins.ts +54 -0
  45. package/src/expression-parser.ts +1183 -927
  46. package/src/index.ts +26 -3
  47. package/src/ir-to-client-js/csr-substitute.ts +5 -0
  48. package/src/ir-to-client-js/plan/build-declaration-emit.ts +16 -0
  49. package/src/ir-to-client-js/plan/declaration-emit.ts +9 -0
  50. package/src/ir-to-client-js/stringify/declaration-emit.ts +11 -0
  51. package/src/jsx-to-ir.ts +182 -43
  52. package/src/lowering-registry.ts +160 -0
  53. package/src/query-href-lowering.ts +147 -0
  54. package/src/ssr-defaults.ts +5 -1
  55. package/src/types.ts +171 -12
  56. package/src/__tests__/flatmap-support.test.ts +0 -218
  57. package/src/__tests__/reduce-op.test.ts +0 -201
@@ -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)
@@ -344,11 +344,21 @@ function propertyNameText(
344
344
 
345
345
  export function typeNodeToTypeInfo(
346
346
  typeNode: ts.TypeNode | undefined,
347
- sourceFile: ts.SourceFile
347
+ sourceFile: ts.SourceFile,
348
+ // When set, `raw` is derived from this extractor instead of
349
+ // `node.getText(sourceFile)`. Synthetic nodes (from `checker.typeToTypeNode`,
350
+ // used by `tsTypeToTypeInfo`) have no source positions, so `getText()` would
351
+ // throw — callers pass a printer-based extractor. In that mode the
352
+ // object-literal / function branches that need member/param source text emit
353
+ // a `raw`-only shape (those consumers only run on real source).
354
+ rawOf?: (node: ts.TypeNode) => string
348
355
  ): TypeInfo | null {
349
356
  if (!typeNode) return null
350
357
 
351
- const raw = typeNode.getText(sourceFile)
358
+ const synthetic = rawOf !== undefined
359
+ const raw = rawOf ? rawOf(typeNode) : typeNode.getText(sourceFile)
360
+ const recurse = (n: ts.TypeNode): TypeInfo =>
361
+ typeNodeToTypeInfo(n, sourceFile, rawOf) ?? { kind: 'unknown', raw: 'unknown' }
352
362
 
353
363
  // Primitive types (check by SyntaxKind)
354
364
  switch (typeNode.kind) {
@@ -366,26 +376,12 @@ export function typeNodeToTypeInfo(
366
376
 
367
377
  // Array types
368
378
  if (ts.isArrayTypeNode(typeNode)) {
369
- return {
370
- kind: 'array',
371
- raw,
372
- elementType: typeNodeToTypeInfo(typeNode.elementType, sourceFile) ?? {
373
- kind: 'unknown',
374
- raw: 'unknown',
375
- },
376
- }
379
+ return { kind: 'array', raw, elementType: recurse(typeNode.elementType) }
377
380
  }
378
381
 
379
382
  // Union types
380
383
  if (ts.isUnionTypeNode(typeNode)) {
381
- return {
382
- kind: 'union',
383
- raw,
384
- unionTypes: typeNode.types.map(
385
- (t) =>
386
- typeNodeToTypeInfo(t, sourceFile) ?? { kind: 'unknown', raw: 'unknown' }
387
- ),
388
- }
384
+ return { kind: 'union', raw, unionTypes: typeNode.types.map(recurse) }
389
385
  }
390
386
 
391
387
  // Type literal (object type)
@@ -393,7 +389,9 @@ export function typeNodeToTypeInfo(
393
389
  return {
394
390
  kind: 'object',
395
391
  raw,
396
- properties: membersToProperties(typeNode.members, sourceFile),
392
+ // Synthetic members have no source positions for membersToProperties'
393
+ // getText(); emit a property-less object in that mode.
394
+ ...(synthetic ? {} : { properties: membersToProperties(typeNode.members, sourceFile) }),
397
395
  }
398
396
  }
399
397
 
@@ -407,14 +405,7 @@ export function typeNodeToTypeInfo(
407
405
  (refName === 'Array' || refName === 'ReadonlyArray') &&
408
406
  typeNode.typeArguments?.length === 1
409
407
  ) {
410
- return {
411
- kind: 'array',
412
- raw,
413
- elementType: typeNodeToTypeInfo(typeNode.typeArguments[0], sourceFile) ?? {
414
- kind: 'unknown',
415
- raw: 'unknown',
416
- },
417
- }
408
+ return { kind: 'array', raw, elementType: recurse(typeNode.typeArguments[0]) }
418
409
  }
419
410
  return {
420
411
  kind: 'interface',
@@ -424,6 +415,8 @@ export function typeNodeToTypeInfo(
424
415
 
425
416
  // Function type
426
417
  if (ts.isFunctionTypeNode(typeNode)) {
418
+ // Param names/defaults come from getText — skip them for synthetic nodes.
419
+ if (synthetic) return { kind: 'function', raw }
427
420
  return {
428
421
  kind: 'function',
429
422
  raw,
@@ -443,6 +436,40 @@ export function typeNodeToTypeInfo(
443
436
  return { kind: 'unknown', raw }
444
437
  }
445
438
 
439
+ // Printer + blank source file reused across calls so checker-driven type
440
+ // conversion never allocates a fresh `ts.createSourceFile` per memo on the
441
+ // build hot path (the blank file is only printer context for synthetic
442
+ // nodes, created once at module load).
443
+ const _typePrinter = ts.createPrinter({ removeComments: true, omitTrailingSemicolon: true })
444
+ const _blankTypeSourceFile = ts.createSourceFile('__bf_types__.ts', '', ts.ScriptTarget.Latest)
445
+
446
+ /**
447
+ * Convert a resolved `ts.Type` to `TypeInfo` via the type checker. Used to
448
+ * sharpen `createMemo` field types the syntactic `inferTypeFromValue`
449
+ * heuristic can't reach — e.g. `createMemo(() => generateDays())` whose body
450
+ * is a local-function call (→ `CalendarDay[][]`) or a ternary of typed
451
+ * arrays (→ `string[]`). Without this the Go adapter renders such memos as
452
+ * `map[string]interface{}` / `bool` placeholders, so a typed backend can't
453
+ * populate the SSR data (#1968). Returns `null` when the type can't be
454
+ * lowered to a `ts.TypeNode`.
455
+ *
456
+ * Shares the structural lowering in `typeNodeToTypeInfo` (via the `rawOf`
457
+ * extractor) so node→TypeInfo logic lives in one place; the synthetic nodes
458
+ * from `typeToTypeNode` have no source text, so `raw` comes from the printer.
459
+ */
460
+ export function tsTypeToTypeInfo(type: ts.Type, checker: ts.TypeChecker): TypeInfo | null {
461
+ const node = checker.typeToTypeNode(type, undefined, ts.NodeBuilderFlags.NoTruncation)
462
+ if (!node) return null
463
+ const rawOf = (n: ts.TypeNode): string => {
464
+ try {
465
+ return _typePrinter.printNode(ts.EmitHint.Unspecified, n, _blankTypeSourceFile)
466
+ } catch {
467
+ return 'unknown'
468
+ }
469
+ }
470
+ return typeNodeToTypeInfo(node, _blankTypeSourceFile, rawOf)
471
+ }
472
+
446
473
  // =============================================================================
447
474
  // AST Helpers
448
475
  // =============================================================================
@@ -474,3 +501,21 @@ export function isArrowComponentFunction(
474
501
  if (!node.initializer || !ts.isArrowFunction(node.initializer)) return false
475
502
  return true
476
503
  }
504
+
505
+ /**
506
+ * The set of reactive getter names for a component — signal accessors plus memo
507
+ * names. Single source of truth for "what counts as a reactive getter", shared
508
+ * by the analyzer's block-memo fold purity oracle (#2040) and `jsx-to-ir`'s
509
+ * `getReactiveGetterNames`. A reactive read is idempotent within a render, so
510
+ * callers treat `getter()` as pure. If a third reactive kind is added, update
511
+ * this one function.
512
+ */
513
+ export function collectReactiveGetterNames(
514
+ signals: ReadonlyArray<{ getter: string }>,
515
+ memos: ReadonlyArray<{ name: string }>,
516
+ ): Set<string> {
517
+ const names = new Set<string>()
518
+ for (const s of signals) names.add(s.getter)
519
+ for (const m of memos) names.add(m.name)
520
+ return names
521
+ }