@barefootjs/go-template 0.17.0 → 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 (39) hide show
  1. package/dist/adapter/expr/url-builder.d.ts.map +1 -1
  2. package/dist/adapter/go-template-adapter.d.ts +144 -14
  3. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  4. package/dist/adapter/index.js +449 -118
  5. package/dist/adapter/lib/compile-state.d.ts +27 -1
  6. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  7. package/dist/adapter/lib/go-emit.d.ts +9 -0
  8. package/dist/adapter/lib/go-emit.d.ts.map +1 -1
  9. package/dist/adapter/lib/go-naming.d.ts +23 -0
  10. package/dist/adapter/lib/go-naming.d.ts.map +1 -1
  11. package/dist/adapter/memo/memo-compute.d.ts +54 -5
  12. package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
  13. package/dist/adapter/memo/memo-type.d.ts +10 -0
  14. package/dist/adapter/memo/memo-type.d.ts.map +1 -1
  15. package/dist/adapter/type/type-codegen.d.ts +5 -1
  16. package/dist/adapter/type/type-codegen.d.ts.map +1 -1
  17. package/dist/adapter/value/parsed-literal-to-go.d.ts +13 -4
  18. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -1
  19. package/dist/build.js +449 -118
  20. package/dist/conformance-pins.d.ts +12 -0
  21. package/dist/conformance-pins.d.ts.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +468 -118
  25. package/package.json +3 -3
  26. package/src/__tests__/go-template-adapter.test.ts +652 -129
  27. package/src/__tests__/lowering-plugin.test.ts +56 -0
  28. package/src/adapter/expr/url-builder.ts +14 -3
  29. package/src/adapter/go-template-adapter.ts +637 -140
  30. package/src/adapter/lib/compile-state.ts +31 -0
  31. package/src/adapter/lib/go-emit.ts +20 -0
  32. package/src/adapter/lib/go-naming.ts +29 -0
  33. package/src/adapter/memo/memo-compute.ts +228 -18
  34. package/src/adapter/memo/memo-type.ts +19 -0
  35. package/src/adapter/type/type-codegen.ts +22 -3
  36. package/src/adapter/value/parsed-literal-to-go.ts +82 -9
  37. package/src/conformance-pins.ts +135 -0
  38. package/src/index.ts +1 -0
  39. package/src/test-render.ts +46 -8
@@ -31,6 +31,7 @@ import type {
31
31
  IRAsync,
32
32
  IRMetadata,
33
33
  TemplatePrimitiveRegistry,
34
+ LoopBindingPathSegment,
34
35
  } from '@barefootjs/jsx'
