@barefootjs/go-template 0.29.0 → 0.30.2

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.
@@ -133,7 +133,7 @@ import { typeInfoToGo } from "./type/type-codegen.ts"
133
133
  import { isBooleanMemo, isListFilterMemo, isStringTernaryMemo } from "./memo/memo-type.ts"
134
134
  import { lowerCtorExpr } from "./memo/ctor-lowering.ts"
135
135
  import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
136
- import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
136
+ import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs, collectPropsReadByCtorInit } from "./memo/memo-compute.ts"
137
137
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
138
138
  import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames, collectNullishConsumedPropNames, collectOmittableAttrConsumedPropNames, collectTextConsumedPropNames, collectPresenceCheckedPropNames, NULLISH_SCALAR_GO_TYPES } from "./props/prop-types.ts"
139
139
  import { collectStringValueNames } from "./props/prop-classes.ts"
@@ -155,6 +155,23 @@ type HigherOrderShape = {
155
155
  predicate: ParsedExpr
156
156
  }
157
157
 
158
+ /**
159
+ * Everything needed to emit a component's #2448 props rebuilder, captured when
160
+ * its own types are generated so a PARENT can emit the registration into its
161
+ * own type block (`emitOwnedReprops`). `params` are this component's own
162
+ * Input fields — since #2457, the PARENT (`loopRowChildPropOverrides`, via
163
+ * `childPropFieldNames`) already resolves the JSX attribute name to the
164
+ * child's own field name before emitting the call, so the switch this spec
165
+ * drives is keyed by that one name on both sides; there is no longer a
166
+ * separate "wire" name to carry (an aliased destructure used to make the
167
+ * parent's JSX-attribute name and the child's field name differ — `"N"` vs
168
+ * `Count` — and this spec used to carry both).
169
+ */
170
+ type RepropsSpec = {
171
+ params: string[]
172
+ usesSearchParams: boolean
173
+ }
174
+
158
175
  /**
159
176
  * String-returning array/string methods. `.get(...)` stays a generic `call`;
160
177
  * the rest fold into `array-method`. Module-level so `isStringExpr` (which
@@ -441,6 +458,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
441
458
  this.state.pendingChildrenDefines = []
442
459
  this.primeCompileState(ir)
443
460
  this.state.stringValueNames = collectStringValueNames(ir)
461
+ // #2448: self-register this component's derived-memo dependencies, so a
462
+ // SAME-FILE child (generated before its parent in the same run) is
463
+ // visible to `loopRowChildPropOverrides`. The cross-file pre-pass door is
464
+ // `registerChildComponentShape`; `compileJSX` only goes through here.
465
+ this.recordDerivedFieldDeps(ir, new Set((ir.metadata.propsParams ?? []).map(p => p.name)))
444
466
 
445
467
  // Surface loop-body usages of sibling-imported components (see
446
468
  // `checkImportedLoopChildComponents`). The barefoot CLI compiles a
@@ -618,10 +640,178 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
618
640
  * (the CLI's cross-file shape pre-pass, #2131) can register from a bare
619
641
  * analyzer pass — a full `ComponentIR` still satisfies it structurally.
620
642
  */
