@barefootjs/go-template 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.
@@ -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,7 +60,7 @@ import {
59
60
  emitAttrValue,
60
61
  augmentInheritedPropAccesses,
61
62
  collectContextConsumers,
62
- isLowerableObjectRestDestructure,
63
+ isLowerableLoopDestructure,
63
64
  type ContextConsumer,
64
65
  collectModuleStringConsts as collectModuleStringConstsShared,
65
66
  prepareLoweringMatchers,
@@ -74,6 +75,7 @@ import {
74
75
  GO_KEYWORDS,
75
76
  capitalize,
76
77
  capitalizeFieldName,
78
+ goFieldNameForKey,
77
79
  slotIdToFieldSuffix,
78
80
  loopKeyToGoFieldPath,
79
81
  } from "./lib/go-naming.ts"
@@ -116,6 +118,7 @@ import {
116
118
  jsLiteralToGo,
117
119
  objectLiteralToGoMap,
118
120
  } from "./value/value-lowering.ts"
121
+ import { parsedLiteralToGo } from "./value/parsed-literal-to-go.ts"
119
122
  import { typeInfoToGo } from "./type/type-codegen.ts"
120
123
  import { isBooleanMemo, isListFilterMemo, isStringTernaryMemo } from "./memo/memo-type.ts"
121
124
  import { lowerCtorExpr } from "./memo/ctor-lowering.ts"
@@ -228,11 +231,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
228
231
  * wrapper's synthetic scalar field) instead of `.`. Innermost last.
229
232
  */
230
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[] = []
231
244
  private loopVarRefCount: Map<string, number> = new Map()
232
245
  /** Stack of destructure-param binding maps (binding name → Go accessor on the
233
- * range var, e.g. `id` → `$__bf_item0.Id`, `rest` → `$__bf_item0`). Innermost
234
- * 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`). */
235
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[] }>> = []
236
264
 
237
265
  /**
238
266
  * Cross-component child shapes, keyed by child component name. Populated via
@@ -475,9 +503,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
475
503
 
476
504
  /**
477
505
  * Register a child component's shape (see `childComponentShapes`). Call once
478
- * 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.
479
510
  */
480
- registerChildComponentShape(ir: ComponentIR): void {
511
+ registerChildComponentShape(ir: Pick<ComponentIR, 'metadata'>): void {
481
512
  const name = ir.metadata.componentName
482
513
  if (!name) return
483
514
  const paramNames = new Set((ir.metadata.propsParams ?? []).map(p => p.name))
@@ -507,10 +538,37 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
507
538
  return capitalizeFieldName(c.localName)
508
539
  }
509
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
+
510
561
  /** Go type for a context-consumer field, from its `createContext` default's type. */
511
562
  private contextConsumerGoType(c: ContextConsumer): string {
512
563
  if (typeof c.defaultValue === 'number') return 'int'
513
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{}'
514
572
  return 'string'
515
573
  }
516
574
 
@@ -519,6 +577,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
519
577
  if (typeof c.defaultValue === 'number') return String(c.defaultValue)
520
578
  if (typeof c.defaultValue === 'boolean') return String(c.defaultValue)
521
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{}{}'
522
588
  return '""'
523
589
  }
524
590
 
@@ -591,16 +657,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
591
657
  * analyzer's structured properties (no definition-string parsing). Both the
592
658
  * struct emitter and the object-literal baker consume it, so a baked literal
593
659
  * can't name a field the struct lacks. A non-Go-identifier source key
594
- * (`"data-id"`, a numeric key) is dropped here, so the baker bails to nil for
595
- * 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).
596
666
  */
597
667
  private structFieldsFor(td: TypeDefinition): Array<{ tsName: string; goName: string; goType: string }> {
598
668
  const fields: Array<{ tsName: string; goName: string; goType: string }> = []
669
+ const seenGoNames = new Set<string>()
599
670
  for (const prop of td.properties ?? []) {
600
- if (!GO_IDENTIFIER.test(prop.name)) continue
671
+ const goName = goFieldNameForKey(prop.name)
672
+ if (seenGoNames.has(goName)) continue
673
+ seenGoNames.add(goName)
601
674
  fields.push({
602
675
  tsName: prop.name,
603
- goName: capitalizeFieldName(prop.name),
676
+ goName,
604
677
  goType: typeInfoToGo(this.emitCtx, prop.type),
605
678
  })
606
679
  }
@@ -928,10 +1001,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
928
1001
  }