35
36
  import {
36
37
  BaseAdapter,
@@ -59,11 +60,12 @@ import {
59
60
  emitAttrValue,
60
61
  augmentInheritedPropAccesses,
61
62
  collectContextConsumers,
62
- isLowerableObjectRestDestructure,
63
+ isLowerableLoopDestructure,
63
64
  type ContextConsumer,
64
65
  collectModuleStringConsts as collectModuleStringConstsShared,
65
- searchParamsLocalNames,
66
- prepareLoweringMatchers
66
+ prepareLoweringMatchers,
67
+ envSignalReaderFor,
68
+ computeSsrSeedPlan,
67
69
  } from '@barefootjs/jsx'
68
70
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
69
71
  import { BF_REGION } from '@barefootjs/shared'
@@ -73,6 +75,7 @@ import {
73
75
  GO_KEYWORDS,
74
76
  capitalize,
75
77
  capitalizeFieldName,
78
+ goFieldNameForKey,
76
79
  slotIdToFieldSuffix,
77
80
  loopKeyToGoFieldPath,
78
81
  } from "./lib/go-naming.ts"
@@ -85,6 +88,7 @@ import {
85
88
  emitReduceEval,
86
89
  emitPredicateEval,
87
90
  emitFlatMapEval,
91
+ emitMapEval,
88
92
  stringTolerantEqOperands,
89
93
  buildUnsupportedSuggestion,
90
94
  GO_REMEDIATION_OPTIONS,
@@ -114,11 +118,12 @@ import {
114
118
  jsLiteralToGo,
115
119
  objectLiteralToGoMap,
116
120
  } from "./value/value-lowering.ts"
121
+ import { parsedLiteralToGo } from "./value/parsed-literal-to-go.ts"
117
122
  import { typeInfoToGo } from "./type/type-codegen.ts"
118
- import { isBooleanMemo, isStringTernaryMemo } from "./memo/memo-type.ts"
123
+ import { isBooleanMemo, isListFilterMemo, isStringTernaryMemo } from "./memo/memo-type.ts"
119
124
  import { lowerCtorExpr } from "./memo/ctor-lowering.ts"
120
125
  import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
121
- import { computeMemoInitialValue, computeMemoInitialValueOrNull } from "./memo/memo-compute.ts"
126
+ import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
122
127
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
123
128
  import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames } from "./props/prop-types.ts"
124
129
 
@@ -226,11 +231,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
226
231
  * wrapper's synthetic scalar field) instead of `.`. Innermost last.
227
232
  */
228
233
  private loopScalarItemStack: boolean[] = []
234
+ /**
235
+ * Per-loop: true when the loop body IS a single component
236
+ * (`loop.childComponent`), i.e. the range iterates the `.{Name}s` wrapper
237
+ * slice and `.` inside the body is a wrapper struct that embeds the child's
238
+ * Props. False for a component merely nested inside an element item
239
+ * (`<li><Badge/></li>`, #2130), where `.` is the raw datum and the child's
240
+ * props live on the PARENT's once-per-slot instance (`$.{Name}SlotN`).
241
+ * Innermost last.
242
+ */
243
+ private loopWrapperStack: boolean[] = []
229
244
  private loopVarRefCount: Map<string, number> = new Map()
230
245
  /** Stack of destructure-param binding maps (binding name → Go accessor on the
231
- * range var, e.g. `id` → `$__bf_item0.Id`, `rest` → `$__bf_item0`). Innermost
232
- * last. Lets `.map(({ id, ...rest }) => …)` resolve instead of BF104. */
246
+ * range var, e.g. `id` → `$__bf_item0.Id`, `rest` → `$__bf_item0`, an
247
+ * array-rest `(bf_slice $__bf_item0 1)`, a nested/index path
248
+ * `(index $__bf_item0.Cells 0)`). Innermost last. Lets `.map(({ id, ...rest
249
+ * })` / `.map(([k, v]) =>` / nested-path destructure resolve instead of
250
+ * BF104 (#2087 Phase B — see `buildDestructureBindingMap`). */
233
251
  private loopBindingStack: Array<Map<string, string>> = []
252
+ /**
253
+ * Stack of object-rest exclude-key maps, parallel to `loopBindingStack`
254
+ * (same push/pop points, innermost last). Only object-rest bindings appear
255
+ * here — keyed by binding name, each entry carries the PARENT accessor
256
+ * (same value as `loopBindingStack`'s entry for that name) plus the sibling
257
+ * keys the destructure pattern already pulled out. `emitSpread` consults
258
+ * this for a `{...rest}` spread onto an intrinsic element, so the residual
259
+ * omits exactly the keys the pattern destructured — member reads
260
+ * (`rest.flag`) don't need it and keep resolving through `loopBindingStack`
261
+ * alone (#2087 Phase B).
262
+ */
263
+ private loopRestExcludeStack: Array<Map<string, { parent: string; excludeKeys: string[] }>> = []
234
264
 
235
265
  /**
236
266
  * Cross-component child shapes, keyed by child component name. Populated via
@@ -272,7 +302,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
272
302
  this.state.currentMemos = ir.metadata.memos ?? []
273
303
  this.state.currentTypeDefinitions = ir.metadata.typeDefinitions ?? []
274
304
  this.state.contextConsumers = collectContextConsumers(ir.metadata)
275
- this.state.searchParamsLocals = searchParamsLocalNames(ir.metadata)
305
+ // Single authority (Package G): the plan already decided which signals are
306
+ // per-request env readers, in declaration order. `ir.metadata.ssrSeedPlan`
307
+ // may be absent for hand-built metadata (tests), hence the fallback to the
308
+ // same shared computation rather than a second, divergent derivation.
309
+ this.state.ssrSeedPlan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
310
+ this.state.envSignalReadersByLocal = new Map()
311
+ this.state.searchParamsLocals = new Set()
312
+ for (const step of this.state.ssrSeedPlan.steps) {
313
+ if (step.kind !== 'env-reader') continue
314
+ // `reader.methods` is a ReadonlySet — JSON round-tripping (adapter
315
+ // conformance harness) serializes it to `{}`, so re-resolve a live
316
+ // reader from the registry by key instead of trusting `step.reader`.
317
+ const reader = envSignalReaderFor(step.reader.key)
318
+ if (reader) this.state.envSignalReadersByLocal.set(step.name, reader)
319
+ if (step.reader.key === 'search') this.state.searchParamsLocals.add(step.name)
320
+ }
276
321
  this.state.loweringMatchers = prepareLoweringMatchers(ir.metadata)
277
322
  augmentInheritedPropAccesses(ir)
278
323
  }
@@ -458,9 +503,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
458
503
 
459
504
  /**
460
505
  * Register a child component's shape (see `childComponentShapes`). Call once
461
- * per known child IR before the parent's `generateTypes`. Idempotent.
506
+ * per known child IR before the parent's `generateTypes`. Idempotent. Takes
507
+ * only the IR's `metadata` so orchestrators that never build the full IR
508
+ * (the CLI's cross-file shape pre-pass, #2131) can register from a bare
509
+ * analyzer pass — a full `ComponentIR` still satisfies it structurally.
462
510
  */
463
- registerChildComponentShape(ir: ComponentIR): void {
511
+ registerChildComponentShape(ir: Pick<ComponentIR, 'metadata'>): void {
464
512
  const name = ir.metadata.componentName
465
513
  if (!name) return
466
514
  const paramNames = new Set((ir.metadata.propsParams ?? []).map(p => p.name))
@@ -490,10 +538,37 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
490
538
  return capitalizeFieldName(c.localName)
491
539
  }
492
540
 
541
+ /**
542
+ * True when `node` is (or is a member access rooted in) a `useContext`
543
+ * local with an object-shaped `createContext` default — see `member()`'s
544
+ * `bf_get` branch. Recurses through non-computed member chains: once a
545
+ * chain is rooted in a map, every further `.property` down the chain reads
546
+ * off an `interface{}` value with no static struct to fall back to, so it
547
+ * stays map-rooted for `bf_get` too.
548
+ */
549
+ private isMapRootedContextChain(node: ParsedExpr): boolean {
550
+ if (node.kind === 'identifier') {
551
+ return this.state.contextConsumers.some(
552
+ c => c.localName === node.name && c.defaultKind === 'object',
553
+ )
554
+ }
555
+ if (node.kind === 'member' && !node.computed) {
556
+ return this.isMapRootedContextChain(node.object)
557
+ }
558
+ return false
559
+ }
560
+
493
561
  /** Go type for a context-consumer field, from its `createContext` default's type. */
494
562
  private contextConsumerGoType(c: ContextConsumer): string {
495
563
  if (typeof c.defaultValue === 'number') return 'int'
496
564
  if (typeof c.defaultValue === 'boolean') return 'bool'
565
+ // An OBJECT-shaped default (`createContext<{ config: X }>({ config: {} })`,
566
+ // #2087) needs a real map type, not the scalar `string` fallback below: a
567
+ // descendant's `ctx.config.label` read lowers through `bf_get` (see
568
+ // `member()`), which needs an actual `map[string]interface{}` receiver to
569
+ // walk — a `string` field crashes real `go run` execution with `can't
570
+ // evaluate field Config in type string`.
571
+ if (c.defaultKind === 'object') return 'map[string]interface{}'
497
572
  return 'string'
498
573
  }
499
574
 
@@ -502,6 +577,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
502
577
  if (typeof c.defaultValue === 'number') return String(c.defaultValue)
503
578
  if (typeof c.defaultValue === 'boolean') return String(c.defaultValue)
504
579
  if (typeof c.defaultValue === 'string') return `"${escapeGoString(c.defaultValue)}"`
580
+ // Object-shaped default: a real (nil-safe) empty map — see
581
+ // `contextConsumerGoType`. The `createContext` argument's actual nested
582
+ // shape (`{ config: {} }`) is never baked key-for-key here: a consumer
583
+ // only ever reads this DEFAULT when no enclosing Provider ran, and
584
+ // `bf_get` (getFieldValue) already returns nil safely for any missing/
585
+ // absent key off an empty map, so `ctx.config.label ?? 'none'` resolves
586
+ // to `'none'` regardless of how deep the default's own nesting goes.
587
+ if (c.defaultKind === 'object') return 'map[string]interface{}{}'
505
588
  return '""'
506
589
  }
507
590
 
@@ -574,16 +657,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
574
657
  * analyzer's structured properties (no definition-string parsing). Both the
575
658
  * struct emitter and the object-literal baker consume it, so a baked literal
576
659
  * can't name a field the struct lacks. A non-Go-identifier source key
577
- * (`"data-id"`, a numeric key) is dropped here, so the baker bails to nil for
578
- * any literal using such a key.
660
+ * (`"data-priority"`, a numeric key) still gets a field via
661
+ * `goFieldNameForKey`'s segment-splitting PascalCase (#2087 Phase B —
662
+ * `rest-destructure-object-spread-in-map`'s `'data-priority'` residual key
663
+ * needs a real field to bake into, or the whole literal defers to nil); a
664
+ * dedup guard drops a later key that sanitizes to a Go name already taken
665
+ * (rare, but two fields can't share one Go identifier).
579
666
  */
580
667
  private structFieldsFor(td: TypeDefinition): Array<{ tsName: string; goName: string; goType: string }> {
581
668
  const fields: Array<{ tsName: string; goName: string; goType: string }> = []
669
+ const seenGoNames = new Set<string>()
582
670
  for (const prop of td.properties ?? []) {
583
- if (!GO_IDENTIFIER.test(prop.name)) continue
671
+ const goName = goFieldNameForKey(prop.name)
672
+ if (seenGoNames.has(goName)) continue
673
+ seenGoNames.add(goName)
584
674
  fields.push({
585
675
  tsName: prop.name,
586
- goName: capitalizeFieldName(prop.name),
676
+ goName,
587
677
  goType: typeInfoToGo(this.emitCtx, prop.type),
588
678
  })
589
679
  }
@@ -911,10 +1001,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
911
1001
  }
912
1002
 
913
1003
  /** Collect static child instances from loop body children for the wrapper struct. */
914
- private collectBodyChildInstances(bodyChildren: IRNode[]): StaticChildInstance[] {
1004
+ private collectBodyChildInstances(
1005
+ bodyChildren: IRNode[],
1006
+ propsParams: ReadonlyArray<{ name: string }> = [],
1007
+ ): StaticChildInstance[] {
915
1008
  const result: StaticChildInstance[] = []
916
1009
  for (const child of bodyChildren) {
917
- this.collectStaticChildInstancesRecursive(child, result, false, new Map())
1010
+ this.collectStaticChildInstancesRecursive(child, result, false, new Map(), propsParams)
918
1011
  }
919
1012
  return result
920
1013
  }
@@ -1000,6 +1093,38 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1000
1093
 
1001
1094
  this.emitDynamicBodyWrappers(lines, ir, componentName, dynamicWithBody, propFallbackVars, emittedWrapperVars)
1002
1095
 
1096
+ // Sibling-memo hoisting (#2075/#2077 review finding 3): a filter-arm memo
1097
+ // whose predicate free vars reference an EARLIER sibling memo would
1098
+ // otherwise recompute that sibling's whole expression a second time
1099
+ // inline in its `bf.FilterEval` env map, duplicating it against the
1100
+ // sibling's own field. Two-pass: first collect which memos are
1101
+ // referenced this way, then hoist each into a local emitted once before
1102
+ // the `return`; the memo-field loop below (and the referencing memo's own
1103
+ // env-map entry, via `ctx.state.hoistedMemoLocals`) both reuse the local
1104
+ // instead of re-emitting the expression. Declared `var name Type = value`
1105
+ // (not `:=`) because an unresolved computation can fall back to the bare
1106
+ // `nil` literal, which `:=` can't type-infer.
1107
+ const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1108
+ this.state.hoistedMemoLocals = new Map()
1109
+ const hoistNames = new Set<string>()
1110
+ for (const memo of ir.metadata.memos) {
1111
+ for (const name of filterArmEarlierSiblingRefs(this.emitCtx, memo, ir.metadata.signals, ir.metadata.propsParams)) {
1112
+ hoistNames.add(name)
1113
+ }
1114
+ }
1115
+ if (hoistNames.size > 0) {
1116
+ for (const memo of ir.metadata.memos) {
1117
+ if (!hoistNames.has(memo.name)) continue
1118
+ const goType = this.inferMemoType(memo, ir.metadata.signals, memoPropsParamMap)
1119
+ const value = computeMemoInitialValue(this.emitCtx, memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars, goType)
1120
+ let localName = `memo${capitalizeFieldName(memo.name)}`
1121
+ while (GO_KEYWORDS.has(localName)) localName += '_'
1122
+ lines.push(`\tvar ${localName} ${goType} = ${value}`)
1123
+ this.state.hoistedMemoLocals.set(memo.name, localName)
1124
+ }
1125
+ lines.push('')
1126
+ }
1127
+
1003
1128
  lines.push(`\treturn ${propsTypeName}{`)
1004
1129
  lines.push('\t\tScopeID: scopeID,')
1005
1130
  // Host context, for when *this* component is itself a slot-attached child.
@@ -1097,11 +1222,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1097
1222
  }
1098
1223
 
1099
1224
  // Memo initial values (from signal initials). Prop-shadowing memos were
1100
- // folded into the prop field above.
1101
- const memoPropsParamMap = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
1225
+ // folded into the prop field above; a memo the pre-pass already hoisted
1226
+ // (above) reuses that local instead of recomputing its expression.
1102
1227
  for (const memo of ir.metadata.memos) {
1103
1228
  const fieldName = capitalizeFieldName(memo.name)
1104
1229
  if (propFieldNames.has(fieldName)) continue
1230
+ const hoistedLocal = this.state.hoistedMemoLocals.get(memo.name)
1231
+ if (hoistedLocal) {
1232
+ lines.push(`\t\t${fieldName}: ${hoistedLocal},`)
1233
+ continue
1234
+ }
1105
1235
  // Pass the inferred Go type so an unresolved computation zeroes to that
1106
1236
  // type (`false` for a boolean memo), not the int `0`.
1107
1237
  const goType = this.inferMemoType(memo, ir.metadata.signals, memoPropsParamMap)
@@ -1130,8 +1260,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1130
1260
  for (const c of this.nonCollidingContextConsumers(takenInit)) {
1131
1261
  const field = this.contextFieldName(c)
1132
1262
  const def = this.contextConsumerGoDefault(c)
1263
+ // A `map[string]interface{}` field's Go zero value (nil) is already a
1264
+ // safely-readable "no value" (`bf_get` / `getFieldValue` treats a nil
1265
+ // map exactly like an empty one, per `reflect.Value.MapIndex`'s
1266
+ // documented nil-map behavior) — no `applyGoFallback` wrapper needed,
1267
+ // same as the other zero-value sentinels below.
1133
1268
  const defaulted =
1134
- c.defaultValue === null || def === '""' || def === '0' || def === 'false'
1269
+ c.defaultValue === null ||
1270
+ def === '""' ||
1271
+ def === '0' ||
1272
+ def === 'false' ||
1273
+ def === 'map[string]interface{}{}'
1135
1274
  ? `in.${field}`
1136
1275
  : applyGoFallback(`in.${field}`, def)
1137
1276
  lines.push(`\t\t${field}: ${defaulted},`)
@@ -1146,7 +1285,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1146
1285
  }
1147
1286
 
1148
1287
  private emitStaticChildInstances(lines: string[], ir: ComponentIR): void {
1149
- const staticChildren = this.collectStaticChildInstances(ir.root)
1288
+ const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
1150
1289
  for (const child of staticChildren) {
1151
1290
  lines.push(`\t\t${child.fieldName}: New${child.name}Props(${child.name}Input{`)
1152
1291
  lines.push(`\t\t\tScopeID: scopeID + "_${child.slotId}",`)
@@ -1353,7 +1492,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1353
1492
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1354
1493
  const wrapperType = this.loopBodyWrapperName(componentName, nested)
1355
1494
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
1356
- const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
1495
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!, ir.metadata.propsParams)
1357
1496
 
1358
1497
  for (const child of bodyChildInstances) {
1359
1498
  const childVar = `child_${child.fieldName}`
@@ -1447,7 +1586,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1447
1586
  const wrapperType = this.loopBodyWrapperName(componentName, nested)
1448
1587
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1449
1588
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
1450
- const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
1589
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!, ir.metadata.propsParams)
1451
1590
 
1452
1591
  // Child sub-component instances created once (identical scope IDs per row).
1453
1592
  for (const child of bodyChildInstances) {
@@ -1772,7 +1911,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1772
1911
  }
1773
1912
  }
1774
1913
 
1775
- const staticChildren = this.collectStaticChildInstances(ir.root)
1914
+ const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
1776
1915
  for (const child of staticChildren) {
1777
1916
  lines.push(`\t${child.fieldName} ${child.name}Props \`json:"-"\``)
1778
1917
  }
@@ -1796,9 +1935,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1796
1935
  * components inside loops (handled by nestedComponents). Each instance carries
1797
1936
  * its component `name`, `slotId`, `props`, and Go `fieldName`.
1798
1937
  */
1799
- private collectStaticChildInstances(node: IRNode): Array<StaticChildInstance> {
1938
+ private collectStaticChildInstances(
1939
+ node: IRNode,
1940
+ propsParams: ReadonlyArray<{ name: string }> = [],
1941
+ ): Array<StaticChildInstance> {
1800
1942
  const result: StaticChildInstance[] = []
1801
- this.collectStaticChildInstancesRecursive(node, result, false, new Map())
1943
+ this.collectStaticChildInstancesRecursive(node, result, false, new Map(), propsParams)
1802
1944
  return result
1803
1945
  }
1804
1946
 
@@ -1871,6 +2013,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1871
2013
  result: StaticChildInstance[],
1872
2014
  inLoop: boolean,
1873
2015
  providerCtx: ReadonlyMap<string, string>,
2016
+ propsParams: ReadonlyArray<{ name: string }> = [],
1874
2017
  ): void {
1875
2018
  if (node.type === 'component') {
1876
2019
  const comp = node as IRComponent
@@ -1880,7 +2023,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1880
2023
  // child components nested inside still get their slot fields.
1881
2024
  if (comp.dynamicTag) {
1882
2025
  for (const child of comp.children) {
1883
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2026
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1884
2027
  }
1885
2028
  return
1886
2029
  }
@@ -1912,86 +2055,208 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1912
2055
  // never contain components (any nested component renders a `{{template}}`
1913
2056
  // action, which the bake extractors reject), so recursing is a no-op.
1914
2057
  for (const child of effectiveChildren) {
1915
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2058
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1916
2059
  }
1917
2060
  }
1918
2061
  // Recurse into Portal's children to find nested components
1919
2062
  if (comp.name === 'Portal' && comp.children) {
1920
2063
  for (const child of comp.children) {
1921
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2064
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1922
2065
  }
1923
2066
  }
1924
2067
  } else if (node.type === 'loop') {
1925
2068
  const loop = node as IRLoop
1926
- // Mark children as inside loop
2069
+ // A loop whose body IS a single component is the nestedComponents /
2070
+ // wrapper-slice machinery's case — mark its subtree as in-loop so the
2071
+ // component (and its body children, which live on the wrapper struct)
2072
+ // are skipped here. A component merely nested inside an element item
2073
+ // (`<li><Badge/></li>`, #2130) gets NO wrapper slice: keep collecting,
2074
+ // so it registers a normal once-per-slot instance (shared across rows,
2075
+ // like the wrapper's `bodyChildInstances`); per-item content reaches it
2076
+ // through the loop-body children define (see `renderComponent`).
1927
2077
  for (const child of loop.children) {
1928
- this.collectStaticChildInstancesRecursive(child, result, true, providerCtx)
2078
+ this.collectStaticChildInstancesRecursive(
2079
+ child,
2080
+ result,
2081
+ inLoop || !!loop.childComponent,
2082
+ providerCtx,
2083
+ propsParams,
2084
+ )
1929
2085
  }
1930
2086
  } else if (node.type === 'element') {
1931
2087
  const element = node as IRElement
1932
2088
  for (const child of element.children) {
1933
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2089
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1934
2090
  }
1935
2091
  } else if (node.type === 'fragment') {
1936
2092
  const fragment = node as IRFragment
1937
2093
  for (const child of fragment.children) {
1938
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2094
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1939
2095
  }
1940
2096
  } else if (node.type === 'conditional') {
1941
2097
  const cond = node as IRConditional
1942
- this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop, providerCtx)
2098
+ this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop, providerCtx, propsParams)
1943
2099
  if (cond.whenFalse) {
1944
- this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx)
2100
+ this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx, propsParams)
1945
2101
  }