643
+ /**
644
+ * #2448: component name -> (memo field -> the INPUT prop Go field names its
645
+ * initializer reads). Deliberately SEPARATE from `childComponentShapes`,
646
+ * which only the CLI's cross-file pre-pass
647
+ * (`registerChildComponentShape`) populates — `compileJSX` never calls that
648
+ * hook, so a SAME-FILE child (the only kind a loop row may contain: a
649
+ * sibling-module child is BF103-refused there) would never have an entry
650
+ * and the #2448 refusal would never fire. That is exactly how the first cut
651
+ * of this fix shipped a conformance pin for a diagnostic that was never
652
+ * emitted.
653
+ *
654
+ * Populated from BOTH doors: `registerChildComponentShape` for the
655
+ * cross-file pre-pass, and `generate()` for every component compiled in
656
+ * this run — a same-file child is generated before its parent, so its entry
657
+ * is present by the time `loopRowChildPropOverrides` looks. Kept out of
658
+ * `ChildComponentShape` on purpose: that struct's presence/absence already
659
+ * drives rest-bag routing and map-typed-param baking, and widening WHEN it
660
+ * exists would change emission for every same-file child in the corpus.
661
+ * This map has exactly one reader.
662
+ */
663
+ private childDerivedFieldDeps: Map<string, Map<string, ReadonlySet<string>>> = new Map()
664
+
665
+ /**
666
+ * component name -> (the prop name as WRITTEN AT THE JSX CALL SITE -> that
667
+ * component's own Go field name). #2457.
668
+ *
669
+ * `generateInputStruct` / `emitPropsDataFields` name a child's Go field from
670
+ * its LOCAL binding (`capitalizeFieldName(param.name)`), but a parent
671
+ * emitting a per-row override
672
+ * (`loopRowChildPropOverrides`) only knows the JSX attribute it wrote. For
673
+ * an un-aliased prop those are the same string, so this map is an identity
674
+ * for every existing shape and every currently-passing fixture stays
675
+ * byte-identical. For an ALIASED destructure (`{ n: count }`) they differ
676
+ * (`"N"` vs `Count`) — `bf.WithProps` documents unknown-field pairs as a
677
+ * silent passthrough, so emitting the JSX name against a struct that has no
678
+ * such field drops the override with no diagnostic. Resolving through this
679
+ * map once, at the parent's emission site (the only place that has both
680
+ * the JSX name and the child's shape), replaces that silent drop with the
681
+ * correct field.
682
+ *
683
+ * Key = `p.sourceName ?? p.name` (identity for an un-aliased param, the JSX
684
+ * attribute name for an aliased one). Value = `capitalizeFieldName(p.name)`
685
+ * — the child's own field, from its LOCAL binding, same as
686
+ * `generateInputStruct` uses.
687
+ *
688
+ * Populated from the same two doors as `childDerivedFieldDeps` — inside
689
+ * `recordDerivedFieldDeps`, unconditionally (before that method's own
690
+ * `deps.size > 0` gate), so every component gets an entry here regardless
691
+ * of whether it has a derived field. `recordRepropsSpec` (the other nearby
692
+ * populator) returns early for components with no derived field or an
693
+ * ineligible shape — this map must NOT inherit either gate, since an
694
+ * aliased child with no derived field at all is exactly the #2457 shape.
695
+ */
696
+ private childPropFieldNames: Map<string, Map<string, string>> = new Map()
697
+
698
+ /**
699
+ * Components a `bf.RegisterReprops` rebuilder CAN be emitted for
700
+ * (`recordRepropsSpec`, #2448), with everything needed to emit it. A parent
701
+ * overriding a prop that feeds one of the child's derived fields emits
702
+ * `bf_reprops` when the child is in here, and falls back to the BF101
703
+ * refusal when it is not — the rebuilder is declined for shapes whose Input
704
+ * can't be reconstructed from Props, and emitting the call anyway would fail
705
+ * at template execute time with "no props rebuilder registered".
706
+ *
707
+ * Populated from `generateTypes`, so a same-file child (the only kind a loop
708
+ * row may contain) is recorded before its parent renders.
709
+ */
710
+ private childRepropsReady: Map<string, RepropsSpec> = new Map()
711
+
712
+ /**
713
+ * child component name -> the component whose type block carries its
714
+ * rebuilder registration. First parent to need the child wins, so two
715
+ * parents overriding the same child don't both emit an `init()` for it.
716
+ *
717
+ * An assignment, not a queue: `generateTypes` is called more than once for
718
+ * the same component (the conformance harness re-generates the entry's types
719
+ * after `compileJSX` already did), and a queue drained by the first call
720
+ * would leave the second call's output — the one that actually gets used —
721
+ * missing the registration.
722
+ */
723
+ private repropsOwner: Map<string, string> = new Map()
724
+
725
+ /**
726
+ * Record which of `ir`'s constructor-computed fields derive from one of its
727
+ * own input props (#2448). See `childDerivedFieldDeps`.
728
+ *
729
+ * BOTH constructor-evaluated declaration forms count, because
730
+ * `generateNewPropsFunction` bakes both into the struct once:
731
+ * a `createMemo` BODY (`createMemo(() => props.n * 2)` → `Dbl: in.N * 2`)
732
+ * and a `createSignal` INITIAL VALUE (`createSignal(props.n)` → `Dbl: in.N`).
733
+ * `bf_with_props` re-runs neither, so a per-row override of `n` leaves
734
+ * either one holding the shared instance's one-shot value.
735
+ *
736
+ * A declaration whose Go field name collides with a prop's own field name is
737
+ * a same-named "shadow" (`size = createMemo(() => props.size ?? 'icon')`) —
738
+ * `generateNewPropsFunction` folds it into the PROP's passthrough field
739
+ * rather than emitting a separate derived one, so a per-row override of
740
+ * that prop lands on the same field and nothing goes stale. A declaration
741
+ * with no structurally-resolvable `parsed` initializer is skipped too: this
742
+ * map is best-effort, and its absence costs a missed refusal (today's
743
+ * behaviour), never a wrong one.
744
+ *
745
+ * Dependencies are keyed by the prop's CANONICAL source name
746
+ * (`ParamInfo.sourceName ?? name`), not the child's local binding. An
747
+ * ALIASED destructure (`function Badge({ n: count }: { n: number })`) reads
748
+ * `count` in the memo body, but the only name the parent knows is the JSX
749
+ * attribute `n` — `loopRowChildPropOverrides` looks THAT up in
750
+ * `childPropFieldNames` before checking this map. Keying on the local name
751
+ * would file the dependency under `Count`, the lookup would miss, and the
752
+ * refusal would silently not fire on exactly the shape it exists for.
753
+ *
754
+ * This canonicalization used to cover the DERIVED case only — an aliased
755
+ * prop with no derived field still had the parent emitting `"N" .N`
756
+ * against a child whose field is `Count`, silently passed over by
757
+ * `bf.WithProps` (#2457). `childPropFieldNames`, populated a few lines
758
+ * below in this same method, closes that residual gap by resolving every
759
+ * prop's field name at the parent's emission site, not just the ones a
760
+ * derived field depends on.
761
+ */
762
+ private recordDerivedFieldDeps(
763
+ ir: Pick<ComponentIR, 'metadata'>,
764
+ paramNames: ReadonlySet<string>,
765
+ ): void {
766
+ const name = ir.metadata.componentName
767
+ if (!name) return
768
+ // Local binding → the prop name the PARENT writes at the call site.
769
+ // Identity for every un-aliased param (`sourceName` is set only on a
770
+ // renaming destructure).
771
+ const sourceOf = new Map(
772
+ (ir.metadata.propsParams ?? []).map(p => [p.name, p.sourceName ?? p.name]),
773
+ )
774
+ const canonical = (local: string): string => capitalizeFieldName(sourceOf.get(local) ?? local)
775
+ const propFieldNames = new Set([...paramNames].map(canonical))
776
+ // #2457: record the JSX-attribute-name -> child's-own-field-name map for
777
+ // EVERY component, unconditionally — see `childPropFieldNames`'s
778
+ // docstring for why this can't share `recordRepropsSpec`'s gates.
779
+ const fieldNames = new Map<string, string>()
780
+ for (const p of ir.metadata.propsParams ?? []) {
781
+ fieldNames.set(p.sourceName ?? p.name, capitalizeFieldName(p.name))
782
+ }
783
+ this.childPropFieldNames.set(name, fieldNames)
784
+ const deps = new Map<string, ReadonlySet<string>>()
785
+ const ctorInits: { field: string; init: ParsedExpr | undefined }[] = [
786
+ ...(ir.metadata.memos ?? []).map(m => ({ field: m.name, init: m.parsed })),
787
+ ...(ir.metadata.signals ?? []).map(s => ({ field: s.getter, init: s.parsed })),
788
+ ]
789
+ for (const { field, init } of ctorInits) {
790
+ if (propFieldNames.has(capitalizeFieldName(field))) continue
791
+ if (!init) continue
792
+ const read = collectPropsReadByCtorInit(
793
+ init,
794
+ ir.metadata.propsObjectName ?? null,
795
+ paramNames,
796
+ )
797
+ if (read.size === 0) continue
798
+ deps.set(field, new Set([...read].map(canonical)))
799
+ }
800
+ if (deps.size > 0) this.childDerivedFieldDeps.set(name, deps)
801
+ }
802
+
621
803
  registerChildComponentShape(ir: Pick<ComponentIR, 'metadata'>): void {
622
804
  const name = ir.metadata.componentName
623
805
  if (!name) return
624
- const paramNames = new Set((ir.metadata.propsParams ?? []).map(p => p.name))
806
+ // Both sets on `ChildComponentShape` are looked up by a PARENT against the
807
+ // name it wrote at the JSX call site, so they are keyed by
808
+ // `sourceName ?? name` — identity for an un-aliased param, the original
809
+ // property name for an aliased destructure (`{ n: count }` → `n`). Keying
810
+ // them by the local binding meant an aliased prop looked undeclared to
811
+ // `emitChildField` (misrouted into the rest bag) and to
812
+ // `loopRowChildPropOverrides` (skipped as rest-bag-only) — the same
813
+ // wrong-name-at-a-parent-side-lookup bug as #2457, one function over.
814
+ const paramNames = new Set((ir.metadata.propsParams ?? []).map(p => p.sourceName ?? p.name))
625
815
  const restPropsName = ir.metadata.restPropsName ?? null
626
816
  const restBagField = restPropsName ? capitalizeFieldName(restPropsName) : null
627
817
  // Optional object/named-interface params lower to `map[string]interface{}`
@@ -635,9 +825,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
635
825
  (p.type.kind === 'object' ||
636
826
  (p.type.kind === 'interface' && !!p.type.raw)),
637
827
  )
