@barefootjs/go-template 0.31.4 → 0.31.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/go-template",
3
- "version": "0.31.4",
3
+ "version": "0.31.6",
4
4
  "description": "Go html/template adapter for BarefootJS - generates Go template files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  "directory": "packages/adapter-go-template"
50
50
  },
51
51
  "dependencies": {
52
- "@barefootjs/shared": "0.31.4"
52
+ "@barefootjs/shared": "0.31.6"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@barefootjs/jsx": ">=0.2.0",
@@ -67,9 +67,9 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@barefootjs/adapter-tests": "0.1.0",
70
- "@barefootjs/client": "0.31.4",
71
- "@barefootjs/jsx": "0.31.4",
72
- "@barefootjs/vite": "0.31.4",
70
+ "@barefootjs/client": "0.31.6",
71
+ "@barefootjs/jsx": "0.31.6",
72
+ "@barefootjs/vite": "0.31.6",
73
73
  "vite": "^6.0.0"
74
74
  }
75
75
  }
@@ -127,6 +127,7 @@ function collectNestedComponents(node: IRNode, result: NestedComponentInfo[]): v
127
127
  ...loop.childComponent,
128
128
  isDynamic: !loop.isStaticArray,
129
129
  isPropDerived: !!loop.isPropDerivedArray,
130
+ clientOnly: loop.clientOnly,
130
131
  loopKey: loop.key ?? undefined,
131
132
  loopParam: loop.param ?? undefined,
132
133
  bodyChildren: hasBodyChildren ? loop.childComponent.children : undefined,
@@ -32,6 +32,7 @@ import type {
32
32
  IRMetadata,
33
33
  TemplatePrimitiveRegistry,
34
34
  LoopBindingPathSegment,
35
+ LoopBindingSource,
35
36
  } from '@barefootjs/jsx'
36
37
  import {
37
38
  BaseAdapter,
@@ -74,6 +75,7 @@ import {
74
75
  resolveStaticLoopSource,
75
76
  collectLoopBoundNames,
76
77
  evaluateStaticLiteral,
78
+ BindingScope,
77
79
  } from '@barefootjs/jsx'
78
80
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
79
81
  import { BF_REGION, escapeHtml } from '@barefootjs/shared'
@@ -263,7 +265,37 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
263
265
  * populated it, not correctness across passes.
264
266
  */
265
267
  private bakedStaticChildLoopCache = new Map<string, BakedStaticChildLoop | null>()
266
- private loopParamStack: string[] = []
268
+ /**
269
+ * The one threaded, immutable scope of "names bound by an enclosing loop
270
+ * callback" (#2482 Stage 3 — replaces this adapter's own order-sensitive
271
+ * loop-param stack). Entered via `enterLoopRow(loop)` in `renderLoop` /
272
+ * `renderUnrolledStaticElementLoop`, bracketing preamble conversion,
273
+ * children rendering, AND the key-anchor (`loopItemMarker`) conversion —
274
+ * mirrors the Stage 1a/1b `ctx.scope` precedent in `jsx-to-ir.ts` and the
275
+ * Stage 2 template-string-adapter migration. `IRLoop` already structurally
276
+ * satisfies `LoopBindingSource` (`param`/`index`/`paramBindings`/
277
+ * `preamble`), so most call sites pass the loop node straight through.
278
+ *
279
+ * ONE Go-specific wrinkle `BindingScope`'s generic 'item' semantics don't
280
+ * know about: a `.keys()`-shape loop's callback param is the RANGE INDEX,
281
+ * not the range value — Go's `{{range}}` dot context there is the
282
+ * (discarded) value, not the key, so the key must never win the
283
+ * depth-0-item "resolves to `.`" fast path `isCurrentLoopItem` grants a
284
+ * plain `.map()` param. `renderLoop` enters such a loop's scope with an
285
+ * overridden EMPTY `param`, mirroring the historical empty-string push
286
+ * onto the old stack for the same shape — the key name stays
287
+ * resolvable only through `loopVarRefCount`'s existing `$name` machinery,
288
+ * unchanged by this migration.
289
+ *
290
+ * Destructured-binding ACCESSOR TEXT (`id` → `$__bf_item0.Id`) is Go-
291
+ * specific rendering payload `ScopeBinding` has no field for (by design —
292
+ * `BindingScope` only carries EXISTENCE/kind, not per-adapter accessor
293
+ * strings), so `loopBindingStack` stays as the accessor source of truth,
294
+ * pushed/popped in lockstep with `scope` at the same points. `scope`
295
+ * subsumes its EXISTENCE role (shadow-guard / dot-vs-`$name` decisions)
296
+ * everywhere except the accessor lookup itself.
297
+ */
298
+ private scope: BindingScope = BindingScope.EMPTY
267
299
  /**
268
300
  * Stack of `IRLoop.depth` values (innermost last), pushed/popped around
269
301
  * `renderChildren(loop.children)` in `renderLoop`. `renderAttributes`
@@ -450,6 +482,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
450
482
  this.state.referencedDerivedConsts = new Set()
451
483
  this.state.templateVarCounter = 0
452
484
  this.state.pendingChildrenDefines = []
485
+ this.scope = BindingScope.EMPTY
453
486
  this.primeCompileState(ir)
454
487
  this.state.stringValueNames = collectStringValueNames(ir)
455
488
  // #2448: self-register this component's derived-memo dependencies, so a
@@ -928,6 +961,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
928
961
  * caller-facing while Props/NewProps key local, so a one-sided check lets
929
962
  * the structs disagree on whether the field exists (a duplicate json tag,
930
963
  * or a dead Input field whose caller writes are silently ignored).
964
+ *
965
+ * `nestedArrayFields` (built by each call site) is ALREADY restricted to
966
+ * prop-derived loops (#2627) — a same-name coincidence with a loop that
967
+ * merely transforms a differently-shaped prop (`Object.entries(props.tags)
968
+ * .filter(...)` feeding a `<Tag>` loop, whose plural `Tags` happens to
969
+ * match a `tags: Record<...>` prop) must NOT shadow the prop's own field;
970
+ * the two are different Go types and the prop has no other field to land
971
+ * in. See the call sites' `nestedArrayFields` construction.
931
972
  */
932
973
  private isNestedArrayShadowed(
933
974
  param: { name: string; sourceName?: string },
@@ -939,6 +980,61 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
939
980
  )
940
981
  }