1946
2102
  } else if (node.type === 'if-statement') {
1947
2103
  // An early-return if-statement root (e.g. an asChild split) keeps its
1948
2104
  // subtrees in consequent/alternate — the non-asChild branch's nested icon
1949
2105
  // needs its slot field like any other static child.
1950
2106
  const stmt = node as IRIfStatement
1951
- this.collectStaticChildInstancesRecursive(stmt.consequent, result, inLoop, providerCtx)
2107
+ this.collectStaticChildInstancesRecursive(stmt.consequent, result, inLoop, providerCtx, propsParams)
1952
2108
  if (stmt.alternate) {
1953
- this.collectStaticChildInstancesRecursive(stmt.alternate, result, inLoop, providerCtx)
2109
+ this.collectStaticChildInstancesRecursive(stmt.alternate, result, inLoop, providerCtx, propsParams)
1954
2110
  }
1955
2111
  } else if (node.type === 'provider') {
1956
2112
  // SSR context propagation: record the provider's value against its context
1957
2113
  // name and extend the active binding map for descendants. A literal value
1958
- // lowers to a Go literal; a non-literal is left unbound (the consumer
1959
- // keeps its default).
2114
+ // lowers to a Go literal; an object-literal value lowers to a Go map when
2115
+ // every member is a supported shape (#2087 — see `extendProviderContext`);
2116
+ // anything else is left unbound (the consumer keeps its default).
1960
2117
  const p = node as IRProvider
1961
- const childCtx = this.extendProviderContext(providerCtx, p)
2118
+ const childCtx = this.extendProviderContext(providerCtx, p, propsParams)
1962
2119
  for (const child of p.children) {
1963
- this.collectStaticChildInstancesRecursive(child, result, inLoop, childCtx)
2120
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, childCtx, propsParams)
1964
2121
  }