638
- .map(p => p.name),
828
+ .map(p => p.sourceName ?? p.name),
639
829
  )
640
830
  this.childComponentShapes.set(name, { paramNames, restBagField, mapTypedParamNames })
831
+ // NOT `paramNames`: `recordDerivedFieldDeps` forwards this set to
832
+ // `collectPropsReadByCtorInit`, which in destructured mode matches BARE
833
+ // IDENTIFIERS in the memo/signal body — those are the LOCAL bindings
834
+ // (`count`), not the call-site names. Handing it the canonical set would
835
+ // make an aliased child's derived field look dependency-free and silently
836
+ // un-refuse / un-rebuild it.
837
+ this.recordDerivedFieldDeps(ir, new Set((ir.metadata.propsParams ?? []).map(p => p.name)))
641
838
  // Contexts this child consumes, so a parent `<Ctx.Provider value>` wrapping
642
839
  // it can set the matching field on the child's slot input.
643
840
  this.childContextConsumers.set(name, collectContextConsumers(ir.metadata))
@@ -761,9 +958,172 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
761
958
 
762
959
  this.generateNewPropsFunction(lines, ir, componentName, nestedComponents, spreadSlots, propTypeOverrides)
763
960
 
961
+ this.recordRepropsSpec(ir, componentName, nestedComponents, spreadSlots)
962
+ this.emitOwnedReprops(lines, componentName)
963
+
764
964
  return this.composeFileHeader(lines)
765
965
  }
766
966
 
