@barefootjs/go-template 0.18.5 → 0.19.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 (36) hide show
  1. package/dist/adapter/analysis/static-child-loop-bake.d.ts +61 -0
  2. package/dist/adapter/analysis/static-child-loop-bake.d.ts.map +1 -0
  3. package/dist/adapter/analysis/static-element-loop-bake.d.ts +83 -0
  4. package/dist/adapter/analysis/static-element-loop-bake.d.ts.map +1 -0
  5. package/dist/adapter/emit-context.d.ts +11 -5
  6. package/dist/adapter/emit-context.d.ts.map +1 -1
  7. package/dist/adapter/go-template-adapter.d.ts +153 -6
  8. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  9. package/dist/adapter/index.js +514 -53
  10. package/dist/adapter/lib/compile-state.d.ts +24 -0
  11. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  12. package/dist/adapter/lib/types.d.ts +9 -0
  13. package/dist/adapter/lib/types.d.ts.map +1 -1
  14. package/dist/adapter/props/prop-classes.d.ts +28 -9
  15. package/dist/adapter/props/prop-classes.d.ts.map +1 -1
  16. package/dist/adapter/props/prop-types.d.ts +45 -0
  17. package/dist/adapter/props/prop-types.d.ts.map +1 -1
  18. package/dist/build.js +514 -53
  19. package/dist/conformance-pins.d.ts.map +1 -1
  20. package/dist/index.js +516 -62
  21. package/dist/render-divergences.d.ts.map +1 -1
  22. package/dist/test-render.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/__tests__/go-template-adapter.test.ts +876 -21
  25. package/src/adapter/analysis/static-child-loop-bake.ts +119 -0
  26. package/src/adapter/analysis/static-element-loop-bake.ts +211 -0
  27. package/src/adapter/emit-context.ts +14 -5
  28. package/src/adapter/go-template-adapter.ts +620 -45
  29. package/src/adapter/lib/compile-state.ts +27 -0
  30. package/src/adapter/lib/types.ts +9 -0
  31. package/src/adapter/props/prop-classes.ts +34 -9
  32. package/src/adapter/props/prop-types.ts +178 -1
  33. package/src/adapter/value/value-lowering.ts +1 -1
  34. package/src/conformance-pins.ts +30 -31
  35. package/src/render-divergences.ts +12 -0
  36. package/src/test-render.ts +127 -10
@@ -67,6 +67,13 @@ import {
67
67
  envSignalReaderFor,
68
68
  computeSsrSeedPlan,
69
69
  isStringConcatBinary,
70
+ isDangerousInnerHtmlAttr,
71
+ resolveDangerousInnerHtml,
72
+ dangerousInnerHtmlMetacharViolation,
73
+ dangerousInnerHtmlDiagnostic,
74
+ resolveStaticLoopSource,
75
+ collectLoopBoundNames,
76
+ evaluateStaticLiteral,
70
77
  } from '@barefootjs/jsx'
71
78
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
72
79
  import { BF_REGION, escapeHtml } from '@barefootjs/shared'
@@ -111,6 +118,8 @@ import { collectRootScopeNodes } from "./lib/ir-scope.ts"
111
118
  import { GO_TEMPLATE_PRIMITIVES } from "./lib/constants.ts"
112
119
  import { CompileState } from "./lib/compile-state.ts"
113
120
  import { hasClientInteractivity, findNestedComponents } from "./analysis/component-tree.ts"
121
+ import { analyzeBakeableStaticChildLoop, scalarToGoLiteral, type BakedStaticChildLoop } from "./analysis/static-child-loop-bake.ts"
122
+ import { analyzeBakeableStaticElementLoop } from "./analysis/static-element-loop-bake.ts"
114
123
  import type { GoEmitContext } from "./emit-context.ts"
115
124
  import { inlineLocalHelperCall } from "./expr/helper-inline.ts"
116
125
  import { lowerRegisteredCall } from "./expr/url-builder.ts"
@@ -126,7 +135,7 @@ import { lowerCtorExpr } from "./memo/ctor-lowering.ts"
126
135
  import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
127
136
  import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
128
137
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
129
- import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames } from "./props/prop-types.ts"
138
+ import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames, collectNullishConsumedPropNames, collectOmittableAttrConsumedPropNames, NULLISH_SCALAR_GO_TYPES } from "./props/prop-types.ts"
130
139
  import { collectStringValueNames } from "./props/prop-classes.ts"
131
140
 
132
141
  export type { GoTemplateAdapterOptions } from "./lib/types.ts"
@@ -215,8 +224,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
215
224
  this.convertExpressionToGo(jsExpr, out, preParsed),
216
225
  convertConditionToGo: (jsCondition, preParsed) =>
217
226
  this.convertConditionToGo(jsCondition, preParsed),
218
- extractPropNameFromInitialValue: (initialValue) => this.extractPropNameFromInitialValue(initialValue),
219
- extractPropFallback: (initialValue) => this.extractPropFallback(initialValue),
227
+ extractPropNameFromInitialValue: (initialValue, preParsed) =>
228
+ this.extractPropNameFromInitialValue(initialValue, preParsed),
229
+ extractPropFallback: (initialValue, preParsed) => this.extractPropFallback(initialValue, preParsed),
220
230
  resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
221
231
  }
222
232
 
@@ -226,6 +236,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
226
236
  }
227
237
 
228
238
  private inLoop: boolean = false
239
+ /**
240
+ * Memoized `analyzeBakeableStaticChildLoop` result per loop marker id
241
+ * (#2208). Consulted from three sites that all need the SAME verdict for
242
+ * the SAME loop — the `renderLoop` gate, the Input struct's field list,
243
+ * and the constructor's per-item construction. `renderLoop`'s gate runs
244
+ * during `generate()`'s render pass; the other two run during
245
+ * `generateTypes()`'s constructor-generation pass, which `generate()`
246
+ * also invokes internally partway through — so this cache is reset (in
247
+ * `primeCompileState`, not here) between those two passes too. Agreement
248
+ * across all three sites is therefore guaranteed by `analyzeBakeable-
249
+ * StaticChildLoop` being a deterministic pure function of the (re-primed)
250
+ * per-compile state, not by one shared memo spanning every read; the
251
+ * cache's value is avoiding redundant recomputation WITHIN the pass that
252
+ * populated it, not correctness across passes.
253
+ */
254
+ private bakedStaticChildLoopCache = new Map<string, BakedStaticChildLoop | null>()
229
255
  private loopParamStack: string[] = []
230
256
  /**
231
257
  * Stack of `IRLoop.depth` values (innermost last), pushed/popped around
@@ -272,6 +298,30 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
272
298
  * alone (#2087 Phase B).
273
299
  */
274
300
  private loopRestExcludeStack: Array<Map<string, { parent: string; excludeKeys: string[] }>> = []
301
+ /**
302
+ * Active per-item static bindings for a #2224 unrolled plain-element loop
303
+ * (`renderUnrolledStaticElementLoop`) — innermost last, so a nested static
304
+ * unroll inside another one resolves against its own item, not an outer
305
+ * one's. When non-empty, `convertExpressionToGo`'s top-of-function check
306
+ * resolves EVERY expression against the innermost entry's item via
307
+ * `evaluateStaticLiteral` and returns a literal Go value instead of
308
+ * descending into the normal `.Field`-style dot-context lowering — there
309
+ * is no real `{{range}}` establishing that context during an unroll, so
310
+ * `identifier()`'s `currentLoopParam → '.'` branch would otherwise resolve
311
+ * against the WRONG (enclosing) dot context. `analyzeBakeableStaticElementLoop`
312
+ * has already verified every expression in the body resolves this way for
313
+ * every item, so the fallback failure branch below is a defensive
314
+ * invariant check, not a real code path.
315
+ */
316
+ private staticLoopItemStack: Array<{ param: string; item: unknown }> = []
317
+ /**
318
+ * Set by the `convertExpressionToGo` override above when a
319
+ * `staticLoopItemStack` entry is active but the current expression fails
320
+ * to resolve against it — should never happen (see that check's comment),
321
+ * but `renderUnrolledStaticElementLoop` asserts this stays `false` after
322
+ * every item render rather than silently shipping a `""` sentinel.
323
+ */
324
+ private staticLoopBakeFailed = false
275
325
 