929
1002
 
930
1003
  /** Collect static child instances from loop body children for the wrapper struct. */
931
- private collectBodyChildInstances(bodyChildren: IRNode[]): StaticChildInstance[] {
1004
+ private collectBodyChildInstances(
1005
+ bodyChildren: IRNode[],
1006
+ propsParams: ReadonlyArray<{ name: string }> = [],
1007
+ ): StaticChildInstance[] {
932
1008
  const result: StaticChildInstance[] = []
933
1009
  for (const child of bodyChildren) {
934
- this.collectStaticChildInstancesRecursive(child, result, false, new Map())
1010
+ this.collectStaticChildInstancesRecursive(child, result, false, new Map(), propsParams)
935
1011
  }
936
1012
  return result
937
1013
  }
@@ -1184,8 +1260,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1184
1260
  for (const c of this.nonCollidingContextConsumers(takenInit)) {
1185
1261
  const field = this.contextFieldName(c)
1186
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.
1187
1268
  const defaulted =
1188
- 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{}{}'
1189
1274
  ? `in.${field}`
1190
1275
  : applyGoFallback(`in.${field}`, def)
1191
1276
  lines.push(`\t\t${field}: ${defaulted},`)
@@ -1200,7 +1285,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1200
1285
  }
1201
1286
 
1202
1287
  private emitStaticChildInstances(lines: string[], ir: ComponentIR): void {
1203
- const staticChildren = this.collectStaticChildInstances(ir.root)
1288
+ const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
1204
1289
  for (const child of staticChildren) {
1205
1290
  lines.push(`\t\t${child.fieldName}: New${child.name}Props(${child.name}Input{`)
1206
1291
  lines.push(`\t\t\tScopeID: scopeID + "_${child.slotId}",`)
@@ -1407,7 +1492,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1407
1492
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1408
1493
  const wrapperType = this.loopBodyWrapperName(componentName, nested)
1409
1494
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
1410
- const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
1495
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!, ir.metadata.propsParams)
1411
1496
 
1412
1497
  for (const child of bodyChildInstances) {
1413
1498
  const childVar = `child_${child.fieldName}`
@@ -1501,7 +1586,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1501
1586
  const wrapperType = this.loopBodyWrapperName(componentName, nested)
1502
1587
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1503
1588
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
1504
- const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
1589
+ const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!, ir.metadata.propsParams)
1505
1590
 
1506
1591
  // Child sub-component instances created once (identical scope IDs per row).
1507
1592
  for (const child of bodyChildInstances) {
@@ -1826,7 +1911,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1826
1911
  }
1827
1912
  }
1828
1913
 
1829
- const staticChildren = this.collectStaticChildInstances(ir.root)
1914
+ const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
1830
1915
  for (const child of staticChildren) {
1831
1916
  lines.push(`\t${child.fieldName} ${child.name}Props \`json:"-"\``)
1832
1917
  }
@@ -1850,9 +1935,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1850
1935
  * components inside loops (handled by nestedComponents). Each instance carries
1851
1936
  * its component `name`, `slotId`, `props`, and Go `fieldName`.
1852
1937
  */
1853
- private collectStaticChildInstances(node: IRNode): Array<StaticChildInstance> {
1938
+ private collectStaticChildInstances(
1939
+ node: IRNode,
1940
+ propsParams: ReadonlyArray<{ name: string }> = [],
1941
+ ): Array<StaticChildInstance> {
1854
1942
  const result: StaticChildInstance[] = []
1855
- this.collectStaticChildInstancesRecursive(node, result, false, new Map())
1943
+ this.collectStaticChildInstancesRecursive(node, result, false, new Map(), propsParams)
1856
1944
  return result
1857
1945
  }
1858
1946
 
@@ -1925,6 +2013,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1925
2013
  result: StaticChildInstance[],
1926
2014
  inLoop: boolean,
1927
2015
  providerCtx: ReadonlyMap<string, string>,
2016
+ propsParams: ReadonlyArray<{ name: string }> = [],
1928
2017
  ): void {
1929
2018
  if (node.type === 'component') {
1930
2019
  const comp = node as IRComponent
@@ -1934,7 +2023,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1934
2023
  // child components nested inside still get their slot fields.
1935
2024
  if (comp.dynamicTag) {
1936
2025
  for (const child of comp.children) {
1937
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2026
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1938
2027
  }
1939
2028
  return
1940
2029
  }
@@ -1966,86 +2055,208 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1966
2055
  // never contain components (any nested component renders a `{{template}}`
1967
2056
  // action, which the bake extractors reject), so recursing is a no-op.
1968
2057
  for (const child of effectiveChildren) {
1969
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2058
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1970
2059
  }
1971
2060
  }
1972
2061
  // Recurse into Portal's children to find nested components
1973
2062
  if (comp.name === 'Portal' && comp.children) {
1974
2063
  for (const child of comp.children) {
1975
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2064
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1976
2065
  }
1977
2066
  }
1978
2067
  } else if (node.type === 'loop') {
1979
2068
  const loop = node as IRLoop
1980
- // 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`).
1981
2077
  for (const child of loop.children) {
1982
- this.collectStaticChildInstancesRecursive(child, result, true, providerCtx)
2078
+ this.collectStaticChildInstancesRecursive(
2079
+ child,
2080
+ result,
2081
+ inLoop || !!loop.childComponent,
2082
+ providerCtx,
2083
+ propsParams,
2084
+ )
1983
2085
  }
1984
2086
  } else if (node.type === 'element') {
1985
2087
  const element = node as IRElement
1986
2088
  for (const child of element.children) {
1987
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2089
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1988
2090
  }
1989
2091
  } else if (node.type === 'fragment') {
1990
2092
  const fragment = node as IRFragment
1991
2093
  for (const child of fragment.children) {
1992
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2094
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
1993
2095
  }
1994
2096
  } else if (node.type === 'conditional') {
1995
2097
  const cond = node as IRConditional
1996
- this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop, providerCtx)
2098
+ this.collectStaticChildInstancesRecursive(cond.whenTrue, result, inLoop, providerCtx, propsParams)
1997
2099
  if (cond.whenFalse) {
1998
- this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx)
2100
+ this.collectStaticChildInstancesRecursive(cond.whenFalse, result, inLoop, providerCtx, propsParams)
1999
2101
  }