967
+ /**
968
+ * #2448: emit a props REBUILDER for a component whose constructor derives a
969
+ * field from one of its own input props, and register it from `init()`.
970
+ *
971
+ * `bf_with_props` patches fields on the child's already-constructed shared
972
+ * instance; it cannot re-run `New<Child>Props`, where a `createMemo` body and
973
+ * a `createSignal` initial value are both baked. This closure reconstructs
974
+ * the Input from the base Props, folds the row's overrides in, and re-runs
975
+ * the real constructor — so every derived field recomputes per row. The
976
+ * parent's call site then emits `bf_reprops` instead of `bf_with_props`
977
+ * (`loopRowChildPropOverrides`).
978
+ *
979
+ * Two invariants the emitted code depends on:
980
+ *
981
+ * - **Input is recoverable from Props.** `emitPropsDataFields` and
982
+ * `generateInputStruct` emit each props param with the SAME field name and
983
+ * the SAME `resolvePropGoType`, so `in.<F> = b.<F>` is exact. A prop read
984
+ * only by a memo still gets its passthrough field, so nothing is lost.
985
+ * Shapes that add Input fields with no Props counterpart (a rest bag, a
986
+ * spread slot, context consumers, nested child Inputs) are declined —
987
+ * `eligible` below — and the parent falls back to today's BF101.
988
+ * - **Identity is carried, never re-derived.** `New<Child>Props` mints a
989
+ * random ScopeID when handed an empty one, so re-running it naively would
990
+ * give every row its own scope and break hydration. ScopeID/BfParent/
991
+ * BfMount come from the base, and the Props-only fields (Scripts,
992
+ * BfIsRoot/BfIsChild/BfDataKey) are reapplied after the constructor.
993
+ *
994
+ * The override switch is keyed by the child's own Input field
995
+ * (`ParamInfo.name`) on both the case label and the assignment target. That
996
+ * used to differ under an aliased destructure — the case label carried the
997
+ * name the PARENT wrote (the JSX attribute, `ParamInfo.sourceName ?? name`)
998
+ * while the assignment targeted the child's own field, so this switch was
999
+ * where those two sides were reconciled. #2457 moved that reconciliation to
1000
+ * the PARENT (`loopRowChildPropOverrides`, via `childPropFieldNames`): the
1001
+ * parent now emits the child's own field name at the call site, so by the
1002
+ * time a `bf_reprops` pipeline reaches this switch both sides already agree
1003
+ * and there's exactly one place — the parent's emission — where the naming
1004
+ * gets reconciled, instead of two.
1005
+ */
1006
+ private recordRepropsSpec(
1007
+ ir: ComponentIR,
1008
+ componentName: string,
1009
+ nestedComponents: NestedComponentInfo[],
1010
+ spreadSlots: SpreadSlotInfo[],
1011
+ ): void {
1012
+ if (!this.childDerivedFieldDeps.has(componentName)) return
1013
+
1014
+ const nestedArrayFields = new Set(nestedComponents.map(n => `${n.name}s`))
1015
+ const params = (ir.metadata.propsParams ?? []).filter(
1016
+ p => !nestedArrayFields.has(capitalizeFieldName(p.name)),
1017
+ )
1018
+ const takenInput = new Set((ir.metadata.propsParams ?? []).map(p => capitalizeFieldName(p.name)))
1019
+ const eligible =
1020
+ nestedComponents.every(n => n.isDynamic && !n.isPropDerived) &&
1021
+ spreadSlots.length === 0 &&
1022
+ !ir.metadata.restPropsName &&
1023
+ this.nonCollidingContextConsumers(takenInput).length === 0
1024
+ if (!eligible) return
1025
+
1026
+ this.childRepropsReady.set(componentName, {
1027
+ params: params.map(p => capitalizeFieldName(p.name)),
1028
+ usesSearchParams: this.usesSearchParams(ir),
1029
+ })
1030
+ }
1031
+
1032
+ /**
1033
+ * Emit the rebuilders assigned to `owner` — the children its own render
1034
+ * asked for (`loopRowChildPropOverrides` → `repropsOwner`).
1035
+ *
1036
+ * Emitted from the PARENT rather than the child on purpose. The prop-seeded
1037
+ * signal (`createSignal(props.initial)`) is one of the most common shapes in
1038
+ * the corpus, so emitting a rebuilder for every component that merely HAS a
1039
+ * derived field added ~250 lines of never-called Go to each integration's
1040
+ * generated file. Only a parent knows whether a child is actually overridden
1041
+ * per row inside a composite loop row, and it knows before its own type block
1042
+ * is built — `generate()` renders the template first. Every component's type
1043
+ * block lands in the same package (`combineGoTypes`, `build.ts`), so the
1044
+ * child's Input/Props types are in scope from here.
1045
+ *
1046
+ * Idempotent: driven by the recorded assignment, so re-generating the same
1047
+ * component's types produces the same block.
1048
+ */
1049
+ private emitOwnedReprops(lines: string[], owner: string): void {
1050
+ for (const [childName, ownerName] of this.repropsOwner) {
1051
+ if (ownerName !== owner) continue
1052
+ const spec = this.childRepropsReady.get(childName)
1053
+ if (!spec) continue
1054
+ this.emitRepropsRegistration(lines, childName, spec)
1055
+ }
1056
+ }
1057
+
1058
+ private emitRepropsRegistration(
1059
+ lines: string[],
1060
+ componentName: string,
1061
+ spec: RepropsSpec,
1062
+ ): void {
1063
+ const { params, usesSearchParams } = spec
1064
+ const inputTypeName = `${componentName}Input`
1065
+ const q = JSON.stringify(componentName)
1066
+
1067
+ lines.push(`// ${componentName} computes at least one field from an input prop when its`)
1068
+ lines.push('// props are constructed, so a per-row override inside a composite loop row')
1069
+ lines.push('// cannot be applied by patching fields — the derived field would keep the')
1070
+ lines.push('// shared instance\'s one-shot value on every row (#2448). This rebuilder')
1071
+ lines.push(`// re-runs New${componentName}Props with the row's overrides folded into the`)
1072
+ lines.push('// Input; the parent calls it through bf_reprops.')
1073
+ lines.push('func init() {')
1074
+ lines.push(`\tbf.RegisterReprops(${q}, func(base interface{}, kv ...interface{}) (interface{}, error) {`)
1075
+ lines.push(`\t\tb, ok := base.(${componentName}Props)`)
1076
+ lines.push('\t\tif !ok {')
1077
+ lines.push(`\t\t\treturn nil, bf.RepropsTypeError(${q}, base)`)
1078
+ lines.push('\t\t}')
1079
+ lines.push(`\t\tin := ${inputTypeName}{`)
1080
+ // Identity comes from the base instance — re-deriving it would re-mint the
1081
+ // ScopeID and break hydration.
1082
+ lines.push('\t\t\tScopeID: b.ScopeID,')
1083
+ lines.push('\t\t\tBfParent: b.BfParent,')
1084
+ lines.push('\t\t\tBfMount: b.BfMount,')
1085
+ if (usesSearchParams) lines.push('\t\t\tSearchParams: b.SearchParams,')
1086
+ for (const field of params) {
1087
+ lines.push(`\t\t\t${field}: b.${field},`)
1088
+ }
1089
+ lines.push('\t\t}')
1090
+ lines.push('\t\tfor i := 0; i < len(kv); i += 2 {')
1091
+ lines.push('\t\t\tname, _ := kv[i].(string)')
1092
+ lines.push('\t\t\tvar err error')
1093
+ lines.push('\t\t\tswitch name {')
1094
+ for (const field of params) {
1095
+ // Case label and assignment target are the same name: the child's own
1096
+ // Input field. The parent (`loopRowChildPropOverrides`, via
1097
+ // `childPropFieldNames`, #2457) already resolved the JSX attribute to
1098
+ // this field before emitting the call, so there is nothing left to
1099
+ // reconcile here.
1100
+ lines.push(`\t\t\tcase ${JSON.stringify(field)}:`)
1101
+ lines.push(`\t\t\t\terr = bf.RepropsAssign(${q}, ${JSON.stringify(field)}, &in.${field}, kv[i+1])`)
1102
+ }
1103
+ // No silent drop. `bf_with_props` passes an unknown field through because
1104
+ // a rest-bag prop legitimately has no named field — but a rebuilder is
1105
+ // only emitted for components with no rest bag, and there is a case for
1106
+ // every prop the parent can override, so an unknown name here means the
1107
+ // override would go unapplied.
1108
+ lines.push('\t\t\tdefault:')
1109
+ lines.push(`\t\t\t\terr = bf.RepropsUnknownFieldError(${q}, name)`)
1110
+ lines.push('\t\t\t}')
1111
+ lines.push('\t\t\tif err != nil {')
1112
+ lines.push('\t\t\t\treturn nil, err')
1113
+ lines.push('\t\t\t}')
1114
+ lines.push('\t\t}')
1115
+ lines.push(`\t\tp := New${componentName}Props(in)`)
1116
+ lines.push('\t\t// Props-only state, absent from Input and therefore not rebuilt.')
1117
+ lines.push('\t\tp.Scripts = b.Scripts')
1118
+ lines.push('\t\tp.BfIsRoot = b.BfIsRoot')
1119
+ lines.push('\t\tp.BfIsChild = b.BfIsChild')
1120
+ lines.push('\t\tp.BfDataKey = b.BfDataKey')
1121
+ lines.push('\t\treturn p, nil')
1122
+ lines.push('\t})')
1123
+ lines.push('}')
1124
+ lines.push('')
1125
+ }
1126
+
767
1127
  /** Convert a TS type definition to Go: object types → structs, string-literal unions → a `string` alias. */