276
326
  /**
277
327
  * Cross-component child shapes, keyed by child component name. Populated via
@@ -307,6 +357,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
307
357
  this.state.restPropsName = ir.metadata.restPropsName ?? null
308
358
  this.state.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
309
359
  this.state.localConstants = ir.metadata.localConstants ?? []
360
+ // #2208 fable review: every name a `.map()`/`.filter()` loop callback
361
+ // binds as its item/index parameter anywhere in the component. Static
362
+ // loop-source resolution (`getBakedStaticChildLoop` /
363
+ // `analyzeBakeableStaticChildLoop`) must never resolve a const whose
364
+ // name a DIFFERENT, enclosing loop's own callback param shadows.
365
+ // Computed here (not from live render-time stack state) because this
366
+ // must agree across THREE call sites, two of which (`generateTypes`'s
367
+ // Input-struct + constructor generation) run OUTSIDE the live
368
+ // `renderLoop` tree-walk that would otherwise track shadowing via
369
+ // stack push/pop — same coarse-but-safe mitigation as #2212.
370
+ this.state.staticLoopSourceBoundNames = collectLoopBoundNames(ir)
371
+ // #2208 fable re-review: the adapter instance is a reused singleton —
372
+ // `generate()` calls this once per component, but `generateTypes()` is
373
+ // ALSO a standalone public entry point (the Go conformance harness in
374
+ // `test-render.ts` calls it directly on an already-`generate()`d
375
+ // adapter for a sibling/child IR). Resetting the bake cache HERE, not
376
+ // just in `generate()`, closes that door too: a stale entry keyed by a
377
+ // marker id that collides with a PREVIOUS component (marker ids
378
+ // restart at `l0` per component) would otherwise either silently
379
+ // suppress this fix or leak that other component's baked data into
380
+ // this one's constructor. `generate()` itself calls `generateTypes()`
381
+ // partway through — re-priming (and so re-clearing the cache) there is
382
+ // harmless: `analyzeBakeableStaticChildLoop` is deterministic over
383
+ // identically-primed state, so a cache miss on the second pass just
384
+ // recomputes the same answer.
385
+ this.bakedStaticChildLoopCache = new Map()
310
386
  this.state.localHelperNames = new Set(
311
387
  this.state.localConstants.filter(c => !c.isModule && c.containsArrow).map(c => c.name),
312
388
  )
@@ -331,6 +407,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
331
407
  }
332
408
  this.state.loweringMatchers = prepareLoweringMatchers(ir.metadata)
333
409
  augmentInheritedPropAccesses(ir)
410
+ // The consumed-prop sets feed `resolvePropGoType`'s interface{} flips
411
+ // (#2248/#2259) and `collectNillablePropNames` is derived from
412
+ // `resolvePropGoType`, which also consults the local type tables — so
413
+ // build the tables first and populate all three HERE, where both entry
414
+ // points share them. Computing them only in `generate()` left the
415
+ // standalone `generateTypes()` entry (sibling IRs in the conformance
416
+ // harness) resolving structs against another component's sets, and
417
+ // `generate()` itself computing nillability against the PREVIOUS
418
+ // compile's type tables.
419
+ this.buildLocalTypeTables(ir, ir.metadata.componentName)
420
+ this.state.nullishConsumedPropNames = collectNullishConsumedPropNames(this.emitCtx, ir)
421
+ this.state.omittableAttrConsumedPropNames = collectOmittableAttrConsumedPropNames(this.emitCtx, ir)
422
+ this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
334
423
  }
335
424
 
336
425
  /** Generate template output for a component. */
@@ -341,7 +430,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
341
430
  this.state.templateVarCounter = 0
342
431
  this.state.pendingChildrenDefines = []
343
432
  this.primeCompileState(ir)
344
- this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
345
433
  this.state.stringValueNames = collectStringValueNames(ir)
346
434
 
347
435
  // Surface loop-body usages of sibling-imported components (see
@@ -856,6 +944,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
856
944
  }
857
945
 
858
946
  for (const nested of inputNested) {
947
+ // #2208: a static loop whose array source is itself fully-static
948
+ // (baked directly in the constructor — see `generateNewPropsFunction`)
949
+ // has no caller-supplied data to accept; the Input struct carries no
950
+ // field for it at all (there was never a working `in.<Name>s` path
951
+ // for this shape before this fix — it refused with BF101).
952
+ if (nested.loopMarkerId && this.getBakedStaticChildLoop(
953
+ nested.loopMarkerId,
954
+ nested,
955
+ nested.loopArrayParsed,
956
+ nested.loopParam,
957
+ nested.loopKey,
958
+ )) continue
859
959
  lines.push(`\t${nested.name}s []${nested.name}Input`)
860
960
  }
861
961
 
@@ -1074,6 +1174,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1074
1174
  // Static nested WITHOUT body children.