1965
2122
  } else if (node.type === 'async') {
1966
2123
  // Async fallback + children render server-side via the OOS
1967
2124
  // protocol; static child components inside them still need slot
1968
2125
  // fields on the parent struct.
1969
2126
  const a = node as IRAsync
1970
- this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop, providerCtx)
2127
+ this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop, providerCtx, propsParams)
1971
2128
  for (const child of a.children) {
1972
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2129
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1973
2130
  }
1974
2131
  }
1975
2132
  }
1976
2133
 
1977
2134
  /**
1978
2135
  * Extend the active provider-context map with one `<Ctx.Provider value>`. A
1979
- * string/number/boolean literal value is lowered to a Go literal; any other
1980
- * shape is skipped (the descendant consumer keeps its `createContext` default).
2136
+ * string/number/boolean literal value is lowered to a Go literal. An
2137
+ * OBJECT-LITERAL value (#2087's chart shape `value={{ config: props.config
2138
+ * ?? {} }}`) lowers to a `map[string]interface{}` Go expression via
2139
+ * `providerObjectValueToGoMap` when every member is a supported shape; any
2140
+ * other shape (including a partially-supported object literal) is skipped —
2141
+ * the descendant consumer keeps its `createContext` default, unchanged from
2142
+ * before this method understood objects at all.
1981
2143
  */
1982
2144
  private extendProviderContext(
1983
2145
  current: ReadonlyMap<string, string>,
1984
2146
  p: IRProvider,
2147
+ propsParams: ReadonlyArray<{ name: string }>,
1985
2148
  ): ReadonlyMap<string, string> {
1986
- const v = p.valueProp?.value as { kind?: string; value?: unknown } | undefined
1987
- if (!v || v.kind !== 'literal') return current
1988
- let goLit: string | null = null
1989
- if (typeof v.value === 'string') goLit = `"${escapeGoString(v.value)}"`
1990
- else if (typeof v.value === 'number' || typeof v.value === 'boolean') goLit = String(v.value)
1991
- if (goLit === null) return current
1992
- const next = new Map(current)
1993
- next.set(p.contextName, goLit)
1994
- return next
2149
+ const v = p.valueProp?.value as
2150
+ | { kind?: string; value?: unknown; parsed?: ParsedExpr }
2151
+ | undefined
2152
+ if (!v) return current
2153
+ if (v.kind === 'literal') {
2154
+ let goLit: string | null = null
2155
+ if (typeof v.value === 'string') goLit = `"${escapeGoString(v.value)}"`
2156
+ else if (typeof v.value === 'number' || typeof v.value === 'boolean') goLit = String(v.value)
2157
+ if (goLit === null) return current
2158
+ const next = new Map(current)
2159
+ next.set(p.contextName, goLit)
2160
+ return next
2161
+ }
2162
+ if (v.kind === 'expression' && v.parsed) {
2163
+ const goMap = this.providerObjectValueToGoMap(v.parsed, propsParams)
2164
+ if (goMap !== null) {
2165
+ const next = new Map(current)
2166
+ next.set(p.contextName, goMap)
2167
+ return next
2168
+ }
2169
+ }
2170
+ return current
2171
+ }
2172
+
2173
+ /**
2174
+ * Lower a `<Ctx.Provider value={{ … }}>` object literal to a Go
2175
+ * `map[string]interface{}{…}` expression, for binding into a static child's
2176
+ * context-consumer field (`emitStaticChildInstances`). Keys keep their
2177
+ * SOURCE (JS-cased) names — the consumer reads them back via `bf_get`
2178
+ * (`member()`), which is case-tolerant (`getFieldValue`, bf.go), so casing
2179
+ * doesn't have to match a capitalized Go field.
2180
+ *
2181
+ * Every member must lower through `lowerProviderMapMemberValue` for the
2182
+ * WHOLE object to lower — a single unsupported member (a getter, a
2183
+ * function, an expression outside the narrow surface below) fails the
2184
+ * whole value and returns `null`, exactly like the pre-#2087 refusal (the
2185
+ * consumer then keeps its `createContext` default).
2186
+ */
2187
+ private providerObjectValueToGoMap(
2188
+ parsed: ParsedExpr,
2189
+ propsParams: ReadonlyArray<{ name: string }>,
2190
+ ): string | null {
2191
+ if (parsed.kind !== 'object-literal') return null
2192
+ const entries: string[] = []
2193
+ for (const prop of parsed.properties) {
2194
+ if (prop.shorthand) return null
2195
+ const goVal = this.lowerProviderMapMemberValue(prop.value, propsParams)
2196
+ if (goVal === null) return null
2197
+ entries.push(`${JSON.stringify(prop.key)}: ${goVal}`)
2198
+ }
2199
+ if (entries.length === 0) return null
2200
+ return `map[string]interface{}{${entries.join(', ')}}`
2201
+ }
2202
+
2203
+ /**
2204
+ * Lower one member VALUE of a provider object literal to a Go expression.
2205
+ * Supports:
2206
+ * - a pure literal / nested literal array-or-object (same machinery
2207
+ * `objectLiteralToGoMap` uses for an inline object passed to a child's
2208
+ * optional object prop);
2209
+ * - `props.<X> ?? {}` (#2087's exact chart shape) — an optional prop with
2210
+ * an empty-object fallback. The referenced prop's OWN Go field is
2211
+ * `interface{}` (a `Record<string, X>` type-alias prop never becomes a
2212
+ * named Go struct — see `typeInfoToGo`'s `interface` case — so it can't
2213
+ * be typed `map[string]interface{}` without risking an unrelated
2214
+ * `ui/compat.lock.json` diff across every other `interface{}`-typed
2215
+ * prop). Recovering the caller-supplied map goes through the runtime's
2216
+ * `bf.AsMap` normalizer rather than a bare
2217
+ * `.(map[string]interface{})` type assertion: an `interface{}` field
2218
+ * can legally hold ANY string-keyed map kind — a Go handler modelling
2219
+ * `Record<string, string>` naturally passes `map[string]string` — and
2220
+ * the single-type assertion would silently drop those values (#2111
2221
+ * review). `bf.AsMap` returns nil for nil / typed-nil / non-map
2222
+ * values, so the emitted fallback still lands on a real, empty map,
2223
+ * matching JS `??`'s "assign the real value when present, else `{}`"
2224
+ * semantics.
2225
+ * Anything else (a getter/callback member, an unresolvable expression)
2226
+ * returns `null`, failing the WHOLE containing object (see
2227
+ * `providerObjectValueToGoMap`).
2228
+ */
2229
+ private lowerProviderMapMemberValue(
2230
+ node: ParsedExpr,
2231
+ propsParams: ReadonlyArray<{ name: string }>,
2232
+ ): string | null {
2233
+ if (node.kind === 'object-literal') return objectLiteralToGoMap(this.emitCtx, node)
2234
+ const literal = parsedLiteralToGo(this.emitCtx, node)
2235
+ if (literal !== null) return literal
2236
+ if (
2237
+ node.kind === 'logical' &&
2238
+ node.op === '??' &&
2239
+ node.right.kind === 'object-literal' &&
2240
+ node.right.properties.length === 0 &&
2241
+ node.left.kind === 'member' &&
2242
+ !node.left.computed &&
2243
+ node.left.object.kind === 'identifier' &&
2244
+ node.left.object.name === this.state.propsObjectName
2245
+ ) {
2246
+ // Bound to a local so the narrowed `member` shape survives into the
2247
+ // closure below — TS drops property-path narrowing (`node.left.kind
2248
+ // === 'member'`) across a nested arrow function boundary.
2249
+ const propName = node.left.property
2250
+ if (propsParams.some(param => param.name === propName)) {
2251
+ const fieldRef = `in.${capitalizeFieldName(propName)}`
2252
+ return (
2253
+ `func() map[string]interface{} { ` +
2254
+ `if m := bf.AsMap(${fieldRef}); m != nil { return m }; ` +
2255
+ `return map[string]interface{}{} }()`
2256
+ )
2257
+ }
2258
+ }
2259
+ return null
1995
2260
  }