768
1128
  private typeDefinitionToGo(td: TypeDefinition): string | null {
769
1129
  // A string-literal union (`type Filter = 'all' | 'active'`) carries no
@@ -1506,7 +1866,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1506
1866
  // A hyphenated attr (`aria-label`) can't be a Go field and, with no rest
1507
1867
  // bag to route it into, has nowhere to go — skip over emitting invalid Go.
1508
1868
  if (jsxName.includes('-')) return
1509
- lines.push(`\t\t\t${capitalizeFieldName(jsxName)}: ${goValue},`)
1869
+ // Same resolution as `loopRowChildPropOverrides` (#2457): the child's
1870
+ // own Go field, which differs from the JSX attribute under an aliased
1871
+ // destructure (`{ n: count }` → field `Count`). Emitting the attribute
1872
+ // name here put an unknown field in a Go struct literal — a build
1873
+ // error rather than a silent drop, but wrong either way. Falls back to
1874
+ // the capitalized attribute for a child this run never registered.
1875
+ const fieldName =
1876
+ this.childPropFieldNames.get(child.name)?.get(jsxName) ?? capitalizeFieldName(jsxName)
1877
+ lines.push(`\t\t\t${fieldName}: ${goValue},`)
1510
1878
  }
1511
1879
  for (const prop of child.props) {
1512
1880
  switch (prop.value.kind) {
@@ -4993,6 +5361,186 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4993
5361
  )
4994
5362
  }
4995
5363
 