941
982
 
983
+ /**
984
+ * True when `nested` is a `/* @client *\/` child-component loop (#2627)
985
+ * whose array is neither a real signal/memo (`isDynamic`) nor a direct
986
+ * prop reference (`isPropDerived`) — e.g. `Object.entries(props.tags)
987
+ * .filter(...)` feeding a `<Tag>` loop. `renderLoop`'s clientOnly branch
988
+ * (its very first check) renders such a loop as bare start/end comment
989
+ * markers on every backend — the SSR template NEVER references this
990
+ * nested component's Go field — and for a non-clientOnly loop with this
991
+ * same array shape, `renderLoop`'s own later BF101 gate (`arrayName` /
992
+ * `isBound` check) refuses to bind a function-scope computed local as a
993
+ * template variable at all. So there is no Go value anywhere to seed an
994
+ * Input field from or range over in `NewXxxProps` — treating `nested` as
995
+ * a normal static/prop-derived child loop emits a field/range/wrapper var
996
+ * for data nothing ever populates, and when its plural name happens to
997
+ * collide with the ACTUAL driving prop (`tags` -> `Tags`, `Tag` ->
998
+ * `Tags`), that dead field is worse than dead: either a duplicate Go
999
+ * field name (compile error) or — via `isNestedArrayShadowed` — silent
1000
+ * loss of the prop's own field, the one channel the client actually
1001
+ * reads hydration data from.
1002
+ *
1003
+ * `nested.isDynamic` is excluded from this check on purpose: a genuinely
1004
+ * signal-backed clientOnly loop keeps its existing (tested, harmless)
1005
+ * `json:"-"` Props field — only the "looks static but is actually an
1006
+ * unbindable local" shape needs full exclusion.
1007
+ */
1008
+ private isOrphanedClientOnlyNested(nested: NestedComponentInfo): boolean {
1009
+ return !!nested.clientOnly && !nested.isDynamic && !nested.isPropDerived
1010
+ }
1011
+
1012
+ /**
1013
+ * The auto-pluralized Go field names (`Row` -> `Rows`) that genuinely
1014
+ * SUBSUME a same-named props param, so that param must not also get its
1015
+ * own field. Four sites consume this — `generateInputStruct`,
1016
+ * `generatePropsStruct`, `emitPropsAuxFields` and `recordRepropsSpec` —
1017
+ * and they must agree exactly: a one-sided answer lets Input and
1018
+ * Props/NewProps disagree about whether a field exists, producing a
1019
+ * duplicate json tag or a dead Input field whose caller writes are
1020
+ * silently ignored. Extracted to one function so that agreement is
1021
+ * structural rather than four copies kept in step by hand (#2628 review).
1022
+ *
1023
+ * Only `isPropDerived` loops qualify (#2627). Such a loop ranges directly
1024
+ * over `props.X` (or a destructured binding of it), so the array field and
1025
+ * the prop are the same data, merely re-shaped into typed rows. A mere
1026
+ * NAME coincidence is not subsumption: `Object.entries(props.tags)
1027
+ * .filter(...)` feeding a `<Tag>` loop pluralizes to `Tags` and collides
1028
+ * with a `tags` prop, but the prop is a `Record` while the loop array is a
1029
+ * derived slice of tuples — dropping the prop's field there leaves it with
1030
+ * no Go field at all to receive the caller's value. Note the check is
1031
+ * purely name-based, so this is not specific to `Record`: any prop whose
1032
+ * capitalized name matches a child's plural hits the same path.
1033
+ */
1034
+ private propDerivedNestedArrayFields(nestedComponents: readonly NestedComponentInfo[]): Set<string> {
1035
+ return new Set(nestedComponents.filter(n => n.isPropDerived).map(n => `${n.name}s`))
1036
+ }
1037
+
942
1038
  /**
943
1039
  * Every Go field name these props params could claim — LOCAL and
944
1040
  * caller-facing. A context-consumer field must exist in ALL of
@@ -1055,10 +1151,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1055
1151
  ): void {
1056
1152
  if (!this.childDerivedFieldDeps.has(componentName)) return
1057
1153
 
1058
- // Must mirror `generateInputStruct`'s exclusion/collision checks exactly —
1059
- // both walk the Input struct's actual (caller-keyed) field names, or the
1060
- // reprops switch references fields the struct doesn't have.
1061
- const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
1154
+ const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
1062
1155
  const params = (ir.metadata.propsParams ?? []).filter(
1063
1156
  p => !this.isNestedArrayShadowed(p, nestedArrayFields),
1064
1157
  )
@@ -1374,10 +1467,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1374
1467
  }
1375
1468
 
1376
1469
  // Static + prop-derived nested components are in Input; signal-backed
1377
- // dynamic ones are template-only.
1378
- const inputNested = nestedComponents.filter(n => !n.isDynamic || n.isPropDerived)
1470
+ // dynamic ones are template-only. An orphaned clientOnly nested loop
1471
+ // (#2627 see `isOrphanedClientOnlyNested`) has no Go value to seed an
1472
+ // Input field from at all, so it's excluded from both buckets.
1473
+ const inputNested = nestedComponents.filter(
1474
+ n => (!n.isDynamic || n.isPropDerived) && !this.isOrphanedClientOnlyNested(n),
1475
+ )
1379
1476
 
1380
- const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
1477
+ const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
1381
1478
 
1382
1479
  for (const param of ir.metadata.propsParams) {
1383
1480
  // #2525: caller-facing name, not the local destructure binding — a
@@ -1615,8 +1712,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1615
1712
  lines.push('\t}')
1616
1713
  lines.push('')
1617
1714
 
1618
- // Static + prop-derived nested components auto-populate from input.
1619
- const staticNested = nestedComponents.filter(n => !n.isDynamic || n.isPropDerived)
1715
+ // Static + prop-derived nested components auto-populate from input. An
1716
+ // orphaned clientOnly nested loop (#2627 see `isOrphanedClientOnlyNested`)
1717
+ // has no Input field to range over (excluded above in `generateInputStruct`),
1718
+ // so it's excluded here too.
1719
+ const staticNested = nestedComponents.filter(
1720
+ n => (!n.isDynamic || n.isPropDerived) && !this.isOrphanedClientOnlyNested(n),
1721
+ )
1620
1722
 
1621
1723
  // Wrapper vars emitted (by either the static-with-body or dynamic-with-body
1622
1724
  // path), so the return struct only includes the ones that were built.
@@ -1753,7 +1855,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1753
1855
  lines.push('\t\tSearchParams: in.SearchParams,')
1754
1856
  }
1755
1857
 
1756
- const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
1858
+ const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
1757
1859
 
1758
1860
  // Props params (field names tracked to skip duplicate signal assignments).
1759
1861
  // A JSX-declared default (`variant = 'default'`) or signal-side fallback
@@ -2478,9 +2580,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2478
2580
  propTypeOverrides: Map<string, string>,
2479
2581
  takenJsonTags: Set<string>,
2480
2582
  ): void {
2481
- // Nested-component array fields are emitted as typed arrays below, not as
2482
- // their raw prop; track them (and emitted names) to skip duplicates.
2483
- const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
2583
+ const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
2484
2584
  const propFieldNames = new Set<string>()
2485
2585
 
2486
2586
  for (const param of ir.metadata.propsParams) {
@@ -2603,6 +2703,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2603
2703
  }
2604
2704
 
2605
2705
  for (const nested of nestedComponents) {
2706
+ // An orphaned clientOnly nested loop (#2627 — see
2707
+ // `isOrphanedClientOnlyNested`) gets NO Props field at all, not even
2708
+ // the dead `json:"-"` one the signal-backed dynamic branch below gets:
2709
+ // its Go field name (`${nested.name}s`) can collide with the ACTUAL
2710
+ // driving prop's own field (e.g. `tags` -> `Tags` colliding with
2711
+ // `Tag` -> `Tags`), which `emitPropsDataFields` already emitted for
2712
+ // real — a second same-named field here is a Go compile error
2713
+ // ("redeclared"), not just dead code.
2714
+ if (this.isOrphanedClientOnlyNested(nested)) continue
2606
2715
  // Loop body with JSX children → use the wrapper struct type.
2607
2716
  const elemType = nested.bodyChildren?.length
2608
2717
  ? this.loopBodyWrapperName(componentName, nested)
@@ -3757,8 +3866,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3757
3866
  // field that never exists. Mirrors the string-const inlining above.
3758
3867
  const inlinedNum = this.resolveModuleNumericConst(name)
3759
3868
  if (inlinedNum !== null) return inlinedNum
3760
- const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
3761
- if (currentLoopParam && name === currentLoopParam) return '.'
3869
+ if (this.isCurrentLoopItem(name)) return '.'
3762
3870
  // An *outer* loop's value variable (we're in a nested loop) is in scope as
3763
3871
  // the Go range variable `$name` declared by that loop's `{{range … := …}}`;
3764
3872
  // the inner dot no longer refers to it, and it's not a root field.
@@ -3858,18 +3966,31 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3858
3966
  return false
3859
3967
  }
3860
3968
 
3969
+ /**
3970
+ * True when `name` is bound as the CURRENT (innermost) loop row's own
3971
+ * plain `.map()` item param — i.e. it resolves to Go template's dot
3972
+ * context (`.`), not a `$name` range variable or a root field. `depth 0`
3973
+ * is `scope`'s innermost frame; `source === 'item'` excludes a
3974
+ * destructured binding (source `'destructure'`, resolved through
3975
+ * `loopBindingStack` instead — see `scope`'s field docstring) and a
3976
+ * `.keys()`-shape loop's key param (never bound as `'item'` at all —
3977
+ * `renderLoop` enters that shape's scope with an overridden empty param).
3978
+ */
3979
+ private isCurrentLoopItem(name: string): boolean {
3980
+ const hit = this.scope.lookup(name)
3981
+ return hit !== null && hit.depth === 0 && hit.binding.source === 'item'
3982
+ }
3983
+
3861
3984
  /**
3862
3985
  * True when `name` is a loop value variable from an enclosing (not the
3863
- * current) loop — i.e. it sits on `loopParamStack` below the top. Such a
3864
- * reference resolves to the Go range variable `$name`, not the inner dot or
3865
- * the root data.
3986
+ * current) loop — i.e. it's bound in `scope` as an OUTER frame's own
3987
+ * plain item param (`depth > 0`, source `'item'`). Such a reference
3988
+ * resolves to the Go range variable `$name`, not the inner dot or the
3989
+ * root data.
3866
3990
  */
