@barefootjs/jsx 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/adapters/interface.d.ts +20 -4
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  4. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  5. package/dist/analyzer-context.d.ts.map +1 -1
  6. package/dist/analyzer.d.ts.map +1 -1
  7. package/dist/augment-inherited-props.d.ts +19 -0
  8. package/dist/augment-inherited-props.d.ts.map +1 -1
  9. package/dist/compiler.d.ts.map +1 -1
  10. package/dist/expression-parser.d.ts +1 -0
  11. package/dist/expression-parser.d.ts.map +1 -1
  12. package/dist/index.d.ts +19 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +815 -251
  15. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  19. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  22. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  24. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  26. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/types.d.ts +22 -1
  28. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  29. package/dist/jsx-to-ir.d.ts.map +1 -1
  30. package/dist/loop-destructure.d.ts +55 -18
  31. package/dist/loop-destructure.d.ts.map +1 -1
  32. package/dist/lowering-registry.d.ts +13 -0
  33. package/dist/lowering-registry.d.ts.map +1 -1
  34. package/dist/relocate.d.ts +28 -0
  35. package/dist/relocate.d.ts.map +1 -1
  36. package/dist/ssr-defaults.d.ts.map +1 -1
  37. package/dist/types.d.ts +68 -0
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +2 -2
  40. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  41. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  42. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  43. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  44. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  45. package/src/__tests__/destructured-map-params.test.ts +11 -1
  46. package/src/__tests__/expression-parser.test.ts +63 -2
  47. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  48. package/src/__tests__/loop-destructure.test.ts +313 -0
  49. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  50. package/src/__tests__/props-destructuring.test.ts +110 -0
  51. package/src/__tests__/serialize-parsed-expr.test.ts +70 -3
  52. package/src/__tests__/ssr-defaults.test.ts +20 -0
  53. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  54. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  55. package/src/__tests__/unsupported-expression.test.ts +194 -7
  56. package/src/adapters/interface.ts +20 -4
  57. package/src/adapters/parsed-expr-emitter.ts +19 -2
  58. package/src/analyzer-context.ts +20 -0
  59. package/src/analyzer.ts +74 -1
  60. package/src/augment-inherited-props.ts +139 -9
  61. package/src/compiler.ts +4 -0
  62. package/src/expression-parser.ts +237 -56
  63. package/src/index.ts +23 -1
  64. package/src/ir-to-client-js/collect-elements.ts +15 -1
  65. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  66. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  67. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  68. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  69. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  70. package/src/ir-to-client-js/emit-registration.ts +4 -2
  71. package/src/ir-to-client-js/html-template.ts +198 -1
  72. package/src/ir-to-client-js/index.ts +1 -0
  73. package/src/ir-to-client-js/types.ts +22 -0
  74. package/src/jsx-to-ir.ts +409 -24
  75. package/src/loop-destructure.ts +89 -36
  76. package/src/lowering-registry.ts +16 -0
  77. package/src/relocate.ts +201 -14
  78. package/src/ssr-defaults.ts +34 -32
  79. package/src/types.ts +65 -0
@@ -136,12 +136,14 @@ describe('Unsupported Expression Error (BF021)', () => {
136
136
  })
137
137
 