5364
+ /**
5365
+ * #2445: per-row Go pipeline arguments for a child component nested inside
5366
+ * a COMPOSITE loop row (row root is a plain element — `<li><Badge
5367
+ * text={row.label}/></li>` — as opposed to the wrapper-slice case where the
5368
+ * loop body IS the component). `collectStaticChildInstancesRecursive`
5369
+ * registers such a child as an ordinary once-per-slot instance
5370
+ * (`$.<Name>SlotN`), built ONCE outside `{{range}}` by
5371
+ * `emitStaticChildInstances` — correct for props that don't depend on the
5372
+ * row, but stale for one that does (`text={row.label}` reads the same
5373
+ * value on every row). A loop-dependent prop is therefore re-applied per
5374
+ * row, at template-execution time, via the `bf_with_props` runtime helper
5375
+ * (the props-argument sibling of `bf_with_children`, which already does
5376
+ * this for per-row JSX children on the same shared instance).
5377
+ *
5378
+ * A prop with no loop-bound free identifier (`IRProp.freeIdentifiers`, IR-
5379
+ * build-time computed, #1267) is left on the constructor path — it's
5380
+ * already correct there, and skipping it keeps every currently-passing
5381
+ * fixture's emitted template byte-identical (no fixture in the corpus
5382
+ * reaches this method with a loop-dependent prop today).
5383
+ *
5384
+ * #2448 — `bf_with_props` overrides fields on the ALREADY-CONSTRUCTED
5385
+ * shared instance; it does not re-run `New<Child>Props`. A field the child
5386
+ * derives FROM the overridden prop at construction time (a memo body or a
5387
+ * signal's initial value) would keep whatever the one-shot constructor
5388
+ * computed and never update per row. Two outcomes, decided per child:
5389
+ *
5390
+ * - The child has a generated props rebuilder (`childRepropsReady`): the
5391
+ * caller emits `bf_reprops` instead, which re-runs the real constructor
5392
+ * per row so every derived field recomputes. Nothing is refused.
5393
+ * - It does not (its Input can't be reconstructed from Props — see
5394
+ * `generateRepropsRegistration`'s eligibility gate): BF101 + `continue`,
5395
+ * exactly like the unsupported-expression refusal a few lines below.
5396
+ * Refusing beats emitting silently-stale output.
5397
+ *
5398
+ * #2457 — the `"Field"` half of each argument pair is the child's OWN Go
5399
+ * field name (resolved through `childPropFieldNames`), not the JSX
5400
+ * attribute name. For an un-aliased prop those are the same string; for an
5401
+ * aliased destructure (`{ n: count }`) the child's field is `Count` while
5402
+ * the JSX attribute is `n`, and emitting the attribute name left
5403
+ * `bf.WithProps`/`bf.RepropsAssign` with a name the struct has no field
5404
+ * for — silently dropped by the former, an unknown-field error from the
5405
+ * latter. Resolving it here, once, means both helpers only ever see a name
5406
+ * the struct actually has.
5407
+ *
5408
+ * Returns the space-joined `"Field" value …` argument list plus the helper
5409
+ * the caller must wrap it in, or null when nothing needs overriding (caller
5410
+ * keeps the bare `$.<Name>SlotN` reference).
5411
+ */
5412
+ private loopRowChildPropOverrides(
5413
+ comp: IRComponent,
5414
+ ): { args: string; helper: 'bf_with_props' | 'bf_reprops' } | null {
5415
+ const childShape = this.childComponentShapes.get(comp.name)
5416
+ const args: string[] = []
5417
+ // Set by the derived-field check below when at least one overridden prop
5418
+ // feeds a constructor-derived field AND the child can rebuild itself.
5419
+ let needsRebuild = false
5420
+ for (const prop of comp.props) {
5421
+ // Client-only props never reach SSR output; `key`/`children` aren't
5422
+ // Props-struct fields; event handlers have no Go field (same
5423
+ // `isEventHandler` predicate `jsx-to-ir.ts` uses for component props);
5424
+ // a hyphenated name can't be a Go field (same guard as `emitChildField`).
5425
+ if (prop.clientOnly) continue
5426
+ if (prop.name === 'key' || prop.name === 'children') continue
5427
+ if (prop.name.startsWith('on') && prop.name.length > 2) continue
5428
+ if (prop.name.includes('-')) continue
5429
+ // A prop that routes into the child's rest bag (`emitChildField`'s
5430
+ // same routing rule) has no named Go field to override — `bf_with_props`
5431
+ // would silently no-op the pair via its unknown-field passthrough.
5432
+ // Leave it on the constructor-only path (unchanged from before this
5433
+ // fix) rather than emit a pipeline argument that can never land.
5434
+ if (childShape?.restBagField && !childShape.paramNames.has(prop.name)) continue
5435
+ // `literal` / `boolean-shorthand` / `boolean-attr` carry no runtime
5436
+ // expression to re-evaluate per row; `spread` / `jsx-children` are
5437
+ // handled elsewhere (`emitSpreadBagInits`, `queueLoopBodyChildrenDefine`).
5438
+ if (prop.value.kind !== 'expression') continue
5439
+ const free = prop.freeIdentifiers
5440
+ if (!free || ![...free].some(name => this.isLoopShadowedName(name))) continue
5441
+ // #2448: does this prop feed a field the child's CONSTRUCTOR derives?
5442
+ // If so, patching fields on the shared instance leaves that field at the
5443
+ // one-shot value on every row. Re-run the constructor per row when the
5444
+ // child has a rebuilder; refuse when it doesn't.
5445
+ {
5446
+ const derived = this.childDerivedFieldDeps.get(comp.name)
5447
+ const overriddenField = capitalizeFieldName(prop.name)
5448
+ const staleField = derived
5449
+ ? [...derived].find(([, deps]) => deps.has(overriddenField))?.[0]
5450
+ : undefined
5451
+ if (staleField && !this.childRepropsReady.has(comp.name)) {
5452
+ this.state.errors.push({
5453
+ code: 'BF101',
5454
+ severity: 'error',
5455
+ message: `Prop '${prop.name}' on <${comp.name}> nested inside a dynamic loop row is overridden per row, but <${comp.name}>'s '${staleField}' field is computed from '${prop.name}' when the shared instance is first constructed and won't recompute per row — it would keep the first row's value on every row.`,
5456
+ loc: prop.loc,
5457
+ suggestion: {
5458
+ message: `Mark this loop position '@client' to render <${comp.name}> client-side, or compute '${staleField}' in the parent and pass it to <${comp.name}> as a plain prop instead of deriving it inside <${comp.name}>.`,
5459
+ },
5460
+ })
5461
+ continue
5462
+ }
5463
+ if (staleField) {
5464
+ needsRebuild = true
5465
+ // The rebuilder is emitted into THIS component's type block, not the
5466
+ // child's — only here do we know it is actually needed. First parent
5467
+ // to claim a child owns the registration, so two parents overriding
5468
+ // the same child don't both emit an `init()` for it.
5469
+ if (!this.repropsOwner.has(comp.name)) {
5470
+ this.repropsOwner.set(comp.name, this.state.componentName)
5471
+ }
5472
+ }
5473
+ }
5474
+ const exprOut: { parsed?: ParsedExpr } = {}
5475
+ const errorCountBefore = this.state.errors.length
5476
+ let go = this.convertExpressionToGo(prop.value.expr, exprOut, prop.value.parsed)
5477
+ if (this.state.errors.length > errorCountBefore) {
5478
+ // `convertExpressionToGo` already pushed its own BF101 for an
5479
+ // unsupported expression and returned the `""` sentinel — using it
5480
+ // here would silently clobber the field with an empty string (or,
5481
+ // for a non-string field, fail at template EXECUTE time instead of
5482
+ // this compile time). Leave the prop on the constructor path; the
5483
+ // reported error already surfaces the real problem. `loc` defaults
5484
+ // to `convertExpressionToGo`'s own `this.makeLoc()` placeholder —
5485
+ // repoint the newly-pushed error(s) at the prop's real source
5486
+ // location, same as the fragment-refusal error below.
5487
+ for (let i = errorCountBefore; i < this.state.errors.length; i++) {
5488
+ this.state.errors[i].loc = prop.loc
5489
+ }
5490
+ continue
5491
+ }
5492
+ // A ternary / single-interpolation template literal with STRING-typed
5493
+ // branches (`row.on ? "yes" : "no"`, `${row.x}` alone) parses to a
5494
+ // ParsedExpr `template-literal` whose sole part is non-string —
5495
+ // `templateLiteral()` wraps that one dynamic part's already-bare
5496
+ // pipeline value (e.g. `(bf_ternary ...)`, #2335) in a bare `{{...}}`
5497
+ // shell meant for TEXT-position embedding. A multi-part template
5498
+ // literal (mixed literal text and interpolation) has no such reduction
5499
+ // and stays refused below. Unwrap the single-part case back to the
5500
+ // bare pipeline value this call site (a function ARGUMENT position,
5501
+ // not a text position) needs.
5502
+ const singlePartTemplateLiteral =
5503
+ exprOut.parsed?.kind === 'template-literal' &&
5504
+ exprOut.parsed.parts.length === 1 &&
5505
+ exprOut.parsed.parts[0].type !== 'string' &&
5506
+ go.startsWith('{{') &&
5507
+ go.endsWith('}}')
5508
+ if (singlePartTemplateLiteral) {
5509
+ go = go.slice(2, -2)
5510
+ }
5511
+ // Skip the fragment re-check for the just-unwrapped single-part case —
5512
+ // `kind` is still (accurately) `template-literal`, which would
5513
+ // otherwise re-trip `isTemplateFragment`'s `kind === 'template-literal'`
5514
+ // branch on the ALREADY-unwrapped bare value.
5515
+ if (!singlePartTemplateLiteral && this.isTemplateFragment(go, exprOut.parsed?.kind)) {
5516
+ // A genuine multi-part `{{if}}...{{end}}`-shaped fragment can't be a
5517
+ // bare pipeline argument — refuse loudly rather than silently
5518
+ // dropping the prop (the #2445 bug was exactly a silent drop one
5519
+ // level up).
5520
+ this.state.errors.push({
5521
+ code: 'BF101',
5522
+ severity: 'error',
5523
+ message: `Prop '${prop.name}' on <${comp.name}> nested inside a dynamic loop row reads the row but can't be lowered to a Go template pipeline argument`,
5524
+ loc: prop.loc,
5525
+ })
5526
+ continue
5527
+ }
5528
+ // #2457: emit the CHILD's own Go field name, not the JSX attribute name.
5529
+ // They differ under an aliased destructure (`{ n: count }` → attribute
5530
+ // `n`, field `Count`); resolving through `childPropFieldNames` here —
5531
+ // the parent's emission site, the only place that has both the JSX
5532
+ // name and the child's shape — means `bf.WithProps`/`bf.RepropsAssign`
5533
+ // never see a name the struct doesn't have. Falls back to today's
5534
+ // capitalized-attribute behaviour for a cross-file child this run's
5535
+ // pre-pass never registered (`childPropFieldNames` has no entry).
5536
+ const fieldName =
5537
+ this.childPropFieldNames.get(comp.name)?.get(prop.name) ?? capitalizeFieldName(prop.name)
5538
+ args.push(`${JSON.stringify(fieldName)} ${wrapIfMultiToken(go)}`)
5539
+ }
5540
+ if (args.length === 0) return null
5541
+ return { args: args.join(' '), helper: needsRebuild ? 'bf_reprops' : 'bf_with_props' }
5542
+ }
5543
+
4996
5544
  /**
4997
5545
  * Resolve `IDENT['key']` / `IDENT["key"]` where `IDENT` is a module-scope
4998
5546
  * object-literal const and the key is a string literal — a compile-time-static
@@ -5744,6 +6292,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5744
6292
  // nested loops with the same index var name from clobbering the outer entry
5745
6293
  // on cleanup.
5746
6294
  const addedLoopVars: string[] = []
6295
+ // A `.map()` callback preamble lowers to one `{{$cls := …}}` per-row
6296
+ // variable per declaration (#2447). Registering the names on
6297
+ // `loopVarRefCount` is what makes `identifier()` resolve each read as
6298
+ // `$cls`; without it the read falls through to `rootFieldRef` and emits
6299
+ // `$.Cls` — a PARENT-struct field, never populated, which rendered the
6300
+ // attribute empty on every row (the same hoisted-to-parent class of
6301
+ // defect as #2445).
6302
+ for (const d of loop.preamble?.declarations ?? []) {
6303
+ this.loopVarRefCount.set(d.name, (this.loopVarRefCount.get(d.name) ?? 0) + 1)
6304
+ addedLoopVars.push(d.name)
6305
+ }
5747
6306
  let pushedBindingMap = false
5748
6307
  if (supportableDestructure) {
5749
6308
  // Bindings resolve against the synthetic `$__bf_item` range var; don't push
@@ -5777,6 +6336,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5777
6336
  )
5778
6337
  this.loopWrapperStack.push(!!loop.childComponent)
5779
6338
  this.loopKeyDepthStack.push(loop.depth)
6339
+ // Rendered inside the pushed loop scope so each initializer resolves
6340
+ // against the range item (`.Done`), in source order so a later
6341
+ // initializer sees an earlier `$` variable — same as the source block.
6342
+ const preambleAssignments = (loop.preamble?.declarations ?? [])
6343
+ .map(d => `{{$${d.name} := ${this.renderParsedExpr(d.valueParsed)}}}`)
6344
+ .join('')
5780
6345
  const children = this.renderChildren(loop.children)
5781
6346
  this.loopKeyDepthStack.pop()
5782
6347
  this.loopWrapperStack.pop()
@@ -5838,10 +6403,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5838
6403
  filterCond = 'true'
5839
6404
  }
5840
6405
 
5841
- return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}{{if ${filterCond}}}${itemMarker}${children}{{end}}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
6406
+ // The preamble assignments sit INSIDE the `{{if}}`, matching JS
6407
+ // evaluation order: a `.filter(p).map(cb)` chain never runs `cb`'s
6408
+ // body for a filtered-out item.
6409
+ return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}{{if ${filterCond}}}${preambleAssignments}${itemMarker}${children}{{end}}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
5842
6410
  }
5843
6411
 
5844
- return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
6412
+ return `{{bfComment "loop:${loop.markerId}"}}{{range $${rangeIndex}, $${rangeValue} := ${goArray}}}${preambleAssignments}${itemMarker}${children}{{end}}{{bfComment "/loop:${loop.markerId}"}}`
5845
6413
  }
5846
6414
 
5847
6415
  /**
@@ -6013,17 +6581,33 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6013
6581
  } else if (this.inLoop && comp.slotId) {
6014
6582
  // Non-wrapper loop (component nested inside an element item, #2130):
6015
6583
  // the range iterates the REAL collection, so `.` is the raw datum and
6016
- // carries none of the child's props. Call through the parent's
6017
- // once-per-slot instance via the root context (`$` — the define's own
6018
- // data, i.e. the parent's Props), injecting per-item content through a
6019
- // loop-body children define executed with the datum (`.`). The shared
6020
- // instance means identical child scope IDs across rows — the same
6584
+ // carries none of the child's props by default. Call through the
6585
+ // parent's once-per-slot instance via the root context (`$` — the
6586
+ // define's own data, i.e. the parent's Props). Two kinds of per-row
6587
+ // content have to reach that shared instance at template-execution
6588
+ // time: JSX children, injected via a loop-body children define
6589
+ // executed with the datum (`bf_with_children`), and any PROP that
6590
+ // reads the row (`text={row.label}`, #2445) — the instance is built
6591
+ // ONCE outside `{{range}}`, so a loop-dependent prop is stale there
6592
+ // and is reapplied per row via `bf_with_props`
6593
+ // (`loopRowChildPropOverrides`). The shared BASE instance (built once)
6594
+ // still carries identical child scope IDs across rows — the same
6021
6595
  // contract the wrapper machinery's `bodyChildInstances` already uses.
6022
6596
  const suffix = slotIdToFieldSuffix(comp.slotId)
6597
+ const overrides = this.loopRowChildPropOverrides(comp)
6023
6598
  const loopBodyDefine = this.queueLoopBodyChildrenDefine(comp)
6599
+ // `bf_reprops` re-runs the child's constructor and so takes the
6600
+ // component name; `bf_with_props` patches fields and doesn't (#2448).
6601
+ // Either way the props helper stays INNER — `bf_with_children` applies
6602
+ // the row's children last, on the rebuilt value.
6603
+ const base = overrides
6604
+ ? overrides.helper === 'bf_reprops'
6605
+ ? `(bf_reprops ${JSON.stringify(comp.name)} $.${comp.name}${suffix} ${overrides.args})`
6606
+ : `(bf_with_props $.${comp.name}${suffix} ${overrides.args})`
6607
+ : `$.${comp.name}${suffix}`
6024
6608
  templateCall = loopBodyDefine
6025
- ? `{{template "${comp.name}" (bf_with_children $.${comp.name}${suffix} (bf_tmpl "${loopBodyDefine}" .))}}`
6026
- : `{{template "${comp.name}" $.${comp.name}${suffix}}}`
6609
+ ? `{{template "${comp.name}" (bf_with_children ${base} (bf_tmpl "${loopBodyDefine}" .))}}`
6610
+ : `{{template "${comp.name}" ${base}}}`
6027
6611
  } else if (this.inLoop) {
6028
6612
  // Loop-nested component without a slotId: no parent field to route
6029
6613
  // through — legacy passthrough of the current dot.