2000
2102
  } else if (node.type === 'if-statement') {
2001
2103
  // An early-return if-statement root (e.g. an asChild split) keeps its
2002
2104
  // subtrees in consequent/alternate — the non-asChild branch's nested icon
2003
2105
  // needs its slot field like any other static child.
2004
2106
  const stmt = node as IRIfStatement
2005
- this.collectStaticChildInstancesRecursive(stmt.consequent, result, inLoop, providerCtx)
2107
+ this.collectStaticChildInstancesRecursive(stmt.consequent, result, inLoop, providerCtx, propsParams)
2006
2108
  if (stmt.alternate) {
2007
- this.collectStaticChildInstancesRecursive(stmt.alternate, result, inLoop, providerCtx)
2109
+ this.collectStaticChildInstancesRecursive(stmt.alternate, result, inLoop, providerCtx, propsParams)
2008
2110
  }
2009
2111
  } else if (node.type === 'provider') {
2010
2112
  // SSR context propagation: record the provider's value against its context
2011
2113
  // name and extend the active binding map for descendants. A literal value
2012
- // lowers to a Go literal; a non-literal is left unbound (the consumer
2013
- // 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).
2014
2117
  const p = node as IRProvider
2015
- const childCtx = this.extendProviderContext(providerCtx, p)
2118
+ const childCtx = this.extendProviderContext(providerCtx, p, propsParams)
2016
2119
  for (const child of p.children) {
2017
- this.collectStaticChildInstancesRecursive(child, result, inLoop, childCtx)
2120
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, childCtx, propsParams)
2018
2121
  }