138
138
  describe('Unsupported Sort Comparator (BF021)', () => {
139
- test('emits BF021 for function-reference comparator — outside accepted catalogue', () => {
139
+ test('function-reference comparator resolves through scope no BF021 (#2090)', () => {
140
140
  // #1448 Tier B follow-up widened the catalogue to include
141
141
  // multi-key (`a.x - b.x || a.y - b.y`), relational ternary, and
142
- // single-`return` block bodies. Function-reference comparators
143
- // (`arr.sort(cmp)` where `cmp` is a named function) are still out
144
- // of scope they need scope resolution and refuse here.
142
+ // single-`return` block bodies. #2090 closes the remaining gap:
143
+ // a bare identifier callback (`arr.sort(cmp)`) is now resolved
144
+ // through the analyzer's scope machinery (one hop, same-file
145
+ // only) to the const-bound arrow, then fed through the same
146
+ // catalogue as an inline comparator.
145
147
  const source = `
146
148
  'use client'
147
149
  import { createSignal } from '@barefootjs/client'
@@ -152,7 +154,73 @@ describe('Unsupported Sort Comparator (BF021)', () => {
152
154
  return (
153
155
  <ul>
154
156
  {items().sort(cmp).map(t => (
155
- <li>{t.name}</li>
157
+ <li key={t.name}>{t.name}</li>
158
+ ))}
159
+ </ul>
160
+ )
161
+ }
162
+ `
163
+
164
+ const { ir, errors } = compileToIR(source)
165
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
166
+
167
+ expect(bf021).toHaveLength(0)
168
+ expect(ir).not.toBeNull()
169
+ if (ir!.type === 'element') {
170
+ const loop = ir!.children.find(c => c.type === 'loop')
171
+ expect(loop).toBeDefined()
172
+ if (loop?.type === 'loop') {
173
+ expect(loop.sortComparator).toBeDefined()
174
+ expect(loop.sortComparator!.raw).toBe('a.priority - b.priority')
175
+ }
176
+ }
177
+ })
178
+
179
+ test('function-declaration comparator reference resolves — no BF021 (#2090)', () => {
180
+ const source = `
181
+ 'use client'
182
+ import { createSignal } from '@barefootjs/client'
183
+
184
+ function cmp(a, b) { return a.priority - b.priority }
185
+
186
+ export function TodoList() {
187
+ const [items, setItems] = createSignal<any[]>([])
188
+ return (
189
+ <ul>
190
+ {items().toSorted(cmp).map(t => (
191
+ <li key={t.name}>{t.name}</li>
192
+ ))}
193
+ </ul>
194
+ )
195
+ }
196
+ `
197
+
198
+ const { ir, errors } = compileToIR(source)
199
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
200
+
201
+ expect(bf021).toHaveLength(0)
202
+ expect(ir).not.toBeNull()
203
+ if (ir!.type === 'element') {
204
+ const loop = ir!.children.find(c => c.type === 'loop')
205
+ if (loop?.type === 'loop') {
206
+ expect(loop.sortComparator).toBeDefined()
207
+ expect(loop.sortComparator!.raw).toBe('a.priority - b.priority')
208
+ }
209
+ }
210
+ })
211
+
212
+ test('identifier resolving to a non-function const emits BF021 (#2090)', () => {
213
+ const source = `
214
+ 'use client'
215
+ import { createSignal } from '@barefootjs/client'
216
+
217
+ export function TodoList() {
218
+ const [items, setItems] = createSignal<any[]>([])
219
+ const cmp = 5
220
+ return (
221
+ <ul>
222
+ {items().sort(cmp).map(t => (
223
+ <li key={t.name}>{t.name}</li>
156
224
  ))}
157
225
  </ul>
158
226
  )
@@ -163,16 +231,135 @@ describe('Unsupported Sort Comparator (BF021)', () => {
163
231
  const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
164
232
 
165
233
  expect(bf021).toHaveLength(1)
234
+ expect(bf021[0].message).toContain('could not be resolved')
166
235
  })
167
236
 
168
- test('@client suppresses BF021 for unsupported sort comparator', () => {
237
+ test('cross-kind shadowing (component const over module function) emits BF021, not the shadowed function (#2090)', () => {
238
+ // A name bound both as a const and as a `function` declaration can
239
+ // only happen across scopes, and FunctionInfo does not carry lexical
240
+ // scope (component-body functions are hoisted for client emission).
241
+ // Resolution refuses the ambiguity rather than guessing — pre-fix
242
+ // this wrongly compiled against the shadowed module function
243
+ // (Copilot review on #2091).
169
244
  const source = `
170
245
  'use client'
171
246
  import { createSignal } from '@barefootjs/client'
172
247
 
248
+ function cmp(a, b) { return a.priority - b.priority }
249
+
250
+ export function TodoList() {
251
+ const [items, setItems] = createSignal<any[]>([])
252
+ const cmp = 5
253
+ return (
254
+ <ul>
255
+ {items().sort(cmp).map(t => (
256
+ <li key={t.name}>{t.name}</li>
257
+ ))}
258
+ </ul>
259
+ )
260
+ }
261
+ `
262
+
263
+ const { errors } = compileToIR(source)
264
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
265
+
266
+ expect(bf021).toHaveLength(1)
267
+ expect(bf021[0].message).toContain('could not be resolved')
268
+ })
269
+
270
+ test('cross-kind shadowing (component function over module const) also refuses with BF021 (#2090)', () => {
271
+ // The safe half of the same ambiguity: JS would use the component
272
+ // function here, but resolution cannot prove which binding the call
273
+ // site sees, so it refuses loudly instead of risking the WRONG
274
+ // (opposite-direction) comparator.
275
+ const source = `
276
+ 'use client'
277
+ import { createSignal } from '@barefootjs/client'
278
+
279
+ const byPrice = (a, b) => a.price - b.price
280
+
281
+ export function ProductList() {
282
+ const [products, setProducts] = createSignal<any[]>([])
283
+ function byPrice(a, b) { return b.price - a.price }
284
+ return (
285
+ <ul>
286
+ {products().sort(byPrice).map(p => (
287
+ <li key={p.name}>{p.name}</li>
288
+ ))}
289
+ </ul>
290
+ )
291
+ }
292
+ `
293
+
294
+ const { errors } = compileToIR(source)
295
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
296
+
297
+ expect(bf021).toHaveLength(1)
298
+ expect(bf021[0].message).toContain('could not be resolved')
299
+ })
300
+
301
+ test('unresolved (imported) identifier comparator emits BF021 (#2090)', () => {
302
+ const source = `
303
+ 'use client'
304
+ import { createSignal } from '@barefootjs/client'
305
+ import { cmp } from './cmp'
306
+
307
+ export function TodoList() {
308
+ const [items, setItems] = createSignal<any[]>([])
309
+ return (
310
+ <ul>
311
+ {items().sort(cmp).map(t => (
312
+ <li key={t.name}>{t.name}</li>
313
+ ))}
314
+ </ul>
315
+ )
316
+ }
317
+ `
318
+
319
+ const { errors } = compileToIR(source)
320
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
321
+
322
+ expect(bf021).toHaveLength(1)
323
+ expect(bf021[0].message).toContain('could not be resolved')
324
+ })
325
+
326
+ test('resolved-but-off-catalogue comparator body emits BF021 naming the comparator (#2090)', () => {
327
+ // `a.deep.x - b.deep.x` has operand depth > 1 — `classifySortOperand`
328
+ // only accepts the param itself or a single-level field access, so this
329
+ // stays refused even once the identifier resolves to the arrow.
330
+ const source = `
331
+ 'use client'
332
+ import { createSignal } from '@barefootjs/client'
333
+
334
+ export function TodoList() {
335
+ const [items, setItems] = createSignal<any[]>([])
336
+ const cmp = (a, b) => a.deep.x - b.deep.x
337
+ return (
338
+ <ul>
339
+ {items().sort(cmp).map(t => (
340
+ <li key={t.name}>{t.name}</li>
341
+ ))}
342
+ </ul>
343
+ )
344
+ }
345
+ `
346
+
347
+ const { errors } = compileToIR(source)
348
+ const bf021 = errors.filter(e => e.code === ErrorCodes.UNSUPPORTED_JSX_PATTERN)
349
+
350
+ expect(bf021).toHaveLength(1)
351
+ expect(bf021[0].message).toContain("'cmp'")
352
+ expect(bf021[0].message).toContain('not a supported shape')
353
+ })
354
+
355
+ test('@client suppresses BF021 for an unresolved sort comparator identifier', () => {
356
+ const source = `
357
+ 'use client'
358
+ import { createSignal } from '@barefootjs/client'
359
+ import { cmp } from './cmp'
360
+
173
361
  export function TodoList() {
174
362
  const [items, setItems] = createSignal<any[]>([])
175
- const cmp = (a, b) => a.priority - b.priority
176
363
  return (
177
364
  <ul>
178
365
  {/* @client */ items().sort(cmp).map(t => (
@@ -96,9 +96,23 @@ export type TemplatePrimitiveEmit = (args: string[]) => string
96
96
  * Keys are the textual callee path as it appears in the JSX expression
97
97
  * (`JSON.stringify`, `Math.floor`, `String`).
98
98
  *
99
- * V1 scope: identifier-path callees only. Method calls on values whose type
100
- * the analyzer must resolve (`props.name.toUpperCase()`) are out of scope
101
- * see #1187 R1. Users can fall back to `/* @client *\/` for those.
99
+ * V1 scope (#1187): identifier-path callees only, and only names the adapter
100
+ * knows about AHEAD OF TIME this map is fixed at adapter-construction
101
+ * time, so it can never contain a name from a component's own (unknown in
102
+ * advance) imports. Method calls on values whose type the analyzer must
103
+ * resolve (`props.name.toUpperCase()`) are ALSO out of scope — see #1187 R1.
104
+ * Users can fall back to `/* @client *\/` for either limitation.
105
+ *
106
+ * V2 (#2069) does not widen this map — it adds a separate, orthogonal
107
+ * acceptance path instead: `RelocateEnv.loweringMatchers`, bound once per
108
+ * component from the global `LoweringPlugin` registry
109
+ * (`prepareLoweringMatchers`, `packages/jsx/src/lowering-registry.ts`). A
110
+ * plugin's `prepare(metadata)` resolves the component's ACTUAL import list,
111
+ * so it can recognise a call this string-keyed map structurally never could
112
+ * (a bespoke user import, unknown until compile time). `templatePrimitives`
113
+ * remains the right home for well-known JS builtins (`JSON.stringify`,
114
+ * `Math.floor`) that every component might reasonably use, with no import
115
+ * to key a plugin against.
102
116
  */
103
117
  export type TemplatePrimitiveRegistry = Record<string, TemplatePrimitiveEmit>
104
118
 
@@ -110,7 +124,9 @@ export type TemplatePrimitiveRegistry = Record<string, TemplatePrimitiveEmit>
110
124
  *
111
125
  * Adapters whose template runtime can't execute arbitrary JS (Go, Perl,
112
126
  * other server-side template languages) should leave this undefined and
113
- * rely on the explicit `templatePrimitives` map alone.
127
+ * rely on the explicit `templatePrimitives` map alone (plus, since #2069,
128
+ * whatever `LoweringPlugin`s are registered — see `TemplatePrimitiveRegistry`
129
+ * above).
114
130
  */
115
131
  export type TemplateCallAcceptor = (calleeName: string) => boolean
116
132
 
@@ -177,9 +177,22 @@ export interface ParsedExprEmitter {
177
177
  // a structured `FlatDepth` (the validated literal / `'infinity'`) rather
178
178
  // than a `ParsedExpr[]` args list. Non-callback, so it is NOT routed
179
179
  // through `callbackMethod`.
180
+ //
181
+ // `depth` is `FlatDepth` for the literal path (unchanged: adapters keep
182
+ // their existing `bf_flat`-family emit exactly as before) or
183
+ // `{ expr: ParsedExpr }` for a DYNAMIC depth (#2094) — an adapter renders
184
+ // `expr` with the `emit` callback and passes the result to a SEPARATE
185
+ // runtime helper that coerces it at render time (JS `ToIntegerOrInfinity`;
186
+ // see the `depthExpr` doc on `ParsedExpr`'s `array-method`/`flat` variant
187
+ // for why this must NOT be the same helper as the literal path — the
188
+ // literal path's `-1` sentinel means "flatten fully", but a genuinely
189
+ // dynamic `-1` means the opposite per JS). Every adapter emits its
190
+ // `flat_dynamic`-family helper for the dynamic form, and every runtime
191
+ // implements the coercion; parity is pinned by the `flat_dynamic`
192
+ // golden helper vectors.
180
193
  flatMethod(
181
194
  object: ParsedExpr,
182
- depth: FlatDepth,
195
+ depth: FlatDepth | { expr: ParsedExpr },
183
196
  emit: (e: ParsedExpr) => string,
184
197
  ): string
185
198
  unsupported(raw: string, reason: string): string
@@ -232,7 +245,11 @@ export function emitParsedExpr(expr: ParsedExpr, emitter: ParsedExprEmitter): st
232
245
  return emitter.objectLiteral(expr.properties, expr.raw, emit)
233
246
  case 'array-method':
234
247
  if (expr.method === 'flat') {
235
- return emitter.flatMethod(expr.object, expr.flatDepth, emit)
248
+ return emitter.flatMethod(
249
+ expr.object,
250
+ expr.depthExpr ? { expr: expr.depthExpr } : expr.flatDepth,
251
+ emit,
252
+ )
236
253
  }
237
254
  return emitter.arrayMethod(expr.method, expr.object, expr.args, emit)
238
255
  case 'unsupported':
@@ -268,6 +268,26 @@ export function createAnalyzerContext(
268
268
  checker: null,
269
269
  componentBodyBlock: null,
270
270
  getJS(node: ts.Node): string {
271
+ // A node whose owning source file differs from this component's
272
+ // `sourceFile` comes from a synthetic re-parse — e.g. #2090's
273
+ // resolved sort-comparator functions, or #2092's desugared
274
+ // tagged-template-literal callee — rather than a live position in
275
+ // this file. `reconstructWithoutTypes` slices `sourceFile.text` by
276
+ // the node's start/end, so feeding it a foreign node's positions
277
+ // would silently slice the WRONG bytes (not throw). These synthetic
278
+ // subtrees are already built from plain re-parsed source text with
279
+ // no straggling TS type syntax to strip, so the node's own text is
280
+ // correct as-is — mirrors the `astText()` workaround already used
281
+ // for synthetic const/function re-parses elsewhere in the compiler.
282
+ let ownSourceFile: ts.SourceFile | undefined
283
+ try {
284
+ ownSourceFile = node.getSourceFile()
285
+ } catch {
286
+ ownSourceFile = undefined
287
+ }
288
+ if (ownSourceFile && ownSourceFile !== sourceFile) {
289
+ return node.getText(ownSourceFile)
290
+ }
271
291
  return reconstructWithoutTypes(node, sourceFile, this.typeExcludeRanges)
272
292
  },
273
293
  }
package/src/analyzer.ts CHANGED
@@ -3011,6 +3011,12 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3011
3011
  hasIgnoreDirective: ignored,
3012
3012
  }
3013
3013
 
3014
+ // Resolve each destructured binding's declared type from the param's type
3015
+ // annotation, so `{ value }: Props` keeps the same per-prop TypeInfo as the
3016
+ // `props: Props` path instead of degrading to `unknown` (which typed
3017
+ // adapters emit as `interface{}` + an unchecked assertion). See issue #2150.
3018
+ const memberTypes = param.type ? collectMemberTypes(param.type, ctx) : null
3019
+
3014
3020
  for (const element of param.name.elements) {
3015
3021
  if (ts.isBindingElement(element) && ts.isIdentifier(element.name)) {
3016
3022
  const localName = element.name.text
@@ -3022,10 +3028,19 @@ function extractProps(param: ts.ParameterDeclaration, ctx: AnalyzerContext): voi
3022
3028
  continue
3023
3029
  }
3024
3030
 
3031
+ // Aliased bindings (`{ value: v }`) take their type from the source
3032
+ // property name, not the local alias.
3033
+ const sourcePropName =
3034
+ element.propertyName && ts.isIdentifier(element.propertyName)
3035
+ ? element.propertyName.text
3036
+ : localName
3037
+ const resolvedType: TypeInfo =
3038
+ memberTypes?.get(sourcePropName) ?? { kind: 'unknown', raw: 'unknown' }
3039
+
3025
3040
  const defaultContainsArrow = element.initializer ? nodeContainsArrow(element.initializer) : false
3026
3041
  ctx.propsParams.push({
3027
3042
  name: localName,
3028
- type: { kind: 'unknown', raw: 'unknown' },
3043
+ type: resolvedType,
3029
3044
  optional: !!element.initializer,
3030
3045
  defaultValue,
3031
3046
  defaultContainsArrow: defaultContainsArrow || undefined,
@@ -3108,6 +3123,64 @@ function collectKeysFromMembers(
3108
3123
  return keys
3109
3124
  }
3110
3125
 
3126
+ /**
3127
+ * Build a property-name -> resolved TypeInfo map from a props type annotation,
3128
+ * for destructured params (`{ value }: Props`) so they carry the same per-prop
3129
+ * types the props-object path (`props: Props`) already resolves. Returns null
3130
+ * for external/unresolvable types.
3131
+ *
3132
+ * Scope (deliberately narrow — targets the panic in issue #2150):
3133
+ * - Only REQUIRED members are resolved. Optional members (`value?: T`) are left
3134
+ * out so they keep degrading to `unknown` -> `interface{}`: typed adapters
3135
+ * rely on that nillable field to omit an unset optional attribute
3136
+ * (`{{if ne .X nil}}` in Go), which a concrete zero value could not express.
3137
+ * - Only PRIMITIVE members (string/number/boolean) are resolved. Those are the
3138
+ * types that otherwise produce an unchecked scalar assertion (`in.X.(int)`)
3139
+ * that panics. Arrays/objects/functions are left as `unknown` because typed
3140
+ * adapters lower them through interface{}-based helpers (`bf_flat`, spread,
3141
+ * `bf_json`); giving them concrete types would break that lowering and is a
3142
+ * larger, separate change.
3143
+ */
3144
+ function collectMemberTypes(
3145
+ typeNode: ts.TypeNode,
3146
+ ctx: AnalyzerContext
3147
+ ): Map<string, TypeInfo> | null {
3148
+ const isResolvablePrimitive = (info: TypeInfo): boolean =>
3149
+ info.kind === 'primitive' &&
3150
+ (info.primitive === 'string' || info.primitive === 'number' || info.primitive === 'boolean')
3151
+
3152
+ const fromMembers = (members: ts.NodeArray<ts.TypeElement>): Map<string, TypeInfo> => {
3153
+ const map = new Map<string, TypeInfo>()
3154
+ for (const member of members) {
3155
+ if (ts.isPropertySignature(member) && member.name && member.type && !member.questionToken) {
3156
+ const info = typeNodeToTypeInfo(member.type, ctx.sourceFile)
3157
+ if (info && isResolvablePrimitive(info)) {
3158
+ map.set(member.name.getText(ctx.sourceFile), info)
3159
+ }
3160
+ }
3161
+ }
3162
+ return map
3163
+ }
3164
+
3165
+ if (ts.isTypeLiteralNode(typeNode)) {
3166
+ return fromMembers(typeNode.members)
3167
+ }
3168
+
3169
+ if (ts.isTypeReferenceNode(typeNode)) {
3170
+ const typeName = typeNode.typeName.getText(ctx.sourceFile)
3171
+ const typeDecl = findTypeDeclaration(typeName, ctx.sourceFile)
3172
+ if (!typeDecl) return null
3173
+ if (ts.isInterfaceDeclaration(typeDecl)) {
3174
+ return fromMembers(typeDecl.members)
3175
+ }
3176
+ if (ts.isTypeAliasDeclaration(typeDecl) && ts.isTypeLiteralNode(typeDecl.type)) {
3177
+ return fromMembers(typeDecl.type.members)
3178
+ }
3179
+ }
3180
+
3181
+ return null
3182
+ }
3183
+
3111
3184
  /**
3112
3185
  * Extract props parameters from a type annotation.
3113
3186
  * Supports type literals and type references to local interfaces.
@@ -32,6 +32,25 @@ export interface ContextConsumer {
32
32
  * absent / not a literal (the consumer then defaults to the empty value).
33
33
  */
34
34
  defaultValue: string | number | boolean | null
35
+ /**
36
+ * Set to `'object'` when the `createContext(<default>)` argument is an
37
+ * OBJECT LITERAL (`createContext<{ config: X }>({ config: {} })`) — a shape
38
+ * `defaultValue` alone can't distinguish from "no default at all" (both
39
+ * collapse to `null`). Adapters with a live SSR context stack (twig, jinja,
40
+ * erb, minijinja, xslate, mojolicious) never read this: their consumer seed
41
+ * (`bf.use_context(name, default)`) only matters when NO enclosing Provider
42
+ * ran, and every existing fixture exercising an object-shaped default keeps
43
+ * its consumer nested inside a Provider that supplies a live value at
44
+ * render time — see each adapter's `memo/seed.ts` (or equivalent). Go has no
45
+ * such runtime stack: it bakes the Provider's value into the descendant's
46
+ * constructor call at COMPILE time (`extendProviderContext` /
47
+ * `emitStaticChildInstances`, go-template-adapter.ts), so an object-shaped
48
+ * default needs its own Go type (`map[string]interface{}`, not the scalar
49
+ * `string` fallback `contextConsumerGoType` used to pick for every
50
+ * non-literal default) — `defaultKind` is what lets it tell "object" apart
51
+ * from "no default" (#2087).
52
+ */
53
+ defaultKind?: 'object'
35
54
  }
36
55
 
37
56
  /**
@@ -44,9 +63,15 @@ export function collectContextConsumers(metadata: IRMetadata): ContextConsumer[]
44
63
  const constants = metadata.localConstants ?? []
45
64
  // Map each createContext const name → its default-arg literal value.
46
65
  const contextDefaults = new Map<string, string | number | boolean | null>()
66
+ // Map each createContext const name → 'object' when its default is an
67
+ // object-literal (a shape `contextDefaults` alone can't distinguish from
68
+ // "no default" — both parse to `null`). Only Go consults this (see
69
+ // `ContextConsumer.defaultKind`'s docstring).
70
+ const contextDefaultKinds = new Map<string, 'object'>()
47
71
  for (const c of constants) {
48
72
  if (c.systemConstructKind !== 'createContext' || c.value === undefined) continue
49
73
  contextDefaults.set(c.name, parseCreateContextDefault(c.value))
74
+ if (isObjectLiteralCreateContextDefault(c.value)) contextDefaultKinds.set(c.name, 'object')
50
75
  }
51
76
  if (contextDefaults.size === 0) return []
52
77
 
@@ -59,6 +84,7 @@ export function collectContextConsumers(metadata: IRMetadata): ContextConsumer[]
59
84
  localName: c.name,
60
85
  contextName: ctxName,
61
86
  defaultValue: contextDefaults.get(ctxName) ?? null,
87
+ defaultKind: contextDefaultKinds.get(ctxName),
62
88
  })
63
89
  }
64
90
  return consumers
@@ -87,6 +113,14 @@ function parseCreateContextDefault(source: string): string | number | boolean |
87
113
  return null
88
114
  }
89
115
 
116
+ /** True when `createContext(<arg>)`'s argument is an object-literal (`{ config: {} }`). */
117
+ function isObjectLiteralCreateContextDefault(source: string): boolean {
118
+ const expr = parseSingleExpression(source)
119
+ if (!expr || !ts.isCallExpression(expr)) return false
120
+ if (expr.arguments.length === 0) return false
121
+ return ts.isObjectLiteralExpression(expr.arguments[0])
122
+ }
123
+
90
124
  function parseSingleExpression(source: string): ts.Expression | null {
91
125
  const sf = ts.createSourceFile('__ctx.ts', `(${source})`, ts.ScriptTarget.Latest, false)
92
126
  const stmt = sf.statements[0]
@@ -139,14 +173,65 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
139
173
  const booleanAttrProps = new Set<string>()
140
174
  const accessed = new Set<string>()
141
175
  const accessRe = new RegExp(`(?:^|[^\\w$.])${propsObj}\\.([A-Za-z_$][\\w$]*)`, 'g')
142
- const scan = (s: string | undefined): void => {
176
+ const scan = (s: string | undefined, also?: Set<string>): void => {
143
177
  if (!s) return
144
- for (const m of s.matchAll(accessRe)) accessed.add(m[1])
178
+ for (const m of s.matchAll(accessRe)) {
179
+ accessed.add(m[1])
180
+ also?.add(m[1])
181
+ }
182
+ }
183
+
184
+ // Coalesce-literal type pins: a read of the form `props.X ?? <literal>`
185
+ // (or `|| <literal>`) fixes X's type to the literal's. The Go adapter
186
+ // seeds a prop-derived signal/memo constructor from the synthetic field
187
+ // (`Count: in.Initial` where `Count int` came from evaluating
188
+ // `props.initial ?? 0`), so classifying `initial` with the string
189
+ // default produces a Go type mismatch the compiler rejects.
190
+ const coalesceLiteralTypes = new Map<string, 'number' | 'boolean' | 'string'>()
191
+ const pinCoalesceLiterals = (s: string | undefined): void => {
192
+ if (!s || !s.includes(propsObj)) return
193
+ const sf = ts.createSourceFile('__aug.ts', `(${s})`, ts.ScriptTarget.Latest, false)
194
+ const visit = (n: ts.Node): void => {
195
+ if (
196
+ ts.isBinaryExpression(n) &&
197
+ (n.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken ||
198
+ n.operatorToken.kind === ts.SyntaxKind.BarBarToken)
199
+ ) {
200
+ let left: ts.Expression = n.left
201
+ while (ts.isParenthesizedExpression(left)) left = left.expression
202
+ if (
203
+ ts.isPropertyAccessExpression(left) &&
204
+ ts.isIdentifier(left.expression) &&
205
+ left.expression.text === propsObj
206
+ ) {
207
+ const name = left.name.text
208
+ let right: ts.Expression = n.right
209
+ while (ts.isParenthesizedExpression(right)) right = right.expression
210
+ if (ts.isPrefixUnaryExpression(right)) right = right.operand
211
+ const kind = ts.isNumericLiteral(right)
212
+ ? 'number'
213
+ : right.kind === ts.SyntaxKind.TrueKeyword || right.kind === ts.SyntaxKind.FalseKeyword
214
+ ? 'boolean'
215
+ : ts.isStringLiteralLike(right)
216
+ ? 'string'
217
+ : null
218
+ if (kind && !coalesceLiteralTypes.has(name)) coalesceLiteralTypes.set(name, kind)
219
+ }
220
+ }
221
+ ts.forEachChild(n, visit)
222
+ }
223
+ visit(sf)
145
224
  }
146
225
 
147
226
  // Memos, signals, init statements, effects: any `props.X` read.
148
- for (const memo of ir.metadata.memos) scan(memo.computation)
149
- for (const signal of ir.metadata.signals) scan(signal.initialValue)
227
+ for (const memo of ir.metadata.memos) {
228
+ scan(memo.computation)
229
+ pinCoalesceLiterals(memo.computation)
230
+ }
231
+ for (const signal of ir.metadata.signals) {
232
+ scan(signal.initialValue)
233
+ pinCoalesceLiterals(signal.initialValue)
234
+ }
150
235
  for (const stmt of ir.metadata.initStatements ?? []) scan(stmt.body)
151
236
  for (const eff of ir.metadata.effects ?? []) scan((eff as { body?: string }).body)
152
237
 
@@ -157,12 +242,58 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
157
242
  for (const c of ir.metadata.localConstants ?? []) {
158
243
  if (c.isModule) continue
159
244
  scan(c.value)
245
+ pinCoalesceLiterals(c.value)
160
246
  }
161
247
 
162
248
  // Template attribute exprs — also note bare-ref / boolean usage.
163
249
  const walk = (node: IRNode | undefined): void => {
164
250
  if (!node) return
251
+ // Non-attribute expression carriers (#2126 follow-up): a `props.X`
252
+ // read can reach the template as a bare scalar through a dynamic
253
+ // text child (`<p>{props.label}</p>`), a conditional / if-statement
254
+ // condition (`{props.show && …}`), or a loop's array expression
255
+ // (`(props.items ?? []).map(…)`). With a typed props object those
256
+ // names are already in `propsParams`; with an untyped `props`
257
+ // parameter these reads were invisible to this pass, so the emitted
258
+ // template referenced a var the props type / ssrDefaults never
259
+ // declared (a strict-mode 500 on Perl-family adapters, a missing
260
+ // struct field on Go). Text reads keep the default `string`
261
+ // classification; condition / loop-array reads land in the nillable
262
+ // bucket (`unknown` → omittable/interface-typed) since their values
263
+ // are only ever truth-tested or iterated.
264
+ const carrier = node as unknown as { type?: string; expr?: string; condition?: string; array?: string }
265
+ if (carrier.type === 'expression') {
266
+ scan(carrier.expr)
267
+ pinCoalesceLiterals(carrier.expr)
268
+ }
269
+ scan(carrier.condition, bareRefProps)
270
+ pinCoalesceLiterals(carrier.condition)
271
+ scan(carrier.array, bareRefProps)
165
272
  const el = node as unknown as IRElement
273
+ // Component nodes carry the same AttrValue shapes under `props`
274
+ // (`<Child value={props.foo} />` reads `$foo` at child-props
275
+ // construction time in the SSR template) — but they are NOT HTML
276
+ // attributes: boolean-attr inference by name doesn't apply, and the
277
+ // value's type belongs to the child's contract, not to how this
278
+ // component renders it. Classify these reads as nillable (`unknown`)
279
+ // unless a `?? <literal>` pins a concrete type.
280
+ for (const prop of (node as unknown as { props?: IRElement['attrs'] }).props ?? []) {
281
+ const v = prop.value as { kind?: string; expr?: string; parts?: Array<{ type: string } & Record<string, string>> }
282
+ if (v?.parts) {
283
+ for (const part of v.parts) {
284
+ if (part.type === 'string') scan(part.value)
285
+ else if (part.type === 'ternary') {
286
+ scan(part.condition)
287
+ scan(part.whenTrue)
288
+ scan(part.whenFalse)
289
+ } else if (part.type === 'lookup') scan(part.key)
290
+ }
291
+ }
292
+ if (v?.kind === 'expression' && typeof v.expr === 'string') {
293
+ scan(v.expr, bareRefProps)
294
+ pinCoalesceLiterals(v.expr)
295
+ }
296
+ }
166
297
  for (const attr of el.attrs ?? []) {
167
298
  const v = attr.value as {
168
299
  kind?: string
@@ -231,14 +362,13 @@ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
231
362
  if (existing.has(name)) continue
232
363
  let raw: string
233
364
  if (booleanAttrProps.has(name)) raw = 'boolean'
365
+ else if (coalesceLiteralTypes.has(name)) raw = coalesceLiteralTypes.get(name)!
234
366
  else if (bareRefProps.has(name)) raw = 'unknown' // → interface{} (nillable, omittable)
235
367
  else raw = 'string' // read in a memo / string context (e.g. className)
236
368
  const type: TypeInfo =
237
- raw === 'boolean'
238
- ? { kind: 'primitive', raw: 'boolean', primitive: 'boolean' }
239
- : raw === 'string'
240
- ? { kind: 'primitive', raw: 'string', primitive: 'string' }
241
- : { kind: 'unknown', raw: 'unknown' }
369
+ raw === 'unknown'
370
+ ? { kind: 'unknown', raw: 'unknown' }
371
+ : { kind: 'primitive', raw, primitive: raw as 'string' | 'number' | 'boolean' }
242
372
  ir.metadata.propsParams.push({ name, type, optional: true })
243
373
  existing.add(name)
244
374
  }
package/src/compiler.ts CHANGED
@@ -276,6 +276,7 @@ function compileMultipleComponents(
276
276
  path: dir + output.componentName + adapter.extension,
277
277
  content: output.rawTemplate,
278
278
  type: 'markedTemplate',
279
+ componentName: output.componentName,
279
280
  })
280
281
  // SSR defaults, paired with the per-component template file via
281
282
  // the matching basename (the build pipeline pairs them in
@@ -287,6 +288,7 @@ function compileMultipleComponents(
287
288
  path: dir + output.componentName + '.ssr-defaults.json',
288
289
  content: JSON.stringify(ssrDefaults),
289
290
  type: 'ssrDefaults',
291
+ componentName: output.componentName,
290
292
  })
291
293
  }
292
294
  }
@@ -672,6 +674,7 @@ export function compileJSX(
672
674
  path: filePath.replace(/\.tsx?$/, adapter.extension),
673
675
  content,
674
676
  type: 'markedTemplate',
677
+ componentName: componentIR.metadata.componentName,
675
678
  })
676
679
 
677
680
  // SSR defaults — JSON-encoded seed values for the template's
@@ -686,6 +689,7 @@ export function compileJSX(
686
689
  path: filePath.replace(/\.tsx?$/, '.ssr-defaults.json'),
687
690
  content: JSON.stringify(ssrDefaults),
688
691
  type: 'ssrDefaults',
692
+ componentName: componentIR.metadata.componentName,
689
693
  })
690
694
  }
691
695
  }