1075
1175
  for (const nested of staticWithoutBody) {
1076
1176
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1177
+ // #2208: a static loop whose ARRAY SOURCE is itself fully-static
1178
+ // (`const items = [{ label: 'Alpha' }, ...]`) has no caller input to
1179
+ // wait for — every item's props/data-key are already known at
1180
+ // compile time. Bake them directly instead of ranging over
1181
+ // `in.<Name>s` (which stays empty forever for this shape, since the
1182
+ // loop-source gate above only lets this fixture through BECAUSE it's
1183
+ // baked here).
1184
+ const baked = nested.loopMarkerId
1185
+ ? this.getBakedStaticChildLoop(
1186
+ nested.loopMarkerId,
1187
+ nested,
1188
+ nested.loopArrayParsed,
1189
+ nested.loopParam,
1190
+ nested.loopKey,
1191
+ )
1192
+ : null
1193
+ if (baked) {
1194
+ lines.push(`\t${varName} := make([]${nested.name}Props, ${baked.items.length})`)
1195
+ baked.items.forEach((item, i) => {
1196
+ const fields = item.inputFields.map(f => `${f.goField}: ${f.goValue}`).join(', ')
1197
+ lines.push(`\t${varName}[${i}] = New${nested.name}Props(${nested.name}Input{${fields}})`)
1198
+ lines.push(`\t${varName}[${i}].BfParent = scopeID`)
1199
+ lines.push(`\t${varName}[${i}].BfMount = "${nested.slotId}"`)
1200
+ if (item.dataKey !== null) {
1201
+ lines.push(`\t${varName}[${i}].BfDataKey = ${JSON.stringify(item.dataKey)}`)
1202
+ }
1203
+ })
1204
+ lines.push('')
1205
+ continue
1206
+ }
1077
1207
  lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
1078
1208
  lines.push(`\tfor i, item := range in.${nested.name}s {`)
1079
1209
  lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
@@ -1096,10 +1226,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1096
1226
  // from an omitted field, so it also fires on the type's zero value.
1097
1227
  const propFallbackVars = this.collectPropFallbackVars(ir)
1098
1228
  for (const [, info] of propFallbackVars) {
1099
- lines.push(`\t${info.varName} := in.${info.fieldName}`)
1100
- lines.push(`\tif ${info.varName} == ${info.zeroLiteral} {`)
1101
- lines.push(`\t\t${info.varName} = ${info.goFallback}`)
1102
- lines.push(`\t}`)
1229
+ if (info.assertType) {
1230
+ // Nillable-lowered prop (#2248): the `interface{}` field makes
1231
+ // "absent" (nil) distinguishable from an explicit `''`/`0`/`false`,
1232
+ // so the fallback applies ONLY on nil — JS `??` semantics. Numbers
1233
+ // coerce through the runtime (an untyped `Size: 3` literal boxes as
1234
+ // int even into a float64-shaped prop); string/bool assert directly.
1235
+ const deref =
1236
+ info.assertType === 'int' ? `bf.ToInt(in.${info.fieldName})`
1237
+ : info.assertType === 'float64' ? `bf.ToFloat64(in.${info.fieldName})`
1238
+ : `in.${info.fieldName}.(${info.assertType})`
1239
+ lines.push(`\tvar ${info.varName} ${info.assertType} = ${info.goFallback}`)
1240
+ lines.push(`\tif in.${info.fieldName} != nil {`)
1241
+ lines.push(`\t\t${info.varName} = ${deref}`)
1242
+ lines.push(`\t}`)
1243
+ } else {
1244
+ lines.push(`\t${info.varName} := in.${info.fieldName}`)
1245
+ lines.push(`\tif ${info.varName} == ${info.zeroLiteral} {`)
1246
+ lines.push(`\t\t${info.varName} = ${info.goFallback}`)
1247
+ lines.push(`\t}`)
1248
+ }
1103
1249
  }
1104
1250
  if (propFallbackVars.size > 0) lines.push('')
1105
1251
 
@@ -1208,7 +1354,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1208
1354
  if (propFieldNames.has(fieldName)) continue
1209
1355
  // `props.X ?? N` reuses the hoisted fallback var so signal and memo share
1210
1356
  // one value.
1211
- const fallbackMatch = this.extractPropFallback(signal.initialValue)
1357
+ const fallbackMatch = this.extractPropFallback(signal.initialValue, signal.parsed)
1212
1358
  const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
1213
1359
  if (hoisted) {
1214
1360
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
@@ -2598,8 +2744,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2598
2744
  localTaken.add(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
2599
2745
  }
2600
2746
 
2747
+ const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
2601
2748
  for (const signal of ir.metadata.signals) {
2602
- const match = this.extractPropFallback(signal.initialValue)
2749
+ const match = this.extractPropFallback(signal.initialValue, signal.parsed)
2603
2750
  if (!match) continue
2604
2751
  if (result.has(match.propName)) continue
2605
2752
  const param = ir.metadata.propsParams.find(p => p.name === match.propName)
@@ -2607,6 +2754,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2607
2754
  // A destructure default already wins via applyGoFallback below.
2608
2755
  if (goPropDefault(param.defaultValue) !== null) continue
2609
2756
  const fieldName = capitalizeFieldName(match.propName)
2757
+ // A `??`-consumed optional scalar lowered to `interface{}` (#2248) —
2758
+ // detected off the SAME `resolvePropGoType` pipeline the struct
2759
+ // generators use, so this can't drift from the emitted field type. The
2760
+ // concrete pre-flip type is what the hoisted local materializes as.
2761
+ const concreteType = propTypeOverrides.get(param.name) ?? typeInfoToGo(this.emitCtx, param.type, param.defaultValue)
2762
+ const nullishLowered =
2763
+ NULLISH_SCALAR_GO_TYPES.has(concreteType) &&
2764
+ resolvePropGoType(this.emitCtx, param, propTypeOverrides) === 'interface{}'
2610
2765
  // Pick the zero literal based on the fallback's literal shape. Bool
2611
2766
  // fallbacks (`?? true`) hoist against the `false` zero — the same Go-zero
2612
2767
  // conflation the int / string cases accept: the caller can't distinguish
@@ -2625,8 +2780,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2625
2780
  // (`?? 0`, `?? ''`, `?? false`, `?? 0.0`). Compare against the computed
2626
2781
  // zeroLiteral so spelling variants like `0.0` collapse to the same skip
2627
2782
  // as `0`.
2628
- if (match.goFallback === zeroLiteral) continue
2629
- if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
2783
+ //
2784
+ // NOT a no-op for a nillable-lowered prop (#2248): its `interface{}`
2785
+ // field can be nil, and the typed hoisted local is what downstream
2786
+ // consumers (`Count: size`, memo env maps) assign from — a nil
2787
+ // interface into a concrete field is a compile error, so the local
2788
+ // must exist even when the fallback equals the zero value.
2789
+ if (!nullishLowered) {
2790
+ if (match.goFallback === zeroLiteral) continue
2791
+ if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
2792
+ }
2630
2793
  // The JSX-side identifier is the natural local name; suffix with `_` if it
2631
2794
  // collides with a Go keyword or a local we already emit.
2632
2795
  let varName = match.propName
@@ -2634,21 +2797,43 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2634
2797
  varName += '_'
2635
2798
  }
2636
2799
  localTaken.add(varName)
2637
- result.set(match.propName, { varName, fieldName, goFallback: match.goFallback, zeroLiteral })
2800
+ result.set(match.propName, {
2801
+ varName,
2802
+ fieldName,
2803
+ goFallback: match.goFallback,
2804
+ zeroLiteral,
2805
+ ...(nullishLowered ? { assertType: concreteType } : {}),
2806
+ })
2638
2807
  }
2639
2808
  return result
2640
2809
  }
2641
2810
 
2642
2811
  /**
2643
- * Parse a signal-time initial value of the form `props.X ?? <literal>` into
2644
- * the source prop name and the Go-formatted fallback. Returns null when the
2645
- * expression isn't a `??` against a property access on `propsObjectName`, or
2646
- * the fallback isn't a simple literal `goPropDefault` can translate.
2812
+ * Parse a signal-time initial value of the form `props.X ?? <literal>`
2813
+ * or, for destructured components, `x ?? <literal>` into the source prop
2814
+ * name and the Go-formatted fallback. Returns null when the expression
2815
+ * isn't that shape or the fallback isn't a simple literal `goPropDefault`
2816
+ * can translate.
2817
+ *
2818
+ * `preParsed` (the signal's best-effort `ParsedExpr`) is matched
2819
+ * structurally when available; the regex handles only the props-object
2820
+ * member form for callers without a tree (memo computations). A bare
2821
+ * identifier can shadow a same-named prop (loop/callback params — the
2822
+ * `collectNullishConsumedPropNames` limitation class), so every caller
2823
+ * validates the returned name against `ir.metadata.propsParams`.
2647
2824
  *
2648
2825
  * Keeps the original prop reference (not just the resolved value) so
2649
2826
  * caller-supplied non-zero inputs are honoured.
2650
2827
  */