2019
2122
  } else if (node.type === 'async') {
2020
2123
  // Async fallback + children render server-side via the OOS
2021
2124
  // protocol; static child components inside them still need slot
2022
2125
  // fields on the parent struct.
2023
2126
  const a = node as IRAsync
2024
- this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop, providerCtx)
2127
+ this.collectStaticChildInstancesRecursive(a.fallback, result, inLoop, providerCtx, propsParams)
2025
2128
  for (const child of a.children) {
2026
- this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx)
2129
+ this.collectStaticChildInstancesRecursive(child, result, inLoop, providerCtx, propsParams)
2027
2130
  }
2028
2131
  }
2029
2132
  }
2030
2133
 
2031
2134
  /**
2032
2135
  * Extend the active provider-context map with one `<Ctx.Provider value>`. A
2033
- * string/number/boolean literal value is lowered to a Go literal; any other
2034
- * 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.
2035
2143
  */
2036
2144
  private extendProviderContext(
2037
2145
  current: ReadonlyMap<string, string>,
2038
2146
  p: IRProvider,
2147
+ propsParams: ReadonlyArray<{ name: string }>,
2039
2148
  ): ReadonlyMap<string, string> {
2040
- const v = p.valueProp?.value as { kind?: string; value?: unknown } | undefined
2041
- if (!v || v.kind !== 'literal') return current
2042
- let goLit: string | null = null
2043
- if (typeof v.value === 'string') goLit = `"${escapeGoString(v.value)}"`
2044
- else if (typeof v.value === 'number' || typeof v.value === 'boolean') goLit = String(v.value)
2045
- if (goLit === null) return current
2046
- const next = new Map(current)
2047
- next.set(p.contextName, goLit)
2048
- 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
2049
2260
  }
2050
2261
 
2051
2262
  /**
@@ -2846,10 +3057,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2846
3057
  if (objHO && (objHO.method === 'find' || objHO.method === 'findLast')) {
2847
3058
  const findResult = this.renderHigherOrderExpr(objHO, emit)
2848
3059
  if (findResult) {
2849
- return `{{with ${findResult}}}{{.${capitalizeFieldName(property)}}}{{end}}`
3060
+ return `{{with ${findResult}}}{{.${goFieldNameForKey(property)}}}{{end}}`
2850
3061
  }
2851
3062
  const templateBlock = this.renderFindTemplateBlock(
2852
- objHO, emit, capitalizeFieldName(property),
3063
+ objHO, emit, goFieldNameForKey(property),
2853
3064
  )
2854
3065
  if (templateBlock) return templateBlock
2855
3066
  }
@@ -2860,6 +3071,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2860
3071
  return this.rootFieldRef(property)
2861
3072
  }
2862
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
+
2863
3090
  // Static property access on a module object-literal const
2864
3091
  // (`variantClasses.ghost` in a class template literal) resolves at compile
2865
3092
  // time — same lookup as the bracket-index form in
@@ -2874,14 +3101,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2874
3101
 
2875
3102
  // Inside a loop, the loop param variable refers to the current item
2876
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.
2877
3112
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
2878
3113
  if (object.kind === 'identifier' && currentLoopParam && object.name === currentLoopParam) {
2879
- return `.${capitalizeFieldName(property)}`
3114
+ return `.${goFieldNameForKey(property)}`
2880
3115
  }
2881
3116
 
2882
3117
  const obj = emit(object)
2883
3118
  if (property === 'length') return `len ${obj}`
2884
- return `${obj}.${capitalizeFieldName(property)}`
3119
+ return `${obj}.${goFieldNameForKey(property)}`
2885
3120
  }
2886
3121
 
2887
3122
  indexAccess(object: ParsedExpr, index: ParsedExpr, emit: (e: ParsedExpr) => string): string {
@@ -3028,11 +3263,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3028
3263
  }
3029
3264
 
3030
3265
  objectLiteral(_properties: ObjectLiteralProperty[], raw: string, _emit: (e: ParsedExpr) => string): string {
3031
- // Not yet lowered structurally from `properties`: route through the
3032
- // `unsupported` path (`[UNSUPPORTED: …]`). Object values that DO reach a Go
3033
- // 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
3034
3281
  // `objectLiteralToGoMap` lowering, not here.
3035
- 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 `""`
3036
3296
  }
3037
3297
 
3038
3298
  /** Set of predicate (boolean-callback) higher-order methods. */