3867
3991
  private isOuterLoopParam(name: string): boolean {
3868
- const top = this.loopParamStack.length - 1
3869
- for (let i = 0; i < top; i++) {
3870
- if (this.loopParamStack[i] === name) return true
3871
- }
3872
- return false
3992
+ const hit = this.scope.lookup(name)
3993
+ return hit !== null && hit.depth > 0 && hit.binding.source === 'item'
3873
3994
  }
3874
3995
 
3875
3996
  /**
@@ -3880,7 +4001,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3880
4001
  * rebinds. Outside any loop the root *is* the dot, so we emit `.Field`.
3881
4002
  */
3882
4003
  private rootFieldRef(name: string): string {
3883
- const prefix = this.loopParamStack.length > 0 ? '$.' : '.'
4004
+ const prefix = this.inLoop ? '$.' : '.'
3884
4005
  return `${prefix}${capitalizeFieldName(name)}`
3885
4006
  }
3886
4007
 
@@ -3922,9 +4043,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3922
4043
  * auto-escaping.
3923
4044
  */
3924
4045
  private resolveModuleStringConst(name: string): string | null {
3925
- if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
3926
- return null
3927
- }
4046
+ if (this.isCurrentLoopItem(name)) return null
3928
4047
  if (this.loopVarRefCount.has(name)) return null