1996
2261
 
1997
2262
  /**
@@ -2095,10 +2360,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2095
2360
  }
2096
2361
 
2097
2362
  // A memo: when no pattern applies, return null so the caller OMITS the
2098
- // field and Go's typed zero value applies.
2363
+ // field and Go's typed zero value applies. Seed the resolution stack
2364
+ // with this memo's own name — a fresh top-level computation, so
2365
+ // self-reference must be caught on the first recursion.
2099
2366
  const memo = memos.find(m => m.name === getterName)
2100
2367
  if (memo) {
2101
- return computeMemoInitialValueOrNull(this.emitCtx, memo, signals, propsParams)
2368
+ return computeMemoInitialValueOrNull(
2369
+ this.emitCtx, memo, signals, propsParams, undefined, new Set([memo.name]),
2370
+ )
2102
2371
  }
2103
2372
  }
2104
2373
 
@@ -2111,6 +2380,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2111
2380
  signals: { getter: string; initialValue: string; type: TypeInfo }[],
2112
2381
  propsParamMap: Map<string, { name: string; type: TypeInfo; defaultValue?: string }>
2113
2382
  ): string {
2383
+ // A LIST-valued `.filter(arrow)` memo (#2075 — the blog PostList `visible`
2384
+ // shape) is a slice of the receiver's boxed elements, not a scalar.
2385
+ // Decided FIRST, ahead of the memo's declared `type` and every other
2386
+ // heuristic below: the analyzer's simple per-memo type inference doesn't
2387
+ // model `.filter`'s predicate shape and can land on a bogus primitive
2388
+ // (observed: `boolean`, from the predicate's own `!`/`||` structure),
2389
+ // which would otherwise sail through the `typeInfoToGo(memo.type) ===
2390
+ // 'interface{}'` gates below unchallenged. `bf.FilterEval` (the SSR
2391
+ // constructor lowering, memo-compute.ts) returns `[]any`; the template's
2392
+ // `range` / reflective field access handle the boxed elements the same
2393
+ // way it already handles other `interface{}`-typed slices.
2394
+ if (isListFilterMemo(memo)) return '[]any'
2395
+
2114
2396
  // A template-literal memo always produces a string. Decide this first so a
2115
2397
  // class-string `/` (e.g. `ring-ring/50`) doesn't trip the arithmetic
2116
2398
  // heuristic below into `int`. The analyzer classified the body shape from
@@ -2775,10 +3057,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2775
3057
  if (objHO && (objHO.method === 'find' || objHO.method === 'findLast')) {
2776
3058
  const findResult = this.renderHigherOrderExpr(objHO, emit)
2777
3059
  if (findResult) {
2778
- return `{{with ${findResult}}}{{.${capitalizeFieldName(property)}}}{{end}}`
3060
+ return `{{with ${findResult}}}{{.${goFieldNameForKey(property)}}}{{end}}`
2779
3061
  }
2780
3062
  const templateBlock = this.renderFindTemplateBlock(
2781
- objHO, emit, capitalizeFieldName(property),
3063
+ objHO, emit, goFieldNameForKey(property),
2782
3064
  )
2783
3065
  if (templateBlock) return templateBlock
2784
3066
  }
@@ -2789,6 +3071,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2789
3071
  return this.rootFieldRef(property)
2790
3072
  }
2791
3073
 
3074
+ // A member chain rooted in a `useContext` local whose `createContext`
3075
+ // default is object-shaped (#2087 — `defaultKind === 'object'`) reads off
3076
+ // a `map[string]interface{}` field, not a struct: plain Go template dot
3077
+ // access (`.Ctx.Config`) does an EXACT-string `MapIndex`, so it would
3078
+ // only resolve a capitalized key the provider never bakes (see
3079
+ // `providerObjectValueToGoMap`, which keeps SOURCE/JS-cased keys). Route
3080
+ // through `bf_get` (the runtime's case-tolerant `getFieldValue`, bf.go)
3081
+ // instead, recursively — once a chain is map-rooted every further
3082
+ // `.property` is ALSO opaque (the map's value type is `interface{}`, so
3083
+ // there's no static struct to fall back to), hence checking the object
3084
+ // rather than just the top-level identifier.
3085
+ if (this.isMapRootedContextChain(object)) {
3086
+ const objGo = emit(object)
3087
+ return `bf_get ${wrapIfMultiToken(objGo)} ${JSON.stringify(property)}`
3088
+ }
3089
+
2792
3090
  // Static property access on a module object-literal const
2793
3091
  // (`variantClasses.ghost` in a class template literal) resolves at compile
2794
3092
  // time — same lookup as the bracket-index form in
@@ -2803,14 +3101,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2803
3101
 
2804
3102
  // Inside a loop, the loop param variable refers to the current item
2805
3103
  // (dot). e.g. `msg.role` inside `{{range $_, $msg := .Messages}}` → `.Role`
3104
+ //
3105
+ // Field names route through `goFieldNameForKey` — the same sanitizer the
3106
+ // struct/map bake side uses — NOT bare `capitalizeFieldName`: a
3107
+ // non-identifier property (`meta["data-x"]`, parsed as computed member
3108
+ // access) would otherwise emit `.Data-x`, which is not even valid Go
3109
+ // template syntax, let alone a reachable field (PR #2089 review). For
3110
+ // identifier keys (snake_case included) the two functions agree, so
3111
+ // nothing previously reachable changes shape.
2806
3112
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
2807
3113
  if (object.kind === 'identifier' && currentLoopParam && object.name === currentLoopParam) {
2808
- return `.${capitalizeFieldName(property)}`
3114
+ return `.${goFieldNameForKey(property)}`
2809
3115
  }
2810
3116
 
2811
3117
  const obj = emit(object)
2812
3118
  if (property === 'length') return `len ${obj}`
2813
- return `${obj}.${capitalizeFieldName(property)}`
3119
+ return `${obj}.${goFieldNameForKey(property)}`
2814
3120
  }
2815
3121
 
2816
3122
  indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
@@ -2957,11 +3263,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2957
3263
  }
2958
3264
 
2959
3265
  objectLiteral(_properties: ObjectLiteralProperty[], raw: string, _emit: (e: ParsedExpr) => string): string {
2960
- // Not yet lowered structurally from `properties`: route through the
2961
- // `unsupported` path (`[UNSUPPORTED: …]`). Object values that DO reach a Go
2962
- // map today (signal/const inits, spread bags) go through the dedicated
3266
+ // The shared `isSupported` gate now admits an EMPTY object literal
3267
+ // (`?? {}`) as `??`'s right operand (expression-parser.ts, `logical`
3268
+ // case) every other adapter has a native `{}` dict/hashref literal to
3269
+ // emit here, but `text/template` has none, so this dispatcher is the
3270
+ // one place left to refuse the shape. Unlike the sibling adapters, Go
3271
+ // can't silently fall back to a safe sentinel text: `this.unsupported`'s
3272
+ // `[UNSUPPORTED: …]` marker would be spliced into a Go template action
3273
+ // (e.g. as an `or`/`and` operand) and break template parsing, and the
3274
+ // shared gate no longer reports BF101 for this shape itself (it now
3275
+ // considers `x ?? {}` supported). So self-report BF101 here — mirroring
3276
+ // the other self-contained refusals in this file (`pushCallbackBF101`,
3277
+ // `refuseFilterExprNode`) — and return the safe `""` string sentinel,
3278
+ // which is always a valid Go template value in every position `??`'s
3279
+ // result can land in. Object values that DO reach a Go map today
3280
+ // (signal/const inits, spread bags) go through the dedicated
2963
3281
  // `objectLiteralToGoMap` lowering, not here.
2964
- return this.unsupported(raw, 'object literal')
3282
+ // `raw` is the whole top-level expression's source text (threaded
3283
+ // unchanged through `convertNode`, same convention every `unsupported`
3284
+ // node relies on for its diagnostic) — e.g. `props.config ?? {}`, not
3285
+ // just the `{}` sub-node — so it reads naturally in the message below.
3286
+ this.state.errors.push({
3287
+ code: 'BF101',
3288
+ severity: 'error',
3289
+ message: `Expression not supported: ${raw}`,
3290
+ loc: this.makeLoc(),
3291
+ suggestion: {
3292
+ message: `Go templates have no object/map literal syntax, so the \`?? {}\` fallback can't render server-side. ${GO_REMEDIATION_OPTIONS}`,
3293
+ },
3294
+ })
3295
+ return `""`
2965
3296
  }
2966
3297
 
2967
3298
  /** Set of predicate (boolean-callback) higher-order methods. */