@@ -3328,7 +3588,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3328
3588
  }
3329
3589
  }
3330
3590
 
3331
- 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
+ }
3332
3608
  // `.flat(depth?)` → `bf_flat <recv> <depth>`. `Infinity` lowers to the `-1`
3333
3609
  // sentinel (flatten fully); a finite depth flattens that many levels
3334
3610
  // (`0` = shallow copy).
@@ -4414,20 +4690,78 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4414
4690
  }
4415
4691
 
4416
4692
  /**
4417
- * Map each destructure binding to its Go accessor on the range var: a named
4418
- * binding `$<rangeVar>.<Field>`, an object-rest binding the bare
4419
- * `$<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.
4704
+ */
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.
4420
4732
  */
4421
- private buildDestructureBindingMap(loop: IRLoop, rangeVar: string): Map<string, string> {
4422
- const m = new Map<string, string>()
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}`
4423
4743
  for (const b of loop.paramBindings ?? []) {
4424
- if (b.rest) {
4425
- 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})`)
4426
4749
  } else {
4427
- 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) })
4428
4752
  }
4429
4753
  }
4430
- 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
4431
4765
  }
4432
4766
 
4433
4767
  renderLoop(loop: IRLoop): string {
@@ -4446,15 +4780,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4446
4780
  //
4447
4781
  // Check the IR's structured `paramBindings` field rather than string-matching
4448
4782
  // `loop.param`: Phase 1 populates it iff the param is a destructure pattern,
4449
- // and the structured check is robust to formatting variants. A destructure
4450
- // param is lowerable only for the object-rest / simple-field shape
4451
- // (`.map(({ id, title, ...rest }) => …)`, `rest` read via member access):
4452
- // each binding resolves to a field on a named range var (`$__bf_item0.Id`,
4453
- // `rest.flag` → `$__bf_item0.Flag`). Array-index / nested / rest-spread
4454
- // shapes (`[a, ...t]`, `{ cells: [h] }`, `{...rest}`) still need machinery
4455
- // 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.
4456
4792
  const destructure = !!(loop.paramBindings && loop.paramBindings.length > 0)
4457
- const supportableDestructure = destructure && isLowerableObjectRestDestructure(loop)
4793
+ const supportableDestructure = destructure && isLowerableLoopDestructure(loop)
4458
4794
  if (destructure && !supportableDestructure) {
4459
4795
  this.state.errors.push({
4460
4796
  code: 'BF104',
@@ -4471,6 +4807,42 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4471
4807
  })
4472
4808
  }
4473
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
+
4474
4846
  let goArray = this.convertExpressionToGo(loop.array)
4475
4847
  const param = loop.param
4476
4848
  let index = loop.index || '_'
@@ -4491,11 +4863,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4491
4863
  rangeValue = '_'
4492
4864
  }
4493
4865
 