2651
- private extractPropFallback(initialValue: string): { propName: string; goFallback: string } | null {
2828
+ private extractPropFallback(
2829
+ initialValue: string,
2830
+ preParsed?: ParsedExpr,
2831
+ ): { propName: string; goFallback: string } | null {
2832
+ const structural = preParsed ? this.extractPropFallbackFromParsed(preParsed) : null
2833
+ if (structural) return structural
2834
+
2835
+ // Regex fallback for callers without a tree (memo computation strings)
2836
+ // and for props-object shapes the structural match doesn't model.
2652
2837
  if (!this.state.propsObjectName) return null
2653
2838
  const trimmed = initialValue.trim()
2654
2839
  const name = this.state.propsObjectName
@@ -2662,12 +2847,64 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2662
2847
  return { propName: m[1], goFallback }
2663
2848
  }
2664
2849
 
2850
+ /** Structural half of {@link extractPropFallback}. */
2851
+ private extractPropFallbackFromParsed(
2852
+ preParsed: ParsedExpr,
2853
+ ): { propName: string; goFallback: string } | null {
2854
+ if (preParsed.kind !== 'logical' || preParsed.op !== '??') return null
2855
+ const left = preParsed.left
2856
+ const propName =
2857
+ left.kind === 'identifier' && !this.state.propsObjectName
2858
+ ? left.name
2859
+ : left.kind === 'member' &&
2860
+ !left.computed &&
2861
+ left.object.kind === 'identifier' &&
2862
+ left.object.name === this.state.propsObjectName
2863
+ ? left.property
2864
+ : null
2865
+ if (!propName) return null
2866
+ // A negative fallback (`?? -1`) parses as unary minus around a number
2867
+ // literal, not a literal.
2868
+ let right = preParsed.right
2869
+ let negate = ''
2870
+ if (right.kind === 'unary' && right.op === '-') {
2871
+ negate = '-'
2872
+ right = right.argument
2873
+ }
2874
+ if (right.kind !== 'literal') return null
2875
+ if (negate && right.literalType !== 'number') return null
2876
+ // A string fallback is already the decoded value — quote it directly;
2877
+ // routing it through `goPropDefault` would strip JSON.stringify's outer
2878
+ // quotes and escape the body a second time (`"` → `\\\"`).
2879
+ if (right.literalType === 'string') {
2880
+ return { propName, goFallback: JSON.stringify(right.value) }
2881
+ }
2882
+ // Numbers keep their source spelling when available (`raw` is only
2883
+ // populated for numbers); booleans/null stringify losslessly.
2884
+ const goFallback = goPropDefault(negate + (right.raw ?? String(right.value)))
2885
+ if (goFallback === null) return null
2886
+ return { propName, goFallback }
2887
+ }
2888
+
2665
2889
  /**
2666
2890
  * Extract the prop name from a signal's `props.xxx`-pattern initialValue,
2667
2891
  * e.g. `"props.initial ?? 0"` → `"initial"`, `"props.checked"` → `"checked"`.
2892
+ * For destructured components (`propsObjectName` null) the same shapes are
2893
+ * matched structurally on `preParsed` with an identifier left operand
2894
+ * (`size ?? 0` → `"size"`); callers validate the name against
2895
+ * `propsParams`, which filters shadowing locals.
2668
2896
  */
2669
- private extractPropNameFromInitialValue(initialValue: string): string | null {
2670
- if (!this.state.propsObjectName) return null
2897
+ private extractPropNameFromInitialValue(initialValue: string, preParsed?: ParsedExpr): string | null {
2898
+ if (!this.state.propsObjectName) {
2899
+ if (
2900
+ preParsed?.kind === 'logical' &&
2901
+ (preParsed.op === '??' || preParsed.op === '||') &&
2902
+ preParsed.left.kind === 'identifier'
2903
+ ) {
2904
+ return preParsed.left.name
2905
+ }
2906
+ return null
2907
+ }
2671
2908
  const trimmed = initialValue.trim()
2672
2909
  const name = this.state.propsObjectName
2673
2910
 
@@ -2757,7 +2994,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2757
2994
  renderElement(element: IRElement): string {
2758
2995
  const tag = element.tag
2759
2996
  const attrs = this.renderAttributes(element)
2760
- const children = this.renderChildren(element.children)
2997
+ const dangerousHtml = this.renderDangerousInnerHtml(element)
2998
+ const children = dangerousHtml !== null ? dangerousHtml : this.renderChildren(element.children)
2761
2999
 
2762
3000
  let hydrationAttrs = ''
2763
3001
  if (element.needsScope) {
@@ -2792,6 +3030,28 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2792
3030
  return `<${tag}${attrs}${hydrationAttrs}>${children}</${tag}>`
2793
3031
  }
2794
3032
 
3033
+ /**
3034
+ * `dangerouslySetInnerHTML={{ __html: '...' }}` (#2207) — see the Blade
3035
+ * adapter's identical helper for the full rationale. `null` means the
3036
+ * attribute is absent (caller falls through to normal `renderChildren`);
3037
+ * a non-`null` string (possibly `''`) replaces the children outright.
3038
+ */
3039
+ private renderDangerousInnerHtml(element: IRElement): string | null {
3040
+ const resolution = resolveDangerousInnerHtml(element)
3041
+ if (!resolution) return null
3042
+ if (resolution.kind === 'dynamic') {
3043
+ this.errors.push(dangerousInnerHtmlDiagnostic(resolution.expr, resolution.loc))
3044
+ return ''
3045
+ }
3046
+ const violation = dangerousInnerHtmlMetacharViolation(resolution.html, this.name)
3047
+ if (violation) {
3048
+ const attr = element.attrs.find(isDangerousInnerHtmlAttr)!
3049
+ this.errors.push(dangerousInnerHtmlDiagnostic(`{ __html: ${JSON.stringify(resolution.html)} }`, attr.loc, violation))
3050
+ return ''
3051
+ }
3052
+ return resolution.html
3053
+ }
3054
+
2795
3055
  renderExpression(expr: IRExpression): string {
2796
3056
  // @client directive: render a comment marker; ClientJS evaluates the
2797
3057
  // expression via updateClientMarker().
@@ -3386,9 +3646,54 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3386
3646
  const wrapLeft = wrapIfMultiToken(emit(left))
3387
3647
  const wrapRight = wrapIfMultiToken(emit(right))
3388
3648
  if (op === '&&') return `and ${wrapLeft} ${wrapRight}`
3649
+ // `??` on a nillable prop needs true JS nullish semantics (#2248): Go's
3650
+ // `or` is truthiness-based, so `{{or .Label "Default"}}` falls back on a
3651
+ // present-but-empty `""` — JS `??` keeps it. `bf_nullish` tests nil-ness
3652
+ // only. Non-nillable operands keep `or`: their concrete Go type can't
3653
+ // represent "absent" at all, so `or` and `??` are indistinguishable there.
3654
+ if (op === '??' && this.nillablePropNameOf(left) !== null) {
3655
+ return `bf_nullish ${wrapLeft} ${wrapRight}`
3656
+ }
3389
3657
  return `or ${wrapLeft} ${wrapRight}`
3390
3658
  }
3391
3659
 
3660
+ /**
3661
+ * The nillable-prop name a `??` left operand refers to, or null. Matches
3662
+ * the two prop-reference shapes (`label` destructured, `props.label`
3663
+ * object-style) against `nullishConsumedPropNames ∩ nillablePropNames`.
3664
+ *
3665
+ * The intersection matters: `nillablePropNames` alone OVERAPPROXIMATES —
3666
+ * it is collected before local type aliases are registered, so an
3667
+ * alias-typed prop (`placement?: TooltipPlacement`) can sit in the set
3668
+ * while its emitted struct field is the concrete alias type. On such a
3669
+ * concrete field "absent" is invisible (the zero value), so the
3670
+ * truthiness-based `or` is the correct approximation and `bf_nullish`
3671
+ * would wrongly KEEP the zero value (e.g. Tooltip's
3672
+ * `placementClasses[props.placement ?? 'top']` would resolve to no
3673
+ * class for an omitted placement). Requiring `nullishConsumedPropNames`
3674
+ * membership pins the
3675
+ * gate to props the `??` analysis actually saw — the same set that drives
3676
+ * the `interface{}` flip in `resolvePropGoType`.
3677
+ */
3678
+ private nillablePropNameOf(expr: ParsedExpr): string | null {
3679
+ let name: string | null = null
3680
+ if (expr.kind === 'identifier') {
3681
+ name = expr.name
3682
+ } else if (
3683
+ expr.kind === 'member' &&
3684
+ !expr.computed &&
3685
+ expr.object.kind === 'identifier' &&
3686
+ expr.object.name === this.state.propsObjectName
3687
+ ) {
3688
+ name = expr.property
3689
+ }
3690
+ return name !== null &&
3691
+ this.state.nullishConsumedPropNames.has(name) &&
3692
+ this.state.nillablePropNames.has(name)
3693
+ ? name
3694
+ : null
3695
+ }
3696
+
3392
3697
  // JSX-level ternaries (`{expr ? a : b}`) are handled at the IR level as
3393
3698
  // IRConditional (via convertConditionToGo → renderConditionExpr). This method
3394
3699
  // is only reached for ternaries nested inside other ParsedExpr trees (e.g.
@@ -4101,10 +4406,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4101
4406
 
4102
4407
  /**
4103
4408
  * Render a predicate for use in Go template `{{if}}` conditions, substituting
4104
- * the loop parameter (e.g. `t` in `t.done`) with dot notation.
4409
+ * the loop parameter (e.g. `t` in `t.done`) with dot notation. `datumField`
4410
+ * (#2228) is the wrapper struct's datum-carrying field name (e.g. `"Todo"`)
4411
+ * for a loop whose body is a child component — see `wrapperDatumField` — so
4412
+ * `t.done` qualifies through it (`.Todo.Done`) instead of the bare `.Done`
4413
+ * `html/template` can't resolve on the wrapper Props struct. `undefined` for
4414
+ * a non-wrapper (plain-element-body) loop, where `.` already IS the datum.
4105
4415
  */
4106
- private renderPredicateCondition(pred: ParsedExpr, param: string): string {
4107
- return this.renderFilterExpr(pred, param)
4416
+ private renderPredicateCondition(pred: ParsedExpr, param: string, datumField?: string | null): string {
4417
+ return this.renderFilterExpr(pred, param, new Map(), datumField ?? undefined)
4108
4418
  }
4109
4419
 
4110
4420
  /** Whether an expression needs parentheses when used in and/or. */
@@ -4138,12 +4448,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4138
4448
  * Render a filter predicate expression (`t => !t.done`, or a block body
4139
4449
  * normalized to one — #2040). `localVarMap` is a vestigial empty default kept
4140
4450
  * on the recursion; block-body locals are now inlined upstream, so no caller
4141
- * populates it.
4451
+ * populates it. `datumField` (#2228) qualifies a bare `param` reference (and
4452
+ * `param.xxx` member/call access) through the wrapper struct's
4453
+ * datum-carrying field — see `wrapperDatumField` — for a loop whose `.` is a
4454
+ * child-component wrapper Props struct rather than the raw datum itself.
4455
+ * `undefined` for every other caller (non-loop `.filter()`/`.find()`/etc.,
4456
+ * or a plain-element-body loop), which keeps emitting the bare `.`/`.Field`
4457
+ * this method always has.
4142
4458
  */
4143
4459
  private renderFilterExpr(
4144
4460
  expr: ParsedExpr,
4145
4461
  param: string,
4146
- localVarMap: Map<string, string> = new Map()
4462
+ localVarMap: Map<string, string> = new Map(),
4463
+ datumField?: string
4147
4464
  ): string {
4148
4465
  // Top-of-recursion: clear the unsupported sentinel so a previous filter
4149
4466
  // expression's failure doesn't poison this one. Parents (`member` /
@@ -4153,7 +4470,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4153
4470
  if (this.filterExprDepth === 0) this.filterExprUnsupported = false
4154
4471
  this.filterExprDepth++
4155
4472
  try {
4156
- return this.renderFilterExprNode(expr, param, localVarMap)
4473
+ return this.renderFilterExprNode(expr, param, localVarMap, datumField)
4157
4474
  } finally {
4158
4475
  this.filterExprDepth--
4159
4476
  }
@@ -4162,12 +4479,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4162
4479
  private renderFilterExprNode(
4163
4480
  expr: ParsedExpr,
4164
4481
  param: string,
4165
- localVarMap: Map<string, string>
4482
+ localVarMap: Map<string, string>,
4483
+ datumField?: string
4166
4484
  ): string {
4485
+ // #2228: `paramPrefix` prepends the wrapper's datum-carrying field to a
4486
+ // loop-param access (`.Todo` under a wrapper, `''` otherwise). Two derived
4487
+ // forms because Go template spells "the dot itself" as `.` but "field on
4488
+ // the dot" as `.Field` — a naive shared `'.'` prefix would emit `..Done`
4489
+ // for the non-wrapper member case:
4490
+ // bare `t` → `paramDot` (`.Todo` / `.`)
4491
+ // `t.done` → `${paramPrefix}.Done` (`.Todo.Done` / `.Done`)
4492
+ const paramPrefix = datumField ? `.${datumField}` : ''
4493
+ const paramDot = paramPrefix || '.'
4167
4494
  switch (expr.kind) {
4168
4495
  case 'identifier': {
4169
4496
  if (expr.name === param) {
4170
- return '.'
4497
+ return paramDot
4171
4498
  }
4172
4499
  // A local variable mapped to a signal.
4173
4500
  const signal = localVarMap.get(expr.name)
@@ -4187,9 +4514,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4187
4514
  return String(expr.value)
4188
4515
 
4189
4516
  case 'member': {
4190
- // t.done -> .Done
4517
+ // t.done -> .Done (or .Todo.Done under a wrapper struct, #2228)
4191
4518
  if (expr.object.kind === 'identifier' && expr.object.name === param) {
4192
- return `.${capitalizeFieldName(expr.property)}`
4519
+ return `${paramPrefix}.${capitalizeFieldName(expr.property)}`
4193
4520
  }
4194
4521
  // `.length` on a higher-order filter result (e.g.
4195
4522
  // `x.tags.filter(t => t.active).length > 0`). Reuse
@@ -4202,21 +4529,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4202
4529
  const innerHO = this.higherOrderShapeOf(expr.object)
4203
4530
  if (innerHO && innerHO.method === 'filter') {
4204
4531
  const lenExpr = this.renderFilterLengthExpr(innerHO, e =>
4205
- this.renderFilterExpr(e, param, localVarMap),
4532
+ this.renderFilterExpr(e, param, localVarMap, datumField),
4206
4533
  )
4207
4534
  if (lenExpr) return `(${lenExpr})`
4208
4535
  }
4209
4536
  }
4210
4537
  // Nested member access or local var.prop.
4211
- const obj = this.renderFilterExpr(expr.object, param, localVarMap)
4538
+ const obj = this.renderFilterExpr(expr.object, param, localVarMap, datumField)
4212
4539
  if (this.filterExprUnsupported) return 'false'
4213
4540
  return `${obj}.${capitalizeFieldName(expr.property)}`
4214
4541
  }
4215
4542
 
4216
4543
  case 'call': {
4217
- // `t.isDone()` -> `.IsDone`
4544
+ // `t.isDone()` -> `.IsDone` (or `.Todo.IsDone` under a wrapper, #2228)
4218
4545
  if (expr.callee.kind === 'member' && expr.callee.object.kind === 'identifier' && expr.callee.object.name === param) {
4219
- return `.${capitalizeFieldName(expr.callee.property)}`
4546
+ return `${paramPrefix}.${capitalizeFieldName(expr.callee.property)}`
4220
4547
  }
4221
4548
  // Signal calls: `filter()` -> `$.Filter`
4222
4549
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
@@ -4232,13 +4559,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4232
4559
  if (asCallbackMethodCall(expr) !== null) {
4233
4560
  return this.refuseFilterExprNode(expr)
4234
4561
  }
4235
- const result = this.renderFilterExpr(expr.callee, param, localVarMap)
4562
+ const result = this.renderFilterExpr(expr.callee, param, localVarMap, datumField)
4236
4563
  if (this.filterExprUnsupported) return 'false'
4237
4564
  return result
4238
4565
  }
4239
4566
 
4240
4567
  case 'unary': {
4241
- const arg = this.renderFilterExpr(expr.argument, param, localVarMap)
4568
+ const arg = this.renderFilterExpr(expr.argument, param, localVarMap, datumField)
4242
4569
  if (this.filterExprUnsupported) return 'false'
4243
4570
  if (expr.op === '!') {
4244
4571
  // Wrap in parens if arg is a function call (eq, ne, gt, …).
@@ -4252,9 +4579,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4252
4579
  }
4253
4580
 
4254
4581
  case 'binary': {
4255
- const left = this.renderFilterExpr(expr.left, param, localVarMap)
4582
+ const left = this.renderFilterExpr(expr.left, param, localVarMap, datumField)
4256
4583
  if (this.filterExprUnsupported) return 'false'
4257
- const right = this.renderFilterExpr(expr.right, param, localVarMap)
4584
+ const right = this.renderFilterExpr(expr.right, param, localVarMap, datumField)
4258
4585
  if (this.filterExprUnsupported) return 'false'
4259
4586
 
4260
4587
  switch (expr.op) {
@@ -4286,9 +4613,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4286
4613
  }
4287
4614
 
4288
4615
  case 'logical': {
4289
- const left = this.renderFilterExpr(expr.left, param, localVarMap)
4616
+ const left = this.renderFilterExpr(expr.left, param, localVarMap, datumField)
4290
4617
  if (this.filterExprUnsupported) return 'false'
4291
- const right = this.renderFilterExpr(expr.right, param, localVarMap)
4618
+ const right = this.renderFilterExpr(expr.right, param, localVarMap, datumField)
4292
4619
  if (this.filterExprUnsupported) return 'false'
4293
4620
  if (expr.op === '&&') {
4294
4621
  return `and (${left}) (${right})`
@@ -4409,6 +4736,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4409
4736
  return '""'
4410
4737
  }
4411
4738
 
4739
+ // #2224: inside a `renderUnrolledStaticElementLoop` pass, there is no
4740
+ // real `{{range}}` establishing a per-item dot context — every
4741
+ // expression in the body must instead resolve directly against the
4742
+ // active item via `evaluateStaticLiteral` and lower to a literal Go
4743
+ // value. Highest priority (even over the static-record-index / inlined-
4744
+ // const early returns below): those string-keyed checks were never
4745
+ // designed to reason about a loop item and could otherwise misfire on
4746
+ // text that merely happens to match their shape. `analyzeBakeable-
4747
+ // StaticElementLoop` has already verified every expression in this body
4748
+ // resolves for every item, so the `staticLoopBakeFailed` branch is a
4749
+ // defensive invariant, not a real code path — if it ever fires, the two
4750
+ // passes disagreed and the safest move is a sentinel, not a `.Field`
4751
+ // reference with no range context behind it.
4752
+ if (this.staticLoopItemStack.length > 0) {
4753
+ const top = this.staticLoopItemStack[this.staticLoopItemStack.length - 1]
4754
+ const parsedForBake = preParsed ?? parseExpression(trimmed)
4755
+ const resolved = evaluateStaticLiteral(parsedForBake, new Map([[top.param, top.item]]))
4756
+ const literal = resolved !== null ? scalarToGoLiteral(resolved.value) : null
4757
+ if (literal !== null) {
4758
+ // Deliberately leave `out.parsed` unset — a `template-literal`-kind
4759
+ // source (`` `Hi ${item.label}` ``) must NOT be treated as
4760
+ // already-fragment text by `renderExpression`'s `isTemplateFragment`
4761
+ // check (it would skip the `{{...}}` wrap and print the Go literal's
4762
+ // quote characters raw into the HTML).
4763
+ return literal
4764
+ }
4765
+ this.staticLoopBakeFailed = true
4766
+ return '""'
4767
+ }
4768
+
4412
4769
  // `IDENT['key']` over a module object-literal const with a STRING-LITERAL key
4413
4770
  // is a fully static lookup — resolve it at compile time. The generic member
4414
4771
  // lowering below would otherwise capitalize the bracket access into a field
@@ -4424,7 +4781,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4424
4781
  // the generic lowering would reference a nonexistent `.TotalPages` field).
4425
4782
  // Only pure numeric / single-quoted-string initializers qualify; anything
4426
4783
  // else may be runtime-dependent.
4427
- if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) {
4784
+ //
4785
+ // #2236: this is a string-keyed fast path over `jsExpr` reached directly
4786
+ // by call sites like attribute emission (`key={count}` → `data-key`) that
4787
+ // never go through `identifier()`'s loop-shadow guards below — so it must
4788
+ // carry its OWN guard. When `.map((count) => ...)` shadows the outer
4789
+ // `const count = 7`, the occurrence inside the loop body must resolve to
4790
+ // the range value (via the normal parse-and-lower fallthrough), not the
4791
+ // outer literal.
4792
+ if (!this.isLoopShadowedName(trimmed) && /^[A-Za-z_$][\w$]*$/.test(trimmed)) {
4428
4793
  const litConst = (this.state.localConstants ?? []).find(c => c.name === trimmed)
4429
4794
  if (litConst?.value !== undefined) {
4430
4795
  const v = litConst.value.trim()
@@ -4495,6 +4860,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4495
4860
  return this.renderParsedExpr(parsed)
4496
4861
  }
4497
4862
 
4863
+ /**
4864
+ * Whether `name` at the CURRENT emission position is bound by an enclosing
4865
+ * loop callback — its item param (`loopParamStack` top), an outer loop's
4866
+ * range variable, a hoisted loop var, or a destructured binding name
4867
+ * (`loopBindingStack`, which is the ONLY place destructured callbacks
4868
+ * record their names; they push `''` onto `loopParamStack`). Shared by the
4869
+ * string-keyed fast paths (#2236, #2242 Copilot review) that resolve raw
4870
+ * `jsExpr` text before `identifier()`'s own guards can see it. Mirrors the
4871
+ * checks in `resolveModuleStringConst` / `resolveModuleNumericConst`.
4872
+ */
4873
+ private isLoopShadowedName(name: string): boolean {
4874
+ return (
4875
+ (this.loopParamStack.length > 0 &&
4876
+ this.loopParamStack[this.loopParamStack.length - 1] === name) ||
4877
+ this.loopVarRefCount.has(name) ||
4878
+ this.isOuterLoopParam(name) ||
4879
+ this.loopBindingStack.some(bindings => bindings.has(name))
4880
+ )
4881
+ }
4882
+
4498
4883
  /**
4499
4884
  * Resolve `IDENT['key']` / `IDENT["key"]` where `IDENT` is a module-scope
4500
4885
  * object-literal const and the key is a string literal — a compile-time-static
@@ -4512,6 +4897,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4512
4897
  // ordinary props/locals never match.
4513
4898
  /^([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)$/.exec(jsExpr)
4514
4899
  if (!m) return null
4900
+ // The base name may be an enclosing loop callback's own (shadowing)
4901
+ // param (`rows.map((cfg) => cfg.x)` under a module `const cfg = {...}`)
4902
+ // — the record-member sibling of the #2236 bare-identifier gap, found
4903
+ // by the loop-param-shadows-record-const fixture. Fall through to the
4904
+ // generic lowering, which resolves the member through the loop binding.
4905
+ if (this.isLoopShadowedName(m[1])) return null
4515
4906
  const key = m[2] ?? m[3]
4516
4907
  const constInfo = (this.state.localConstants ?? []).find(
4517
4908
  c => c.name === m[1] && c.isModule,
@@ -4974,6 +5365,68 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4974
5365
  return undefined
4975
5366
  }
4976
5367
 
5368
+ /**
5369
+ * #2228: the Go field name on the wrapper Props struct that carries the
5370
+ * loop datum, for a loop whose body IS a child component (`loop.childComponent`,
5371
+ * e.g. `.TodoItems` ranging over `TodoItemProps`). `{{range}}`'s dot context
5372
+ * for such a loop is the WHOLE wrapper struct (`TodoItemProps{ Todo Todo,
5373
+ * OnToggle ..., ... }`), not the raw per-item datum — so a filter predicate
5374
+ * (`t => !t.done`) referencing the loop param can't lower `t.done` straight
5375
+ * to `.Done` (no such top-level field; `html/template` fails at execute time
5376
+ * with `can't evaluate field Done in type TodoItemProps`). The datum lives
5377
+ * nested under whichever child prop was PASSED the loop param verbatim
5378
+ * (`todo={todo}` → field `Todo`, from `capitalizeFieldName('todo')` — the
5379
+ * SAME derivation `generateInputStruct`/`generatePropsStruct` use for every
5380
+ * other prop-to-field mapping, so this never invents a field name the
5381
+ * generated struct doesn't actually have). Returns `null` for a non-wrapper
5382
+ * loop, or when no prop's value is a bare reference to the loop param (the
5383
+ * datum isn't forwarded at all — nothing to qualify through).
5384
+ */
5385
+ private wrapperDatumField(loop: {
5386
+ childComponent?: IRLoopChildComponent
5387
+ param: string
5388
+ }): string | null {
5389
+ if (!loop.childComponent) return null
5390
+ for (const prop of loop.childComponent.props) {
5391
+ if (prop.isEventHandler) continue
5392
+ if (prop.value.kind !== 'expression') continue
5393
+ const parsed = prop.value.parsed
5394
+ const isBareParamRef = parsed
5395
+ ? parsed.kind === 'identifier' && parsed.name === loop.param
5396
+ : prop.value.expr.trim() === loop.param
5397
+ if (isBareParamRef) return capitalizeFieldName(prop.name)
5398
+ }
5399
+ return null
5400
+ }
5401
+
5402
+ /**
5403
+ * Memoized bakeability check for a static-array loop whose body is a
5404
+ * single child component (#2208) — see `analyzeBakeableStaticChildLoop`'s
5405
+ * docstring. Accepts either an `IRLoop` (the `renderLoop` gate) or a
5406
+ * `NestedComponentInfo` (`generateNewPropsFunction`/Input-struct sites) —
5407
+ * both carry the same `loopArrayParsed`/`loopParam`/`loopKey`/props data,
5408
+ * just under different field names, so this normalizes to one shape and
5409
+ * caches by marker id so all three call sites agree.
5410
+ */
5411
+ private getBakedStaticChildLoop(
5412
+ markerId: string,
5413
+ childComponent: { props: IRLoopChildComponent['props'] },
5414
+ arrayParsed: ParsedExpr | undefined,
5415
+ param: string | undefined,
5416
+ key: string | undefined,
5417
+ ): BakedStaticChildLoop | null {
5418
+ if (this.bakedStaticChildLoopCache.has(markerId)) {
5419
+ return this.bakedStaticChildLoopCache.get(markerId) ?? null
5420
+ }
5421
+ const result = analyzeBakeableStaticChildLoop(
5422
+ { props: childComponent.props, loopArrayParsed: arrayParsed, loopParam: param, loopKey: key },
5423
+ this.state.localConstants,
5424
+ { isNameShadowed: name => this.state.staticLoopSourceBoundNames.has(name) },
5425
+ )
5426
+ this.bakedStaticChildLoopCache.set(markerId, result)
5427
+ return result
5428
+ }
5429
+
4977
5430
  renderLoop(loop: IRLoop): string {
4978
5431
  // clientOnly loops: emit SSR markers so the client can insert DOM nodes. The
4979
5432
  // marker id disambiguates sibling `.map()` calls under the same parent.
@@ -5036,8 +5489,44 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5036
5489
  // Phase A/B) no longer refuses `static-array-from-props`'s `([emoji,
5037
5490
  // users]) => ...` param first. Cross-adapter policy: Jinja / ERB apply the
5038
5491
  // same narrow check in their own `renderLoop` (see `jinja-adapter.ts`).
5492
+ // #2208: a static-array loop whose body is a single child component
5493
+ // (`loop.childComponent`) with a plain-value prop set can be BAKED —
5494
+ // every per-item prop and data-key resolves to a compile-time-known Go
5495
+ // literal (see `analyzeBakeableStaticChildLoop`), so the constructor
5496
+ // (`generateNewPropsFunction`'s `staticWithoutBody` path) can emit the
5497
+ // child instances directly instead of requiring the loop array bind as
5498
+ // a template variable at all. Memoized by marker id so this gate and
5499
+ // the constructor's later baking agree. A plain-ELEMENT body (no
5500
+ // `childComponent`) is NOT handled by baking and keeps refusing below —
5501
+ // see the go-only follow-up issue for that narrower gap.
5502
+ const bakedChildLoop = loop.childComponent
5503
+ ? this.getBakedStaticChildLoop(loop.markerId, loop.childComponent, loop.arrayParsed, loop.param, loop.key ?? undefined)
5504
+ : null
5505
+
5506
+ // #2224 shape 1: a static-array loop whose body is a plain ELEMENT tree
5507
+ // (no child component) has no `.{Name}s`-shaped template target for
5508
+ // #2208's baking to feed — there's nothing for `{{range}}` to iterate at
5509
+ // all once the array itself can't bind as a template variable. Rather
5510
+ // than synthesizing a Go struct type for the item shape (see the #2224
5511
+ // issue body), unroll the body once per item at template-generation
5512
+ // time instead, substituting each item's statically-known field values
5513
+ // directly — see `analyzeBakeableStaticElementLoop`'s docstring for the
5514
+ // exact (conservative) acceptance gate. `null` here means the shape
5515
+ // isn't (yet) bakeable this way; the existing gates below keep firing
5516
+ // exactly as before.
5517
+ const bakedElementLoop = loop.childComponent
5518
+ ? null
5519
+ : analyzeBakeableStaticElementLoop(
5520
+ loop,
5521
+ this.state.localConstants,
5522
+ { isNameShadowed: name => this.state.staticLoopSourceBoundNames.has(name) },
5523
+ )
5524
+ if (bakedElementLoop) {
5525
+ return this.renderUnrolledStaticElementLoop(loop, bakedElementLoop.items)
5526
+ }
5527
+
5039
5528
  const arrayName = loop.array.trim()
5040
- if (/^[A-Za-z_$][\w$]*$/.test(arrayName)) {
5529
+ if (bakedChildLoop === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
5041
5530
  const arrayConst = this.state.localConstants.find(c => c.name === arrayName)
5042
5531
  if (arrayConst && !arrayConst.isModule && arrayConst.parsed && !this.isStringExpr(arrayConst.parsed, new Set())) {
5043
5532
  this.state.errors.push({
@@ -5053,7 +5542,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5053
5542
  }
5054
5543
  }
5055
5544
 
5056
- let goArray = this.convertExpressionToGo(loop.array)
5545
+ // #2224 shape 2: when the body IS a child component, `goArray` gets
5546
+ // unconditionally overwritten to `.${componentName}s` below regardless
5547
+ // of what this call returns — so for an INLINE array-literal source
5548
+ // (`[{ label: 'Alpha' }, ...].map(item => <ListItem .../>)`), calling
5549
+ // `convertExpressionToGo` on the raw literal text here is pure waste at
5550
+ // best. At worst it's actively harmful: an array-literal-of-objects
5551
+ // fails the shared `isSupported` gate (`object-literal` is refused
5552
+ // standalone — expression-parser.ts), so this call would push a BF101
5553
+ // as a side effect even though `bakedChildLoop` above (via
5554
+ // `resolveStaticLoopSource`, which evaluates the literal directly
5555
+ // instead of going through `isSupported`) already resolved the SAME
5556
+ // loop just fine. Skip the call entirely for a child-component body —
5557
+ // baked or not, dynamic `.map()` over a real prop/signal array included
5558
+ // — so no spurious diagnostic is ever recorded for a value nothing ends
5559
+ // up consuming.
5560
+ let goArray = loop.childComponent ? '' : this.convertExpressionToGo(loop.array)
5057
5561
  const param = loop.param
5058
5562
  let index = loop.index || '_'
5059
5563
 
@@ -5174,9 +5678,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5174
5678
  let filterCond: string
5175
5679
 
5176
5680
  if (loop.filterPredicate.predicate) {
5681
+ // #2228: for a wrapper-slice loop (`.TodoItems` ranging over
5682
+ // TodoItemProps), `.` in the predicate is the WHOLE wrapper struct —
5683
+ // qualify `loop.filterPredicate.param` references through the
5684
+ // datum-carrying field (`.Todo.Done`, not `.Done`) so the emitted
5685
+ // `{{if}}` only ever dereferences fields the wrapper struct actually
5686
+ // has. `wrapperDatumField` returns `null` for a plain-element-body
5687
+ // loop, where `.` already IS the datum — `renderPredicateCondition`
5688
+ // then keeps emitting the bare `.`/`.Field` form unchanged.
5689
+ const datumField = this.wrapperDatumField(loop)
5177
5690
  filterCond = this.renderPredicateCondition(
5178
5691
  loop.filterPredicate.predicate,
5179
- loop.filterPredicate.param
5692
+ loop.filterPredicate.param,
5693
+ datumField
5180
5694
  )
5181
5695
  } else {
5182
5696
  filterCond = 'true'
@@ -5188,6 +5702,61 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5188
5702
  return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
5189
5703
  }
5190
5704
 
5705
+ /**
5706
+ * #2224 shape 1: render a static-array, plain-element-body loop's per-item
5707
+ * markup ONCE PER ITEM at template-generation time instead of a Go
5708
+ * `{{range}}` — `analyzeBakeableStaticElementLoop` has already verified
5709
+ * every expression in `loop.children` resolves against each item. Keeps
5710
+ * the SAME `<!--bf-loop:id--> ... <!--/bf-loop:id-->` marker pair a
5711
+ * dynamic loop emits (so the CSR-side static `forEach` wiring, compiled by
5712
+ * the separate `ir-to-client-js.ts` pass and untouched by this change,
5713
+ * still finds the same DOM range), and pushes `loop.param` /
5714
+ * `loop.depth` onto the SAME stacks `renderLoop`'s `{{range}}` path uses,
5715
+ * so `data-key`/`data-key-N` attribute-name derivation
5716
+ * (`renderAttributes`) is unaffected by which path rendered the loop. No
5717
+ * `itemMarker` (`loopItemMarker`) call: the analysis gate already refused
5718
+ * `bodyIsMultiRoot` / `bodyIsItemConditional` bodies, so it would always
5719
+ * return `''` here anyway.
5720
+ */
5721
+ private renderUnrolledStaticElementLoop(loop: IRLoop, items: readonly unknown[]): string {
5722
+ this.inLoop = true
5723
+ this.loopWrapperStack.push(false)
5724
+ this.loopKeyDepthStack.push(loop.depth)
5725
+ this.loopScalarItemStack.push(this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null)
5726
+ this.loopParamStack.push(loop.param)
5727
+
5728
+ let body = ''
5729
+ for (const item of items) {
5730
+ this.staticLoopItemStack.push({ param: loop.param, item })
5731
+ body += this.renderChildren(loop.children)
5732
+ this.staticLoopItemStack.pop()
5733
+ if (this.staticLoopBakeFailed) {
5734
+ // Invariant violation (see `staticLoopBakeFailed`'s docstring): the
5735
+ // gate and this render pass disagreed. Surface it loudly instead of
5736
+ // shipping a template with `""` sentinels silently spliced in.
5737
+ this.staticLoopBakeFailed = false
5738
+ this.state.errors.push({
5739
+ code: 'BF101',
5740
+ severity: 'error',
5741
+ message: `Loop array \`${loop.array.trim()}\` could not be fully unrolled — an expression in the loop body did not resolve against every item as the compile-time analysis expected.`,
5742
+ loc: loop.loc ?? this.makeLoc(),
5743
+ suggestion: {
5744
+ message: 'This indicates a bug in the Go adapter\'s static-loop unrolling (#2224) rather than an unsupported source pattern; please file a bug with a reproduction.',
5745
+ },
5746
+ })
5747
+ break
5748
+ }
5749
+ }
5750
+
5751
+ this.loopParamStack.pop()
5752
+ this.loopScalarItemStack.pop()
5753
+ this.loopKeyDepthStack.pop()
5754
+ this.loopWrapperStack.pop()
5755
+ this.inLoop = false
5756
+
5757
+ return `{{bfComment "loop:${loop.markerId}"}}${body}{{bfComment "/loop:${loop.markerId}"}}`
5758
+ }
5759
+
5191
5760
  /**
5192
5761
  * Per-item `<!--bf-loop-i-->` / `<!--bf-loop-i:KEY-->` start marker emitted
5193
5762
  * inside a `{{range}}` body. Multi-root Fragment items get the bare anchor;
@@ -5574,6 +6143,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5574
6143
  // predicate (no BF101 / BF102). This keeps the BF102 remediation ("defer
5575
6144
  // it with /* @client */") accurate for attribute-only state.
5576
6145
  if (attr.clientOnly) continue
6146
+ // `dangerouslySetInnerHTML` never renders as an HTML attribute — it's
6147
+ // handled by `renderDangerousInnerHtml` instead, which replaces the
6148
+ // element's children. Skip it here so its `{ __html: ... }` object
6149
+ // literal never reaches the generic object-literal BF101 refusal
6150
+ // (which would double-report alongside the purpose-built one).
6151
+ if (isDangerousInnerHtmlAttr(attr)) continue
5577
6152
  // Rewrite JSX special-prop names to their HTML-attribute counterparts. The
5578
6153
  // Go template adapter has no JSX runtime to strip `key` / emit `data-key`,
5579
6154
  // so the rewrite happens at attribute-emit time. Mirror of the `key`