@@ -3077,6 +3408,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3077
3408
  return this.pushCallbackBF101(method, true)
3078
3409
  }
3079
3410
 
3411
+ // Value-producing `.map(cb)` (#2073): eval-only. (The JSX-returning
3412
+ // `.map` is an IRLoop upstream and never reaches this dispatch.)
3413
+ if (method === 'map') {
3414
+ const evalForm = emitMapEval(recv, body, params[0] ?? '_', emit)
3415
+ if (evalForm !== null) return evalForm
3416
+ return this.pushCallbackBF101(method, true)
3417
+ }
3418
+
3080
3419
  return this.pushCallbackBF101(method, true)
3081
3420
  }
3082
3421
 
@@ -3249,7 +3588,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3249
3588
  }
3250
3589
  }
3251
3590
 
3252
- flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
3591
+ flatMethod(
3592
+ object: ParsedExpr,
3593
+ depth: FlatDepth | { expr: ParsedExpr },
3594
+ emit: (e: ParsedExpr) => string,
3595
+ ): string {
3596
+ if (typeof depth === 'object') {
3597
+ // Dynamic depth (#2094) → `bf_flat_dynamic <recv> <renderedDepthExpr>`,
3598
+ // a SEPARATE runtime helper from `bf_flat` below. `bf_flat`'s `-1`
3599
+ // argument is a compile-time SENTINEL meaning "flatten fully"
3600
+ // (`Infinity` normalised at parse time); a genuinely dynamic render-time
3601
+ // value of `-1` means the JS-correct OPPOSITE (no flatten — negative
3602
+ // depth never recurses). Reusing `bf_flat` for both would silently
3603
+ // invert that case, so the dynamic form routes through `FlatDynamicDepth`
3604
+ // (`bf.go`), which performs full `ToIntegerOrInfinity` coercion from
3605
+ // scratch on whatever value the rendered expression evaluates to.
3606
+ return `bf_flat_dynamic ${wrapIfMultiToken(emit(object))} ${wrapIfMultiToken(emit(depth.expr))}`
3607
+ }
3253
3608
  // `.flat(depth?)` → `bf_flat <recv> <depth>`. `Infinity` lowers to the `-1`
3254
3609
  // sentinel (flatten fully); a finite depth flattens that many levels
3255
3610
  // (`0` = shallow copy).
@@ -3657,6 +4012,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3657
4012
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
3658
4013
  return `$.${capitalizeFieldName(expr.callee.name)}`
3659
4014
  }
4015
+ // A nested callback method call (`other.some(r => …)`) reaching this
4016
+ // arm has no Go template form in filter context — the fallthrough
4017
+ // below renders only the callee and silently DROPS the arrow argument,
4018
+ // changing predicate semantics (#2038). The one faithful nested shape
4019
+ // (`.filter(cb).length` → `len (bf_filter_eval …)`) is intercepted at
4020
+ // the `member` arm before this node is visited; everything else must
4021
+ // be loud.
4022
+ if (asCallbackMethodCall(expr) !== null) {
4023
+ return this.refuseFilterExprNode(expr)
4024
+ }
3660
4025
  const result = this.renderFilterExpr(expr.callee, param, localVarMap)
3661
4026
  if (this.filterExprUnsupported) return 'false'
3662
4027
  return result
@@ -3721,31 +4086,37 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3721
4086
  return `or (${left}) (${right})`
3722
4087
  }
3723
4088
 
3724
- default: {
3725
- // The filter predicate body contains a node kind we can't lower to a Go
3726
- // template action most commonly a nested higher-order
3727
- // (`x => x.tags.filter(...).length > 0`). Surface BF101 with the
3728
- // offending expression. Set the recursion-wide `filterExprUnsupported`
3729
- // flag so parent branches return `false` instead of wrapping the
3730
- // sentinel into `false.Length` / `gt false.Length 0` etc.: the build
3731
- // fails on BF101 anyway, but the emitted template must stay
3732
- // syntactically valid so `text/template` parsing doesn't cascade into
3733
- // confusing secondary errors.
3734
- this.filterExprUnsupported = true
3735
- this.state.errors.push({
3736
- code: 'BF101',
3737
- severity: 'error',
3738
- message: `Filter predicate contains an expression that cannot be lowered to a Go template action: ${exprToString(expr)}`,
3739
- loc: this.makeLoc(),
3740
- suggestion: {
3741
- message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Rewrite the predicate to avoid nested higher-order methods (`.filter()` / `.map()` / etc. inside the predicate body)',
3742
- },
3743
- })
3744
- return 'false'
3745
- }
4089
+ default:
4090
+ // The filter predicate body contains a node kind we can't lower to a
4091
+ // Go template action. Shared refusal with the nested-callback guard in
4092
+ // the `call` arm (#2038).
4093
+ return this.refuseFilterExprNode(expr)
3746
4094
  }
3747
4095
  }
3748
4096
 
4097
+ /**
4098
+ * Refuse a filter-predicate node that has no Go template lowering. Surfaces
4099
+ * BF101 with the offending expression and sets the recursion-wide
4100
+ * `filterExprUnsupported` flag so parent branches return `false` instead of
4101
+ * wrapping the sentinel into `false.Length` / `gt false.Length 0` etc.: the
4102
+ * build fails on BF101 anyway, but the emitted template must stay
4103
+ * syntactically valid so `text/template` parsing doesn't cascade into
4104
+ * confusing secondary errors.
4105
+ */
4106
+ private refuseFilterExprNode(expr: ParsedExpr): string {
4107
+ this.filterExprUnsupported = true
4108
+ this.state.errors.push({
4109
+ code: 'BF101',
4110
+ severity: 'error',
4111
+ message: `Filter predicate contains an expression that cannot be lowered to a Go template action: ${exprToString(expr)}`,
4112
+ loc: this.makeLoc(),
4113
+ suggestion: {
4114
+ message: 'Options:\n1. Use /* @client */ for client-side evaluation\n2. Rewrite the predicate to avoid nested higher-order methods (`.filter()` / `.map()` / etc. inside the predicate body)',
4115
+ },
4116
+ })
4117
+ return 'false'
4118
+ }
4119
+
3749
4120
  /**
3750
4121
  * Whether a ParsedExpr renders as a Go template function call (so it needs
3751
4122
  * parentheses around it).
@@ -4319,20 +4690,78 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4319
4690
  }
4320
4691
 
4321
4692
  /**
4322
- * Map each destructure binding to its Go accessor on the range var: a named
4323
- * binding `$<rangeVar>.<Field>`, an object-rest binding the bare
4324
- * `$<rangeVar>` so the member emitter renders `rest.flag` `$<rangeVar>.Flag`.
4693
+ * Walk a destructure binding's structured `segments` path (#2087 Phase A) into
4694
+ * a Go accessor over `base`. A `field` step appends `.<GoFieldName>` — dot
4695
+ * access resolves against a struct field OR a map key (Go template's
4696
+ * `evalField` supports both), and `goFieldNameForKey` produces a valid Go
4697
+ * identifier regardless of whether the SOURCE key was one (`cells` → `Cells`,
4698
+ * `data-priority` → `DataPriority`) — Go dot-syntax only requires the EMITTED
4699
+ * name to be a legal identifier, not the original JS key. An `index` step
4700
+ * has no dot-notation form, so it wraps in Go's `index` builtin
4701
+ * (`(index <acc> N)`); the wrap also parenthesises the whole accessor so a
4702
+ * following step or an outer composition (`len (...)`) can't swallow it as
4703
+ * extra call arguments.
4325
4704
  */