4494
- // If the loop contains a component child, range over `.{ComponentName}s`,
4495
- // which carries a ScopeID per item (TodoItem → `.TodoItems`).
4496
- const childComponent = this.findChildComponent(loop.children)
4497
- if (childComponent) {
4498
- 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`
4499
4878
  }
4500
4879
 
4501
4880
  this.inLoop = true
@@ -4513,7 +4892,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4513
4892
  if (supportableDestructure) {
4514
4893
  // Bindings resolve against the synthetic `$__bf_item` range var; don't push
4515
4894
  // a loop param (the param is a pattern, not a name).
4516
- 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)
4517
4898
  pushedBindingMap = true
4518
4899
  this.loopParamStack.push('')
4519
4900
  if (rangeIndex !== '_') {
@@ -4538,7 +4919,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4538
4919
  this.loopScalarItemStack.push(
4539
4920
  this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null,
4540
4921
  )
4922
+ this.loopWrapperStack.push(!!loop.childComponent)
4541
4923
  const children = this.renderChildren(loop.children)
4924
+ this.loopWrapperStack.pop()
4542
4925
  this.loopScalarItemStack.pop()
4543
4926
  // Build the per-item anchor marker while the loop param is still on the
4544
4927
  // stack, so a `bodyIsItemConditional` key expression resolves against the
@@ -4551,7 +4934,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4551
4934
  else this.loopVarRefCount.set(v, rc)
4552
4935
  }
4553
4936
  this.loopParamStack.pop()
4554
- if (pushedBindingMap) this.loopBindingStack.pop()
4937
+ if (pushedBindingMap) {
4938
+ this.loopBindingStack.pop()
4939
+ this.loopRestExcludeStack.pop()
4940
+ }
4555
4941
  this.inLoop = false
4556
4942
 
4557
4943
  // Apply sort if present: wrap the array in a sort pipeline before `range`.
@@ -4607,24 +4993,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4607
4993
  return ''
4608
4994
  }
4609
4995
 
4610
- /** Find the first component child in a list of nodes. */
4611
- private findChildComponent(nodes: IRNode[]): IRComponent | null {
4612
- for (const node of nodes) {
4613
- if (node.type === 'component') {
4614
- return node as IRComponent
4615
- }
4616
- if (node.type === 'element' && (node as IRElement).children) {
4617
- const found = this.findChildComponent((node as IRElement).children)
4618
- if (found) return found
4619
- }
4620
- if (node.type === 'fragment' && (node as IRFragment).children) {
4621
- const found = this.findChildComponent((node as IRFragment).children)
4622
- if (found) return found
4623
- }
4624
- }
4625
- return null
4626
- }
4627
-
4628
4996
  /**
4629
4997
  * When `comp`'s JSX children contain template actions (nested components,
4630
4998
  * dynamic text) — i.e. none of the static bake paths in
@@ -4698,13 +5066,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4698
5066
 
4699
5067
  // In Go templates, components are rendered via {{template "name" data}}.
4700
5068
  let templateCall: string
4701
- if (this.inLoop) {
4702
- // Loop body component with JSX children: render children through a
4703
- // companion define so `bf_with_children` injects them at template
4704
- // execution time. Temporarily exit loop context so nested component calls
4705
- // (e.g. TableCell inside TableRow) use the normal non-loop rendering path
4706
- // (`.TableCellSlotN` fields on the wrapper struct), while the loop param
4707
- // 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.
4708
5078
  const loopBodyDefine = this.queueLoopBodyChildrenDefine(comp)
4709
5079
  if (loopBodyDefine) {
4710
5080
  // Scalar-item loop: feed the body define the wrapper's `.BfLoopItem` (the
@@ -4717,6 +5087,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4717
5087
  } else {
4718
5088
  templateCall = `{{template "${comp.name}" .}}`
4719
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}" .}}`
4720
5108
  } else if (comp.slotId) {
4721
5109
  // Static children with slotId: unique field name based on slotId.
4722
5110
  const suffix = slotIdToFieldSuffix(comp.slotId)
@@ -4896,16 +5284,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4896
5284
  // Property access through the loop param (`t.attrs`) is already handled
4897
5285
  // by the member-expression path that returns `.Attrs`.
4898
5286
  //
4899
- // The emit path is wired up but end-to-end fixture coverage is gated on
4900
- // two harness gaps: (a) `buildGoPropsInit` in `test-render.ts` can't pass
4901
- // nested-object arrays from JS into the Go input struct, and (b)
4902
- // `convertInitialValue` returns `nil` for complex literal arrays so
4903
- // 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.
4904
5293
  const trimmed = value.expr.trim()
4905
5294
  const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
4906
5295
  if (currentLoopParam && trimmed === currentLoopParam) {
4907
5296
  return `{{bf_spread_attrs .}}`
4908
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
+ }
4909
5311
  const goExpr = this.convertExpressionToGo(value.expr)
4910
5312
  // `convertExpressionToGo` already pushes BF101 for unsupported
4911
5313
  // expressions and returns `""`; pass through so the template still