3929
4048
  if (this.isOuterLoopParam(name)) return null
3930
4049
  const value = this.state.moduleStringConsts.get(name)
@@ -3940,9 +4059,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3940
4059
  * range variable that shadows a const name still wins.
3941
4060
  */
3942
4061
  private resolveModuleNumericConst(name: string): string | null {
3943
- if (this.loopParamStack.length > 0 && this.loopParamStack[this.loopParamStack.length - 1] === name) {
3944
- return null
3945
- }
4062
+ if (this.isCurrentLoopItem(name)) return null
3946
4063
  if (this.loopVarRefCount.has(name)) return null
3947
4064
  if (this.isOuterLoopParam(name)) return null
3948
4065
  const c = this.state.localConstants.find(
@@ -4033,7 +4150,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4033
4150
  const slice = this.state.memoBackedLoopSlice.get(object.callee.name)
4034
4151
  if (slice) {
4035
4152
  // Root field, so reach it through `$.` inside a loop.
4036
- const prefix = this.loopParamStack.length > 0 ? '$.' : '.'
4153
+ const prefix = this.inLoop ? '$.' : '.'
4037
4154
  return `len ${prefix}${slice}`
4038
4155
  }
4039
4156
  }
@@ -4094,8 +4211,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4094
4211
  // template syntax, let alone a reachable field (PR #2089 review). For
4095
4212
  // identifier keys (snake_case included) the two functions agree, so
4096
4213
  // nothing previously reachable changes shape.
4097
- const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
4098
- if (object.kind === 'identifier' && currentLoopParam && object.name === currentLoopParam) {
4214
+ if (object.kind === 'identifier' && this.isCurrentLoopItem(object.name)) {
4099
4215
  return `.${goFieldNameForKey(property)}`
4100
4216
  }
4101
4217
 
@@ -5466,22 +5582,18 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5466
5582
 
5467
5583
  /**
5468
5584
  * Whether `name` at the CURRENT emission position is bound by an enclosing
5469
- * loop callback — its item param (`loopParamStack` top), an outer loop's
5470
- * range variable, a hoisted loop var, or a destructured binding name
5471
- * (`loopBindingStack`, which is the ONLY place destructured callbacks
5472
- * record their names; they push `''` onto `loopParamStack`). Shared by the
5585
+ * loop callback — its item param, an outer loop's range variable, a
5586
+ * destructured binding name, an index var, or a preamble local. `scope`
5587
+ * (`BindingScope`, #2482 Stage 3) covers all of those EXCEPT a
5588
+ * `.keys()`-shape loop's own key param (never bound in `scope` at all
5589
+ * see `scope`'s field docstring), which stays reachable only through
5590
+ * `loopVarRefCount` — so both are still consulted. Shared by the
5473
5591
  * string-keyed fast paths (#2236, #2242 Copilot review) that resolve raw
5474
5592
  * `jsExpr` text before `identifier()`'s own guards can see it. Mirrors the
5475
5593
  * checks in `resolveModuleStringConst` / `resolveModuleNumericConst`.
5476
5594
  */
5477
5595
  private isLoopShadowedName(name: string): boolean {
5478
- return (
5479
- (this.loopParamStack.length > 0 &&
5480
- this.loopParamStack[this.loopParamStack.length - 1] === name) ||
5481
- this.loopVarRefCount.has(name) ||
5482
- this.isOuterLoopParam(name) ||
5483
- this.loopBindingStack.some(bindings => bindings.has(name))
5484
- )
5596
+ return this.scope.isBound(name) || this.loopVarRefCount.has(name)
5485
5597
  }
5486
5598
 
5487
5599
  /**
@@ -5871,8 +5983,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5871
5983
  }
5872
5984
  const inlined = this.resolveModuleStringConst(expr.name)
5873
5985
  if (inlined !== null) return plain(inlined)
5874
- const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
5875
- if (currentLoopParam && expr.name === currentLoopParam) {
5986
+ if (this.isCurrentLoopItem(expr.name)) {
5876
5987
  return plain('.')
5877
5988
  }
5878
5989
  // Outer loop value variable (nested loop) → its range var `$name`.
@@ -5971,11 +6082,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5971
6082
  return plain(this.rootFieldRef(expr.property))
5972
6083
  }
5973
6084
 
5974
- {
5975
- const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
5976
- if (expr.object.kind === 'identifier' && currentLoopParam && expr.object.name === currentLoopParam) {
5977
- return plain(`.${capitalizeFieldName(expr.property)}`)
5978
- }
6085
+ if (expr.object.kind === 'identifier' && this.isCurrentLoopItem(expr.object.name)) {
6086
+ return plain(`.${capitalizeFieldName(expr.property)}`)
5979
6087
  }
5980
6088
 
5981
6089
  const obj = this.renderConditionExpr(expr.object)
@@ -6241,6 +6349,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6241
6349
  if (this.bakedStaticChildLoopCache.has(markerId)) {
6242
6350
  return this.bakedStaticChildLoopCache.get(markerId) ?? null
6243
6351
  }
6352
+ // #2482 Stage 3: this method is called from TWO sites outside the live
6353
+ // `renderLoop` tree walk (`generateNewPropsFunction`'s Input-struct
6354
+ // field list and constructor-generation pass, per the docstring above)
6355
+ // where there is no live `this.scope` to consult — so, unlike
6356
+ // `renderLoop`'s own `analyzeBakeableStaticElementLoop` call, this stays
6357
+ // on the coarse whole-component shadow-name Set `primeCompileState`
6358
+ // populates. A genuinely-legitimate surviving use of the pre-#2482
6359
+ // device (flagged for Stage 4 rather than migrated here).
6244
6360
  const result = analyzeBakeableStaticChildLoop(
6245
6361
  { props: childComponent.props, loopArrayParsed: arrayParsed, loopParam: param, loopKey: key },
6246
6362
  this.state.localConstants,
@@ -6337,19 +6453,33 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6337
6453
  // exact (conservative) acceptance gate. `null` here means the shape
6338
6454
  // isn't (yet) bakeable this way; the existing gates below keep firing
6339
6455
  // exactly as before.
6456
+ // #2482 Stage 3: `renderLoop` is a live tree-walk, so `this.scope` (not
6457
+ // yet entered for THIS loop's own row — that happens further below)
6458
+ // gives the position-accurate ancestor-shadowing answer directly, unlike
6459
+ // `getBakedStaticChildLoop` above, which is memoized across sites that
6460
+ // run OUTSIDE the tree walk and so must keep its coarse whole-component
6461
+ // shadow-name Set (see that method's own comment).
6340
6462
  const bakedElementLoop = loop.childComponent
6341
6463
  ? null
6342
6464
  : analyzeBakeableStaticElementLoop(
6343
6465
  loop,
6344
6466
  this.state.localConstants,
6345
- { isNameShadowed: name => this.state.staticLoopSourceBoundNames.has(name) },
6467
+ { isNameShadowed: this.scope.asShadowPredicate() },
6346
6468
  )
6347
6469
  if (bakedElementLoop) {
6348
6470
  return this.renderUnrolledStaticElementLoop(loop, bakedElementLoop.items)
6349
6471
  }
6350
6472
 
6351
6473
  const arrayName = loop.array.trim()
6352
- if (bakedChildLoop === null && /^[A-Za-z_$][\w$]*$/.test(arrayName)) {
6474
+ // #2482 Stage 4: guard against an ENCLOSING loop's own item param
6475
+ // shadowing a same-named module/component const (`items.map(items =>
6476
+ // items.map(...))`) — without this, a bare-identifier array reference
6477
+ // that resolves to the outer loop's per-row value could misresolve to
6478
+ // the shadowed const below and raise a false BF101. `this.scope` here
6479
+ // is the position-accurate ancestor scope (see the comment above
6480
+ // `bakedElementLoop`) — same shadow-guard shape as
6481
+ // `prop-handling.ts`'s `expandDynamicPropValue`.
6482
+ if (bakedChildLoop === null && /^[A-Za-z_$][\w$]*$/.test(arrayName) && !this.scope.isBound(arrayName)) {
6353
6483
  const arrayConst = this.state.localConstants.find(c => c.name === arrayName)
6354
6484
  if (arrayConst && !arrayConst.isModule && arrayConst.parsed && !this.isStringExpr(arrayConst.parsed, new Set())) {
6355
6485
  this.state.errors.push({
@@ -6360,6 +6490,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6360
6490
  suggestion: {
6361
6491
  message:
6362
6492
  '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.',
6493
+ escape: [{ kind: 'prop-precompute' }, { kind: 'client-directive' }],
6363
6494
  },
6364
6495
  })
6365
6496
  }
@@ -6423,15 +6554,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6423
6554
  // of the row-scoped field) (#2487).
6424
6555
  const wasInLoopOuter = this.inLoop
6425
6556
  this.inLoop = true
6426
- // Track Go template loop variables. The range *value* variable is the dot
6427
- // context (`.`) and goes on `loopParamStack`; the range *index* variable
6428
- // needs `$name` notation and goes on `loopVarRefCount`. For `.keys()`, the
6429
- // user's param IS the index (the `$k, $_` position), so it needs `$name` —
6430
- // don't push it to loopParamStack (`.` would resolve to the value, not key);
6431
- // push falsy `''` so the `currentLoopParam &&` guard in `identifier()` /
6432
- // `renderConditionExpr` short-circuits. Ref-counting (not a flat Set) keeps
6433
- // nested loops with the same index var name from clobbering the outer entry
6434
- // on cleanup.
6557
+ // Enter this loop's row scope (#2482 Stage 3). `IRLoop` already
6558
+ // structurally satisfies `LoopBindingSource`, so the plain/destructured
6559
+ // cases pass `loop` straight through `enterLoopRow` binds
6560
+ // `paramBindings` (destructure) OR `param` (plain item) automatically.
6561
+ // `.keys()` is the one shape `BindingScope`'s generic 'item' semantics
6562
+ // don't fit: the callback param there IS the index, not the row value
6563
+ // (the dot context is the discarded value), so it must never win
6564
+ // `isCurrentLoopItem`'s dot-shortcut enter with an overridden empty
6565
+ // `param`, mirroring the historical empty-string push onto the old
6566
+ // loop-param stack for the same shape. The key stays reachable only
6567
+ // via `loopVarRefCount`'s `$name` bookkeeping below, unchanged by this
6568
+ // migration.
6569
+ const scopeLoop: LoopBindingSource = loop.iterationShape === 'keys' ? { ...loop, param: '' } : loop
6570
+ const prevScope = this.scope
6571
+ this.scope = prevScope.enterLoopRow(scopeLoop)
6572
+ // Track Go template loop variables. The range *index* variable needs
6573
+ // `$name` notation and goes on `loopVarRefCount` (the range *value*
6574
+ // variable's dot-context resolution now comes from `scope` above).
6575
+ // Ref-counting (not a flat Set) keeps nested loops with the same index
6576
+ // var name from clobbering the outer entry on cleanup.
6435
6577
  const addedLoopVars: string[] = []
6436
6578
  // A `.map()` callback preamble lowers to one `{{$cls := …}}` per-row
6437
6579
  // variable per declaration (#2447). Registering the names on
@@ -6446,23 +6588,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6446
6588
  }
6447
6589
  let pushedBindingMap = false
6448
6590
  if (supportableDestructure) {
6449
- // Bindings resolve against the synthetic `$__bf_item` range var; don't push
6450
- // a loop param (the param is a pattern, not a name).
6591
+ // Bindings resolve against the synthetic `$__bf_item` range var.
6451
6592
  const built = this.buildDestructureBindingMap(loop, rangeValue)
6452
6593
  this.loopBindingStack.push(built.bindings)
6453
6594
  this.loopRestExcludeStack.push(built.restExcludes)
6454
6595
  pushedBindingMap = true
6455
- this.loopParamStack.push('')
6456
6596
  if (rangeIndex !== '_') {
6457
6597
  this.loopVarRefCount.set(rangeIndex, (this.loopVarRefCount.get(rangeIndex) ?? 0) + 1)
6458
6598
  addedLoopVars.push(rangeIndex)
6459
6599
  }
6460
6600
  } else if (loop.iterationShape === 'keys') {
6461
- this.loopParamStack.push('')
6462
6601
  this.loopVarRefCount.set(param, (this.loopVarRefCount.get(param) ?? 0) + 1)
6463
6602
  addedLoopVars.push(param)
6464
6603
  } else {
6465
- this.loopParamStack.push(param)
6466
6604
  if (rangeIndex !== '_') {
6467
6605
  this.loopVarRefCount.set(rangeIndex, (this.loopVarRefCount.get(rangeIndex) ?? 0) + 1)
6468
6606
  addedLoopVars.push(rangeIndex)
@@ -6487,9 +6625,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6487
6625
  this.loopKeyDepthStack.pop()
6488
6626
  this.loopWrapperStack.pop()
6489
6627
  this.loopScalarItemStack.pop()
6490
- // Build the per-item anchor marker while the loop param is still on the
6491
- // stack, so a `bodyIsItemConditional` key expression resolves against the
6492
- // range item (`.` context) like `data-key` does — popping first would
6628
+ // Build the per-item anchor marker while the row scope is still active,
6629
+ // so a `bodyIsItemConditional` key expression resolves against the
6630
+ // range item (`.` context) like `data-key` does — restoring first would
6493
6631
  // rewrite `t.id` to `.T.ID` instead of `.ID`.
6494
6632
  const itemMarker = this.loopItemMarker(loop)
6495
6633
  for (const v of addedLoopVars) {
@@ -6497,7 +6635,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6497
6635
  if (rc <= 0) this.loopVarRefCount.delete(v)
6498
6636
  else this.loopVarRefCount.set(v, rc)
6499
6637
  }
6500
- this.loopParamStack.pop()
6638
+ this.scope = prevScope
6501
6639
  if (pushedBindingMap) {
6502
6640
  this.loopBindingStack.pop()
6503
6641
  this.loopRestExcludeStack.pop()
@@ -6578,7 +6716,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6578
6716
  this.loopWrapperStack.push(false)
6579
6717
  this.loopKeyDepthStack.push(loop.depth)
6580
6718
  this.loopScalarItemStack.push(this.scalarLiteralLoopGoType(loop.arrayParsed, loop.itemType) !== null)
6581
- this.loopParamStack.push(loop.param)
6719
+ // The bake gate (`analyzeBakeableStaticElementLoop`) already refused a
6720
+ // destructured or `.keys()`-shape param, so `loop` binds via `enterLoopRow`
6721
+ // unadjusted — always a plain item (#2482 Stage 3).
6722
+ const prevScope = this.scope
6723
+ this.scope = prevScope.enterLoopRow(loop)
6582
6724
 
6583
6725
  let body = ''
6584
6726
  for (const item of items) {
@@ -6603,7 +6745,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6603
6745
  }
6604
6746
  }
6605
6747
 
6606
- this.loopParamStack.pop()
6748
+ this.scope = prevScope
6607
6749
  this.loopScalarItemStack.pop()
6608
6750
  this.loopKeyDepthStack.pop()
6609
6751
  this.loopWrapperStack.pop()
@@ -6947,8 +7089,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6947
7089
  // (#2087), which lifted the flat-object-only restriction for the
6948
7090
  // destructure-residual fixtures below.
6949
7091
  const trimmed = value.expr.trim()
6950
- const currentLoopParam = this.loopParamStack[this.loopParamStack.length - 1]
6951
- if (currentLoopParam && trimmed === currentLoopParam) {
7092
+ if (this.isCurrentLoopItem(trimmed)) {
6952
7093
  // The row's keys are Go-cased by the field-access contract: the
6953
7094
  // same lowering that makes `attrs.id` emit `{{.ID}}`
6954
7095
  // (`goFieldNameForKey` → `capitalizeFieldName`) requires the
@@ -6,8 +6,12 @@
6
6
  *
7
7
  * NOT included (deliberately): cross-compile child-shape registries
8
8
  * (`childComponentShapes`, `childContextConsumers`, populated before a parent
9
- * compiles), the render-recursion cursor stacks (`loopParamStack`,
10
- * `filterExprDepth`, …), and constant config (`options`, `templatePrimitives`).
9
+ * compiles), the render-recursion cursor state (`scope: BindingScope`,
10
+ * `loopBindingStack`, `filterExprDepth`, …), and constant config (`options`,
11
+ * `templatePrimitives`). #2482 Stage 4: `scope` replaced the old mutable
12
+ * stack this comment used to name here (see the field's own docstring on
13
+ * `GoTemplateAdapter` for the full migration story) — this file never held
14
+ * that stack itself, only described where it lived.
11
15
  */
12
16
 
13
17
  import type {
@@ -30,6 +30,21 @@ export type GoRenderCtx = {
30
30
  export interface NestedComponentInfo extends IRLoopChildComponent {
31
31
  isDynamic: boolean
32
32
  isPropDerived: boolean
33
+ /**
34
+ * True when the enclosing loop carries a `/* @client *\/` directive
35
+ * (`loop.clientOnly`, #2627). `renderLoop`'s FIRST branch short-circuits a
36
+ * clientOnly loop to bare start/end comment markers — the SSR template
37
+ * never references this nested component's Go field, no matter how
38
+ * `isDynamic`/`isPropDerived` classify its array. Combined with
39
+ * `!isDynamic && !isPropDerived` (a clientOnly loop whose array is
40
+ * neither a real signal/memo NOR a direct prop reference — e.g.
41
+ * `Object.entries(props.tags).filter(...)`), there is no Go value to
42
+ * seed an Input/Props field FROM at all: the array is a function-scope
43
+ * computed local that `renderLoop`'s own BF101 gate refuses to bind as a
44
+ * template variable for a non-clientOnly loop. See
45
+ * `GoTemplateAdapter.isOrphanedClientOnlyNested`.
46
+ */
47
+ clientOnly?: boolean
33
48
  /** The enclosing loop's `key` expression (e.g. `item.label`) and map param
34
49
  * name (`item`), so the loop-child init can stamp `data-key` per item. */
35
50
  loopKey?: string