4326
- private buildDestructureBindingMap(loop: IRLoop, rangeVar: string): Map<string, string> {
4327
- const m = new Map<string, string>()
4705
+ private buildSegmentAccessor(base: string, segments: readonly LoopBindingPathSegment[]): string {
4706
+ let acc = base
4707
+ for (const seg of segments) {
4708
+ acc = seg.kind === 'field' ? `${acc}.${goFieldNameForKey(seg.key)}` : `(index ${acc} ${seg.index})`
4709
+ }
4710
+ return acc
4711
+ }
4712
+
4713
+ /**
4714
+ * Map each destructure binding to its Go accessor on the range var (#2087
4715
+ * Phase B — `isLowerableLoopDestructure` admits every shape below):
4716
+ *
4717
+ * - fixed binding (any depth): the FULL `segments` path over the range var
4718
+ * (`id` → `$item.Id`, `head` in `cells: [head]` → `(index $item.Cells 0)`,
4719
+ * `k` in `[k, v]` → `(index $item 0)`).
4720
+ * - array-rest (`[first, ...tail]`): the PARENT `segments` prefix wrapped in
4721
+ * `bf_slice` (`tail` → `(bf_slice $item 1)`) — this IS the binding's whole
4722
+ * value, so it composes correctly both bare and under `.length` (`len
4723
+ * (bf_slice $item 1)`, via the `member()` emitter's generic `len <obj>`
4724
+ * arm).
4725
+ * - object-rest (`{ id, ...rest }`): the bare PARENT accessor, so a member
4726
+ * read (`rest.flag`) renders `$item.Flag` — the simplest lowering that
4727
+ * keeps the already-green `rest-destructure-object-in-map` fixture byte-
4728
+ * exact. A `{...rest}` SPREAD needs the residual (item minus the
4729
+ * destructured siblings), which this map alone can't express; that case
4730
+ * is tracked separately in the returned `restExcludes` map and consumed
4731
+ * by `emitSpread`'s `bf_omit` lowering, not through this binding value.
4732
+ */
4733
+ private buildDestructureBindingMap(
4734
+ loop: IRLoop,
4735
+ rangeVar: string,
4736
+ ): {
4737
+ bindings: Map<string, string>
4738
+ restExcludes: Map<string, { parent: string; excludeKeys: string[] }>
4739
+ } {
4740
+ const bindings = new Map<string, string>()
4741
+ const restExcludes = new Map<string, { parent: string; excludeKeys: string[] }>()
4742
+ const base = `$${rangeVar}`
4328
4743
  for (const b of loop.paramBindings ?? []) {
4329
- if (b.rest) {
4330
- m.set(b.name, `$${rangeVar}`)
4744
+ const parent = this.buildSegmentAccessor(base, b.segments ?? [])
4745
+ if (!b.rest) {
4746
+ bindings.set(b.name, parent)
4747
+ } else if (b.rest.kind === 'array') {
4748
+ bindings.set(b.name, `(bf_slice ${parent} ${b.rest.from})`)
4331
4749
  } else {
4332
- m.set(b.name, `$${rangeVar}.${capitalizeFieldName(b.path.slice(1))}`)
4750
+ bindings.set(b.name, parent)
4751
+ restExcludes.set(b.name, { parent, excludeKeys: b.rest.exclude.map(k => k.key) })
4333
4752
  }
4334
4753
  }
4335
- return m
4754
+ return { bindings, restExcludes }
4755
+ }
4756
+
4757
+ /** Innermost-first lookup mirroring `loopBindingStack`'s search in
4758
+ * `identifier()`, but over the object-rest exclude-key side table. */
4759
+ private lookupRestExclude(name: string): { parent: string; excludeKeys: string[] } | undefined {
4760
+ for (let i = this.loopRestExcludeStack.length - 1; i >= 0; i--) {
4761
+ const info = this.loopRestExcludeStack[i].get(name)
4762
+ if (info) return info
4763
+ }
4764
+ return undefined
4336
4765
  }
4337
4766
 
4338
4767
  renderLoop(loop: IRLoop): string {
@@ -4351,15 +4780,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4351
4780
  //
4352
4781
  // Check the IR's structured `paramBindings` field rather than string-matching
4353
4782
  // `loop.param`: Phase 1 populates it iff the param is a destructure pattern,
4354
- // and the structured check is robust to formatting variants. A destructure
4355
- // param is lowerable only for the object-rest / simple-field shape
4356
- // (`.map(({ id, title, ...rest }) => …)`, `rest` read via member access):
4357
- // each binding resolves to a field on a named range var (`$__bf_item0.Id`,
4358
- // `rest.flag` → `$__bf_item0.Flag`). Array-index / nested / rest-spread
4359
- // shapes (`[a, ...t]`, `{ cells: [h] }`, `{...rest}`) still need machinery
4360
- // Go's `{{range}}` can't express inline → BF104.
4783
+ // and the structured check is robust to formatting variants. `isLowerableLoopDestructure`
4784
+ // (#2087 Phase A/B) admits fixed bindings at ANY depth (`.field`, array-index,
4785
+ // nested combinations of either `buildSegmentAccessor` walks the structured
4786
+ // `segments` path), array-rest (`[a, ...t]` `bf_slice`), and object-rest
4787
+ // whose every use is a member read (`rest.flag`) or a `{...rest}` spread onto
4788
+ // an intrinsic element (`bf_omit`, see `emitSpread`). Still refused (BF104):
4789
+ // a bare-value object-rest use (`String(rest)`, `{rest}`, spread onto a
4790
+ // component), and a `.filter().map(destructure)` chain — those need either the
4791
+ // actual residual object or a filter-param retarget this lowering doesn't do.
4361
4792
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
4362
- const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop)
4793
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
4363
4794
  if (destructure && !supportableDestructure) {
4364
4795
  this.state.errors.push({
4365
4796
  code: 'BF104',
@@ -4376,6 +4807,42 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4376
4807
  })
4377
4808
  }
4378
4809
 
4810
+ // A loop array that's a bare reference to a FUNCTION-SCOPE local const with
4811
+ // a computed initializer (`const entries = Object.entries(props.x ??
4812
+ // {}).filter(...)`) can't be bound as a Go template value: module-scope
4813
+ // consts (`isModule`) are a different, already-working case (statically
4814
+ // evaluated and seeded into the render context elsewhere), but a
4815
+ // function-scope local only reaches the template at all via
4816
+ // `computeDerivedConstFields`'s STRING-typed derived-const lowering
4817
+ // (`isStringExpr`) — an array-typed initializer like this one never
4818
+ // qualifies, so `identifier()` would still emit `.Entries` (the naive
4819
+ // `rootFieldRef` fallback) while `generateTypes` emits no such field.
4820
+ // `html/template` resolves struct fields dynamically at EXECUTE time, not
4821
+ // at Go compile time, so left unchecked this fails loudly only when the
4822
+ // template actually runs (`can't evaluate field Entries in type
4823
+ // ...Props`) instead of at build time. Pre-existing, general limitation,
4824
+ // orthogonal to #2087's destructure-binding work — newly reachable in this
4825
+ // adapter's test corpus only because the widened destructure gate (#2087
4826
+ // Phase A/B) no longer refuses `static-array-from-props`'s `([emoji,
4827
+ // users]) => ...` param first. Cross-adapter policy: Jinja / ERB apply the
4828
+ // same narrow check in their own `renderLoop` (see `jinja-adapter.ts`).
4829
+ const arrayName = loop.array.trim()
4830
+ if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
4831
+ const arrayConst = this.state.localConstants.find(c => c.name === arrayName)
4832
+ if (arrayConst && !arrayConst.isModule && arrayConst.parsed && !this.isStringExpr(arrayConst.parsed, new Set())) {
4833
+ this.state.errors.push({
4834
+ code: 'BF101',
4835
+ severity: 'error',
4836
+ message: `Loop array \`${arrayName}\` is a local computed value (\`${arrayConst.value}\`) that the Go template adapter cannot bind as a template variable — only a string-derived local resolves to a generated struct field.`,
4837
+ loc: loop.loc ?? this.makeLoc(),
4838
+ suggestion: {
4839
+ message:
4840
+ 'Pre-compute the array server-side and pass it as a prop, or mark the loop position as @client-only so it runs in JS on the client.',
4841
+ },
4842
+ })
4843
+ }
4844
+ }
4845
+
4379
4846
  let goArray = this.convertExpressionToGo(loop.array)
4380
4847
  const param = loop.param
4381
4848
  let index = loop.index || '_'
@@ -4396,11 +4863,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4396
4863
  rangeValue = '_'
4397
4864
  }
4398
4865
 
4399
- // If the loop contains a component child, range over `.{ComponentName}s`,
4400
- // which carries a ScopeID per item (TodoItem → `.TodoItems`).
4401
- const childComponent = this.findChildComponent(loop.children)
4402
- if (childComponent) {
4403
- goArray = `.${childComponent.name}s`
4866
+ // If the loop body IS a single component, range over `.{ComponentName}s`,
4867
+ // which carries a ScopeID per item (TodoItem → `.TodoItems`). Gate on the
4868
+ // IR's `loop.childComponent` the SAME condition `findNestedComponents`
4869
+ // uses to generate that slice field — not on a deep search of the body:
4870
+ // a component merely nested inside an element item (`<li><Badge/></li>`)
4871
+ // generates NO `.Badges` slice, so retargeting the range at it 500s at
4872
+ // render with `can't evaluate field Badges in type *XxxProps` (#2130).
4873
+ // Such loops keep iterating the real collection; the nested child renders
4874
+ // through the parent's once-per-slot instance (see `renderComponent`'s
4875
+ // non-wrapper in-loop branch).
4876
+ if (loop.childComponent) {
4877
+ goArray = `.${loop.childComponent.name}s`
4404
4878
  }
4405
4879
 
4406
4880
  this.inLoop = true
@@ -4418,7 +4892,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4418
4892
  if (supportableDestructure) {
4419
4893
  // Bindings resolve against the synthetic `$__bf_item` range var; don't push
4420
4894
  // a loop param (the param is a pattern, not a name).
4421
- this.loopBindingStack.push(this.buildDestructureBindingMap(loop, rangeValue))
4895
+ const built = this.buildDestructureBindingMap(loop, rangeValue)
4896
+ this.loopBindingStack.push(built.bindings)
4897
+ this.loopRestExcludeStack.push(built.restExcludes)
4422
4898
  pushedBindingMap = true
4423
4899
  this.loopParamStack.push('')
4424
4900
  if (rangeIndex !== '_') {
@@ -4443,7 +4919,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4443
4919
  this.loopScalarItemStack.push(
4444
4920
  this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null,
4445
4921
  )
4922
+ this.loopWrapperStack.push(!!loop.childComponent)
4446
4923
  const children = this.renderChildren(loop.children)
4924
+ this.loopWrapperStack.pop()
4447
4925
  this.loopScalarItemStack.pop()
4448
4926
  // Build the per-item anchor marker while the loop param is still on the
4449
4927
  // stack, so a `bodyIsItemConditional` key expression resolves against the
@@ -4456,7 +4934,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4456
4934
  else this.loopVarRefCount.set(v, rc)
4457
4935
  }
4458
4936
  this.loopParamStack.pop()
4459
- if (pushedBindingMap) this.loopBindingStack.pop()
4937
+ if (pushedBindingMap) {
4938
+ this.loopBindingStack.pop()
4939
+ this.loopRestExcludeStack.pop()
4940
+ }
4460
4941
  this.inLoop = false
4461
4942
 
4462
4943
  // Apply sort if present: wrap the array in a sort pipeline before `range`.
@@ -4512,24 +4993,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4512
4993
  return ''
4513
4994
  }
4514
4995
 
4515
- /** Find the first component child in a list of nodes. */
4516
- private findChildComponent(nodes: IRNode[]): IRComponent | null {
4517
- for (const node of nodes) {
4518
- if (node.type === 'component') {
4519
- return node as IRComponent
4520
- }
4521
- if (node.type === 'element' && (node as IRElement).children) {
4522
- const found = this.findChildComponent((node as IRElement).children)
4523
- if (found) return found
4524
- }
4525
- if (node.type === 'fragment' && (node as IRFragment).children) {
4526
- const found = this.findChildComponent((node as IRFragment).children)
4527
- if (found) return found
4528
- }
4529
- }
4530
- return null
4531
- }
4532
-
4533
4996
  /**
4534
4997
  * When `comp`'s JSX children contain template actions (nested components,
4535
4998
  * dynamic text) — i.e. none of the static bake paths in
@@ -4603,13 +5066,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4603
5066
 
4604
5067
  // In Go templates, components are rendered via {{template "name" data}}.
4605
5068
  let templateCall: string
4606
- if (this.inLoop) {
4607
- // Loop body component with JSX children: render children through a
4608
- // companion define so `bf_with_children` injects them at template
4609
- // execution time. Temporarily exit loop context so nested component calls
4610
- // (e.g. TableCell inside TableRow) use the normal non-loop rendering path
4611
- // (`.TableCellSlotN` fields on the wrapper struct), while the loop param
4612
- // stack stays intact so datum references resolve.
5069
+ if (this.inLoop && (this.loopWrapperStack[this.loopWrapperStack.length - 1] ?? false)) {
5070
+ // Wrapper-slice loop (body IS this component): `.` is the wrapper struct
5071
+ // embedding the child's Props. Loop body component with JSX children:
5072
+ // render children through a companion define so `bf_with_children`
5073
+ // injects them at template execution time. Temporarily exit loop context
5074
+ // so nested component calls (e.g. TableCell inside TableRow) use the
5075
+ // normal non-loop rendering path (`.TableCellSlotN` fields on the
5076
+ // wrapper struct), while the loop param stack stays intact so datum
5077
+ // references resolve.
4613
5078
  const loopBodyDefine = this.queueLoopBodyChildrenDefine(comp)
4614
5079
  if (loopBodyDefine) {
4615
5080
  // Scalar-item loop: feed the body define the wrapper's `.BfLoopItem` (the
@@ -4622,6 +5087,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4622
5087
  } else {
4623
5088
  templateCall = `{{template "${comp.name}" .}}`
4624
5089
  }
5090
+ } else if (this.inLoop && comp.slotId) {
5091
+ // Non-wrapper loop (component nested inside an element item, #2130):
5092
+ // the range iterates the REAL collection, so `.` is the raw datum and
5093
+ // carries none of the child's props. Call through the parent's
5094
+ // once-per-slot instance via the root context (`$` — the define's own
5095
+ // data, i.e. the parent's Props), injecting per-item content through a
5096
+ // loop-body children define executed with the datum (`.`). The shared
5097
+ // instance means identical child scope IDs across rows — the same
5098
+ // contract the wrapper machinery's `bodyChildInstances` already uses.
5099
+ const suffix = slotIdToFieldSuffix(comp.slotId)
5100
+ const loopBodyDefine = this.queueLoopBodyChildrenDefine(comp)
5101
+ templateCall = loopBodyDefine
5102
+ ? `{{template "${comp.name}" (bf_with_children $.${comp.name}${suffix} (bf_tmpl "${loopBodyDefine}" .))}}`
5103
+ : `{{template "${comp.name}" $.${comp.name}${suffix}}}`
5104
+ } else if (this.inLoop) {
5105
+ // Loop-nested component without a slotId: no parent field to route
5106
+ // through — legacy passthrough of the current dot.
5107
+ templateCall = `{{template "${comp.name}" .}}`
4625
5108
  } else if (comp.slotId) {
4626
5109
  // Static children with slotId: unique field name based on slotId.
4627
5110
  const suffix = slotIdToFieldSuffix(comp.slotId)
@@ -4801,16 +5284,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4801
5284
  // Property access through the loop param (`t.attrs`) is already handled
4802
5285
  // by the member-expression path that returns `.Attrs`.
4803
5286
  //
4804
- // The emit path is wired up but end-to-end fixture coverage is gated on
4805
- // two harness gaps: (a) `buildGoPropsInit` in `test-render.ts` can't pass
4806
- // nested-object arrays from JS into the Go input struct, and (b)
4807
- // `convertInitialValue` returns `nil` for complex literal arrays so
4808
- // signal-init arrays of objects don't reach the SSR template.
5287
+ // A per-item spread whose operand is a plain object/array field read
5288
+ // (not a destructure-rest binding) is otherwise gated on whether the
5289
+ // enclosing signal's literal initial value bakes at all see
5290
+ // `parsed-literal-to-go.ts`'s nested object/array property support
5291
+ // (#2087), which lifted the flat-object-only restriction for the
5292
+ // destructure-residual fixtures below.
4809
5293
  const trimmed = value.expr.trim()
4810
5294
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
4811
5295
  if (currentLoopParam && trimmed === currentLoopParam) {
4812
5296
  return `{{bf_spread_attrs .}}`
4813
5297
  }
5298
+ // Destructure object-rest spread onto an element (`{...rest}`, #2087
5299
+ // Phase B): `rest` alone would spread the WHOLE item (every field,
5300
+ // including the ones the pattern already destructured out as `id` /
5301
+ // `title`) — route through `bf_omit` instead so the residual excludes
5302
+ // exactly those sibling keys. Only a spread whose expr is the BARE
5303
+ // rest name matches (`isLowerableLoopDestructure`'s admitted shape);
5304
+ // anything else (`{...fn(rest)}`) already refused at the IR gate.
5305
+ const restInfo = this.lookupRestExclude(trimmed)
5306
+ if (restInfo) {
5307
+ const excludeArgs = restInfo.excludeKeys.map(k => JSON.stringify(k)).join(' ')
5308
+ const omitArgs = excludeArgs ? `${restInfo.parent} ${excludeArgs}` : restInfo.parent
5309
+ return `{{bf_spread_attrs (bf_omit ${omitArgs})}}`
5310
+ }
4814
5311
  const goExpr = this.convertExpressionToGo(value.expr)
4815
5312
  // `convertExpressionToGo` already pushes BF101 for unsupported
4816
5313
  // expressions and returns `""`; pass through so the template still