@barefootjs/go-template 0.31.9 → 0.32.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.
@@ -119,7 +119,7 @@ import type {
119
119
  } from "./lib/types.ts"
120
120
  import { collectRootScopeNodes } from "./lib/ir-scope.ts"
121
121
  import { GO_TEMPLATE_PRIMITIVES } from "./lib/constants.ts"
122
- import { CompileState } from "./lib/compile-state.ts"
122
+ import { CompileState, resolveSignalParsedThroughSeedPlan } from "./lib/compile-state.ts"
123
123
  import { hasClientInteractivity, findNestedComponents } from "./analysis/component-tree.ts"
124
124
  import { analyzeBakeableStaticChildLoop, scalarToGoLiteral, type BakedStaticChildLoop } from "./analysis/static-child-loop-bake.ts"
125
125
  import { analyzeBakeableStaticElementLoop } from "./analysis/static-element-loop-bake.ts"
@@ -255,6 +255,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
255
255
  extractPropNameFromInitialValue: (initialValue, preParsed) =>
256
256
  this.extractPropNameFromInitialValue(initialValue, preParsed),
257
257
  extractPropFallback: (initialValue, preParsed) => this.extractPropFallback(initialValue, preParsed),
258
+ extractCollisionDerivation: (parsed) => this.extractCollisionDerivation(parsed),
258
259
  resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
259
260
  }
260
261
 
@@ -1873,6 +1874,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1873
1874
  lines.push('')
1874
1875
  }
1875
1876
 
1877
+ this.emitCallerPropsInit(lines, ir, nestedComponents, staticWithoutBody, staticWithBody, dynamicWithBody, emittedWrapperVars, propTypeOverrides)
1878
+
1876
1879
  lines.push(`\treturn ${propsTypeName}{`)
1877
1880
  lines.push('\t\tScopeID: scopeID,')
1878
1881
  // Host context, for when *this* component is itself a slot-attached child.
@@ -1881,6 +1884,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1881
1884
  if (this.usesSearchParams(ir)) {
1882
1885
  lines.push('\t\tSearchParams: in.SearchParams,')
1883
1886
  }
1887
+ lines.push('\t\tBfCallerProps: bfCallerProps,')
1884
1888
 
1885
1889
  const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
1886
1890
 
@@ -1918,7 +1922,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1918
1922
  if (this.isNestedArrayShadowed(param, nestedArrayFields)) continue
1919
1923
  const hoisted = propFallbackVars.get(param.name)
1920
1924
  if (hoisted) {
1921
- lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
1925
+ // #2683: a `collisionWrap` entry means this field is ALSO a
1926
+ // colliding signal's shared field — `hoisted.varName` alone only
1927
+ // carries the coalesced `props.X ?? <lit>` value, so the surrounding
1928
+ // arithmetic (`* 2`, etc.) is composed back on here, giving the
1929
+ // FULLY DERIVED value the signal's own initializer computes. Without
1930
+ // this the field would silently carry just the coalesce result (the
1931
+ // same class of bug this fix addresses, one operator short).
1932
+ const value = hoisted.collisionWrap
1933
+ ? `${hoisted.varName} ${hoisted.collisionWrap.operator} ${hoisted.collisionWrap.operand}`
1934
+ : hoisted.varName
1935
+ lines.push(`\t\t${fieldName}: ${value},`)
1922
1936
  } else {
1923
1937
  const paramDefault = goPropDefault(param.defaultValue)
1924
1938
  const memoFold = memoFallbacks.get(fieldName)
@@ -1948,7 +1962,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1948
1962
  if (propFieldNames.has(fieldName)) continue
1949
1963
  // `props.X ?? N` reuses the hoisted fallback var so signal and memo share
1950
1964
  // one value.
1951
- const fallbackMatch = this.extractPropFallback(signal.initialValue, signal.parsed)
1965
+ const fallbackMatch = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
1952
1966
  const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
1953
1967
  if (hoisted) {
1954
1968
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
@@ -2038,6 +2052,139 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2038
2052
  lines.push('}')
2039
2053
  }
2040
2054
 
2055
+ /**
2056
+ * Build the `bfCallerProps` local (assigned to `BfCallerProps` in the
2057
+ * returned struct) — the hydration-only, caller-supplied-keys-only view
2058
+ * of this component's props (#2684). See the field's doc comment
2059
+ * (`emitPropsStructHeader`) for the two-consumers rationale.
2060
+ *
2061
+ * Three classes, mirroring the issue's taxonomy — gated on `param.optional`
2062
+ * (NOT on the resolved Go type's nillability alone, even though that reads
2063
+ * as the more "obvious" test): a REQUIRED prop must stay unconditional
2064
+ * even when its resolved type happens to be nillable (`ctx: unknown`, the
2065
+ * `array-flatmap-thisarg` fixture passes `ctx: null` explicitly) — Go's
2066
+ * `interface{}` cannot tell "explicit null" from "omitted" once
2067
+ * unmarshaled (both are the nil zero value), so nil-checking a required
2068
+ * field would silently turn an intentional `null` into a dropped key.
2069
+ * 1. Required prop → always included, raw `in.<InputField>` (never the
2070
+ * defaulted Props-field value — a caller who passes the same value
2071
+ * as the author's default is indistinguishable from one who didn't,
2072
+ * but that's inherent to a required prop having no "omitted" state).
2073
+ * 2. Optional prop whose resolved Go type is nillable (`interface{}`,
2074
+ * `map[string]interface{}`, or any slice `[]T`) → included only
2075
+ * when `in.<InputField> != nil`, i.e. only when the caller actually
2076
+ * passed something.
2077
+ * 3. Optional prop that nonetheless resolved to a CONCRETE type (a
2078
+ * string-union alias like `placement?: 'top' | 'bottom'`, a bare
2079
+ * scalar never consumed nullish/attr/text/presence-wise, OR a type
2080
+ * that resolves to `interface{}` some OTHER way — e.g. an inherited
2081
+ * `extends` clause the analyzer can't fully resolve — while
2082
+ * `param.optional` itself is, possibly inaccurately, `false`) —
2083
+ * presence is unknowable from Input alone, so it's included
2084
+ * unconditionally, same as class 1. A documented residual, not
2085
+ * silently dropped; see the PR that introduced this method for named
2086
+ * instances found in the corpus (e.g. `textarea`'s `rows`, inherited
2087
+ * through `TextareaHTMLAttributes`). Do NOT widen these props' types
2088
+ * to "fix" this — that's `resolvePropGoType`'s call, not this
2089
+ * method's, and doing so would risk the same `ctx`-style regression.
2090
+ *
2091
+ * `children` is excluded outright (#1952 — a separately-declared
2092
+ * position, not relitigated here). Rest-props bags are never added to
2093
+ * the Props struct's JSON at all (checked: no field exists for them
2094
+ * outside Input), so nothing to exclude here either — already at parity
2095
+ * with the reference, which never serializes rest keys.
2096
+ *
2097
+ * A prop shadowed by its own driving nested-array field (`isNestedArrayShadowed`
2098
+ * — e.g. `toggleItems` colliding with a `<ToggleItem>` loop's derived
2099
+ * `ToggleItems` field, #2672/#2525) has NO Props/Input field of its own;
2100
+ * the reshaped nested-array local (`varName`, already built above by the
2101
+ * static/dynamic body-wrapper emission) is its only remaining carrier, so
2102
+ * that's what gets keyed in here instead of `in.<InputField>`. KNOWN
2103
+ * RESIDUAL: each item within that array is still the OLD whole-struct
2104
+ * marshal (baked author defaults / null-for-absent / zero-for-required
2105
+ * inside every item) — recursing the sidecar into embedded Props-struct
2106
+ * arrays needs either a generated `MarshalJSON` per Props type or a
2107
+ * runtime array-flattening helper, a bigger surface this method's design
2108
+ * doesn't cover. Reported, not chased (see PR description).
2109
+ */
2110
+ private emitCallerPropsInit(
2111
+ lines: string[],
2112
+ ir: ComponentIR,
2113
+ nestedComponents: NestedComponentInfo[],
2114
+ staticWithoutBody: NestedComponentInfo[],
2115
+ staticWithBody: NestedComponentInfo[],
2116
+ dynamicWithBody: NestedComponentInfo[],
2117
+ emittedWrapperVars: Set<string>,
2118
+ propTypeOverrides: Map<string, string>,
2119
+ ): void {
2120
+ lines.push('\tbfCallerProps := map[string]interface{}{}')
2121
+ const bfCallerPropsTakenTags = new Set<string>()
2122
+ const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
2123
+
2124
+ for (const param of ir.metadata.propsParams) {
2125
+ // Children are DOM content, not hydration data (#1952) — excluded the
2126
+ // same way `emitPropsDataFields` excludes them from the Props struct.
2127
+ if (param.name === 'children') continue
2128
+ // Shadowed by its own driving nested-array field (#2672/#2525) — no
2129
+ // Input field exists for it at all; handled below via the array local.
2130
+ if (this.isNestedArrayShadowed(param, nestedArrayFields)) continue
2131
+ // Same tag algorithm `emitPropsDataFields` uses for the Props field's
2132
+ // own `json:"…"` tag, re-derived against a FRESH set: props are always
2133
+ // processed first, in the same order, over an empty set in BOTH
2134
+ // places, so the two computations can't disagree.
2135
+ const callerKey = this.claimJsonTag(this.toJsonTag(param.sourceName ?? param.name), bfCallerPropsTakenTags)
2136
+ if (callerKey === '-') continue // tag collision — dropped from the wire, same as the Props field itself would be
2137
+ const inputField = `in.${capitalizeFieldName(param.sourceName ?? param.name)}`
2138
+ const goType = resolvePropGoType(this.emitCtx, param, propTypeOverrides)
2139
+ const isNillable = goType === 'interface{}' || goType === 'map[string]interface{}' || goType.startsWith('[]')
2140
+ // `param.optional` gates the branch (not "isNillable alone") because a
2141
+ // REQUIRED prop must stay unconditional even when its resolved type
2142
+ // happens to be nillable (`ctx: unknown`, #2094's array-flatmap-thisarg
2143
+ // fixture passes `ctx: null` explicitly) — Go's `interface{}` cannot
2144
+ // tell "explicit null" from "omitted" once unmarshaled (both are the
2145
+ // nil zero value), so a nil-check here would silently turn an
2146
+ // intentional `null` into a dropped key, regressing a fixture that
2147
+ // matched the reference before this method existed. `resolvePropGoType`
2148
+ // never applies its nillable flip to a required prop, so this ONLY
2149
+ // affects the rare case where a type resolves to `interface{}` some
2150
+ // OTHER way (e.g. `unknown`, or a type inherited through an
2151
+ // unresolved external `extends` clause) while `param.optional` is
2152
+ // (accurately or not) false — see the method docstring's residual list.
2153
+ if (param.optional && isNillable) {
2154
+ lines.push(`\tif ${inputField} != nil {`)
2155
+ lines.push(`\t\tbfCallerProps["${callerKey}"] = ${inputField}`)
2156
+ lines.push(`\t}`)
2157
+ } else {
2158
+ // Required (class 1), or optional-but-concrete residual (class 3),
2159
+ // or optional-but-misresolved-required residual — see docstring.
2160
+ lines.push(`\tbfCallerProps["${callerKey}"] = ${inputField}`)
2161
+ }
2162
+ }
2163
+
2164
+ // Shadowed nested-array props — see method docstring.
2165
+ for (const nested of [...staticWithoutBody, ...staticWithBody, ...dynamicWithBody]) {
2166
+ if (!nested.isPropDerived) continue
2167
+ const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2168
+ const isBuilt = staticWithoutBody.includes(nested) || emittedWrapperVars.has(varName)
2169
+ if (!isBuilt) continue
2170
+ const arrayFieldName = capitalizeFieldName(`${nested.name}s`)
2171
+ const param = ir.metadata.propsParams.find(
2172
+ p => capitalizeFieldName(p.name) === arrayFieldName || capitalizeFieldName(p.sourceName ?? p.name) === arrayFieldName,
2173
+ )
2174
+ if (!param || !this.isNestedArrayShadowed(param, nestedArrayFields)) continue
2175
+ const callerKey = this.claimJsonTag(this.toJsonTag(param.sourceName ?? param.name), bfCallerPropsTakenTags)
2176
+ if (callerKey === '-') continue
2177
+ if (param.optional) {
2178
+ lines.push(`\tif in.${nested.name}s != nil {`)
2179
+ lines.push(`\t\tbfCallerProps["${callerKey}"] = ${varName}`)
2180
+ lines.push(`\t}`)
2181
+ } else {
2182
+ lines.push(`\tbfCallerProps["${callerKey}"] = ${varName}`)
2183
+ }
2184
+ }
2185
+ lines.push('')
2186
+ }
2187
+
2041
2188
  private emitStaticChildInstances(lines: string[], ir: ComponentIR): void {
2042
2189
  const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
2043
2190
  for (const child of staticChildren) {
@@ -2741,6 +2888,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2741
2888
  if (this.usesSearchParams(ir)) {
2742
2889
  lines.push('\tSearchParams bf.SearchParams `json:"-"`')
2743
2890
  }
2891
+
2892
+ // #2684: the raw, CALLER-SUPPLIED view of this component's props —
2893
+ // populated by `NewXxxProps` with exactly the keys the caller passed
2894
+ // (required props always; optional ones only when not nil), holding
2895
+ // the UNDEFAULTED value. This exists because every prop field above
2896
+ // (e.g. `X`) serves TWO different consumers that want two different
2897
+ // values: the Go TEMPLATE wants the DEFAULTED value so `{{.X}}` renders
2898
+ // the author's fallback, while the HYDRATION PAYLOAD wants to know only
2899
+ // what the caller actually supplied — baking the author's default into
2900
+ // it makes an omitted prop indistinguishable from one explicitly passed
2901
+ // with that value, and a Go zero value indistinguishable from an
2902
+ // explicit zero/empty/false (the reference, Hono, only ever serializes
2903
+ // caller-passed keys). One field can't answer both questions at once,
2904
+ // so this sidecar gives the hydration payload its own carrier;
2905
+ // `BfPropsAttr` (runtime/bf.go) marshals THIS map instead of the whole
2906
+ // struct when it's present. `json:"-"` — never marshaled as an ordinary
2907
+ // struct field; `BfPropsAttr` reads it directly by name via reflection.
2908
+ lines.push('\tBfCallerProps map[string]interface{} `json:"-"`')
2744
2909
  }
2745
2910
 
2746
2911
  private emitPropsDataFields(
@@ -2782,7 +2947,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2782
2947
  if (signal.envReader) continue
2783
2948
  const fieldName = capitalizeFieldName(signal.getter)
2784
2949
  if (propFieldNames.has(fieldName)) continue
2785
- const jsonTag = this.claimJsonTag(this.toJsonTag(signal.getter), takenJsonTags)
2950
+ // Signal fields are component-internal state, not caller input (#2672):
2951
+ // the client never reads `_p.<signalGetter>` — it re-derives the value
2952
+ // from `createSignal(...)`'s own initial expression (which itself reads
2953
+ // whatever PROP field seeded it, already emitted above with a real tag
2954
+ // by the propsParams loop). Excluding the signal field from JSON stops
2955
+ // it from co-boarding into `bf-p` while leaving `{{.Field}}` SSR access
2956
+ // untouched — Go template field access doesn't consult json tags.
2957
+ const jsonTag = '-'
2786
2958
  // A synthesised struct type wins outright — the signal is an untyped
2787
2959
  // object array we gave a concrete element type.
2788
2960
  const synthType = this.state.synthStructTypes.get(signal.getter)
@@ -2832,7 +3004,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2832
3004
  for (const memo of ir.metadata.memos) {
2833
3005
  const fieldName = capitalizeFieldName(memo.name)
2834
3006
  if (propFieldNames.has(fieldName)) continue
2835
- const jsonTag = this.claimJsonTag(this.toJsonTag(memo.name), takenJsonTags)
3007
+ // Memo fields are derived, re-computed client-side from the same prop
3008
+ // reads the memo body itself performs — never read as `_p.<memoName>`
3009
+ // (#2672). Same rationale as the signal fields above.
3010
+ const jsonTag = '-'
2836
3011
  const goType = this.inferMemoType(memo, ir.metadata.signals, propsParamMap)
2837
3012
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
2838
3013
  }
@@ -2868,10 +3043,27 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2868
3043
  ...ir.metadata.memos.map(m => capitalizeFieldName(m.name)),
2869
3044
  ])
2870
3045
  for (const c of this.nonCollidingContextConsumers(takenProps)) {
2871
- const jsonTag = this.claimJsonTag(this.toJsonTag(c.localName), takenJsonTags)
3046
+ // Context-consumer fields are resolved server-side from the enclosing
3047
+ // `Provider` and read via `{{.Field}}` in SSR only — the client's own
3048
+ // `useContext` re-reads the DOM-scoped provider value at hydration
3049
+ // time, never `_p.<contextField>` (#2672).
3050
+ const jsonTag = '-'
2872
3051
  lines.push(`\t${this.contextFieldName(c)} ${this.contextConsumerGoType(c)} \`json:"${jsonTag}"\``)
2873
3052
  }
2874
3053
 
3054
+ // Capitalized Go field names of every declared prop — BOTH the LOCAL
3055
+ // binding and the caller-facing (`sourceName`) spelling, unioned the
3056
+ // same way `isNestedArrayShadowed` does for the propsParams loop above
3057
+ // (an aliased destructure like `{ rows: items }` can collide under
3058
+ // either naming depending on alias direction — see the #2525 collision
3059
+ // tests). Used below to detect when a nested-array field's name
3060
+ // collides with (and shadows) an actual prop field.
3061
+ const propDrivingFieldNames = new Set<string>()
3062
+ for (const p of ir.metadata.propsParams) {
3063
+ propDrivingFieldNames.add(capitalizeFieldName(p.name))
3064
+ propDrivingFieldNames.add(capitalizeFieldName(p.sourceName ?? p.name))
3065
+ }
3066
+
2875
3067
  for (const nested of nestedComponents) {
2876
3068
  // An orphaned clientOnly nested loop (#2627 — see
2877
3069
  // `isOrphanedClientOnlyNested`) gets NO Props field at all, not even
@@ -2889,8 +3081,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2889
3081
  if (nested.isDynamic && !nested.isPropDerived) {
2890
3082
  // Dynamic signal-array loops are template-only.
2891
3083
  lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
3084
+ } else if (
3085
+ nested.isDynamic &&
3086
+ nested.isPropDerived &&
3087
+ !propDrivingFieldNames.has(`${nested.name}s`)
3088
+ ) {
3089
+ // Prop-derived dynamic loops (`props.items.map(item => <Child/>)`,
3090
+ // #2672): this field is USUALLY a RE-SHAPED COPY of the driving prop,
3091
+ // built for SSR's `{{range}}` — not the prop itself. The client's own
3092
+ // `mapArray` re-derives every row straight from the real prop field
3093
+ // (`_p.items`, emitted with a real tag by the propsParams loop
3094
+ // above), never from `_p.<Name>s`, so co-boarding this copy into
3095
+ // `bf-p` is redundant component-internal derivation, same as a memo.
3096
+ //
3097
+ // EXCEPT when the two Go field names collide (`propDrivingFieldNames`
3098
+ // — mirrors `isNestedArrayShadowed`'s check the propsParams loop
3099
+ // itself runs): a prop named `toggleItems` driving a `<ToggleItem>`
3100
+ // loop capitalizes to the SAME Go field name as the nested-array
3101
+ // field (`ToggleItems`), so `emitPropsDataFields` shadows the prop's
3102
+ // OWN field entirely and this array field is the ONLY carrier of
3103
+ // that prop's data. Flipping it there would silently drop caller
3104
+ // input from `bf-p` instead of merely trimming a redundant copy —
3105
+ // the `else` branch below keeps a real tag for exactly that case.
3106
+ lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
2892
3107
  } else {
2893
- // Static + prop-derived arrays go in JSON so the client can hydrate.
3108
+ // Static arrays go in JSON so the client can hydrate (that data can
3109
+ // be non-literal, request-time Input the caller supplies with no
3110
+ // prop-field twin to fall back on) — and so does a prop-derived
3111
+ // dynamic array whose field name shadows its own driving prop's
3112
+ // field (the `propDrivingFieldNames` case above): this field is
3113
+ // that prop's ONLY remaining carrier in the struct.
2894
3114
  const jsonTag = this.claimJsonTag(
2895
3115
  this.toJsonTag(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`),
2896
3116
  takenJsonTags,
@@ -2906,9 +3126,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2906
3126
 
2907
3127
  // Top-level intrinsic-element spreads: each gets a `Spread_<slotId>
2908
3128
  // map[string]any` field the template reads via `{{bf_spread_attrs}}`.
2909
- // Loop-internal spreads emit inline and don't appear here.
3129
+ // Loop-internal spreads emit inline and don't appear here. SSR-only —
3130
+ // the resolved attrs are already baked into the rendered HTML, and no
3131
+ // client runtime reads `_p.Spread_<slotId>` back out of `bf-p` (#2672).
2910
3132
  for (const slot of spreadSlots) {
2911
- const jsonTag = this.claimJsonTag(this.toJsonTag(slot.slotId), takenJsonTags)
3133
+ const jsonTag = '-'
2912
3134
  lines.push(`\t${slot.slotId} map[string]any \`json:"${jsonTag}"\``)
2913
3135
  }
2914
3136
  }
@@ -3531,7 +3753,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3531
3753
 
3532
3754
  const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
3533
3755
  for (const signal of ir.metadata.signals) {
3534
- const match = this.extractPropFallback(signal.initialValue, signal.parsed)
3756
+ let match = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
3757
+ // #2683: the idempotent fold above declines a non-idempotent
3758
+ // derivation (`(props.count ?? 1) * 2`) at the top level. When that
3759
+ // signal's Go field name COLLIDES with the prop's own field — the
3760
+ // exact condition under which the props-field loop below would
3761
+ // otherwise silently drop the derivation and emit the raw prop
3762
+ // instead — fall back to the collision-derivation matcher so the
3763
+ // coalesced value still gets hoisted; the props-field loop composes
3764
+ // the surrounding arithmetic back on top of it (`collisionWrap`).
3765
+ // Gated strictly on the collision so a differently-named signal
3766
+ // deriving from the same prop (not colliding) is untouched — that
3767
+ // path's own dedicated field keeps today's behavior byte-for-byte.
3768
+ let collisionWrap: { operator: string; operand: string } | undefined
3769
+ if (!match) {
3770
+ const collision = this.extractCollisionDerivation(this.resolvedSignalParsed(signal))
3771
+ if (collision) {
3772
+ const collisionParam = ir.metadata.propsParams.find(p => p.name === collision.propName)
3773
+ const collisionField = collisionParam
3774
+ ? capitalizeFieldName(collisionParam.sourceName ?? collision.propName)
3775
+ : null
3776
+ if (collisionField && capitalizeFieldName(signal.getter) === collisionField) {
3777
+ match = { propName: collision.propName, goFallback: collision.goFallback }
3778
+ collisionWrap = { operator: collision.operator, operand: collision.operand }
3779
+ }
3780
+ }
3781
+ }
3535
3782
  if (!match) continue
3536
3783
  if (result.has(match.propName)) continue
3537
3784
  const param = ir.metadata.propsParams.find(p => p.name === match.propName)
@@ -3591,11 +3838,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3591
3838
  goFallback: match.goFallback,
3592
3839
  zeroLiteral,
3593
3840
  ...(nullishLowered ? { assertType: concreteType } : {}),
3841
+ ...(collisionWrap ? { collisionWrap } : {}),
3594
3842
  })
3595
3843
  }
3596
3844
  return result
3597
3845
  }
3598
3846
 
3847
+ /**
3848
+ * Resolve a signal's initializer through the SSR seed plan's const-hop
3849
+ * inlining (#2685) before prop-fallback extraction — see
3850
+ * {@link resolveSignalParsedThroughSeedPlan} (shared with
3851
+ * `collectNullishConsumedPropNames`'s signal-seed loop, the single door
3852
+ * both consumers of this resolution go through).
3853
+ */
3854
+ private resolvedSignalParsed(signal: { getter: string; parsed?: ParsedExpr }): ParsedExpr | undefined {
3855
+ return resolveSignalParsedThroughSeedPlan(this.state, signal)
3856
+ }
3857
+
3599
3858
  /**
3600
3859
  * Parse a signal-time initial value of the form `props.X ?? <literal>` —
3601
3860
  * or, for destructured components, `x ?? <literal>` — into the source prop
@@ -3674,6 +3933,67 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3674
3933
  return { propName, goFallback }
3675
3934
  }
3676
3935
 
3936
+ /**
3937
+ * #2683: match a signal's collision-derivation shape — `(props.X ?? <lit>)
3938
+ * <op> <int>` — the ONE non-idempotent shape this adapter faithfully lowers
3939
+ * when a signal's Go field name collides with its own prop's field (see the
3940
+ * collision-override loop in `collectPropFallbackVars` / the props-field
3941
+ * loop in `generateNewPropsFunction`). Composes two ALREADY-EXISTING
3942
+ * lowerings instead of adding a third: `extractPropFallbackFromParsed`
3943
+ * recognizes the embedded `props.X ?? <lit>` presence check exactly as it
3944
+ * does for the idempotent fold, and the wrap mirrors the SAME `<ref> <op>
3945
+ * <int>` arithmetic shape `memoInitialFromParsedBody`'s multiply-family
3946
+ * branch already supports for a bare `props.X <op> N` (non-negative
3947
+ * integer operand only, matching that branch's own restriction).
3948
+ *
3949
+ * Returns null for any other shape — the caller then keeps today's
3950
+ * raw-passthrough behavior (sound-or-loud; the remaining #2683 pin, if
3951
+ * any, covers whatever this doesn't reach).
3952
+ */
3953
+ private extractCollisionDerivation(
3954
+ parsed: ParsedExpr | undefined,
3955
+ ): { propName: string; goFallback: string; operator: string; operand: string } | null {
3956
+ if (!parsed || parsed.kind !== 'binary') return null
3957
+ if (!['*', '+', '-', '/'].includes(parsed.op)) return null
3958
+ const { right } = parsed
3959
+ if (
3960
+ right.kind !== 'literal' ||
3961
+ right.literalType !== 'number' ||
3962
+ typeof right.value !== 'number' ||
3963
+ !Number.isInteger(right.value) ||
3964
+ right.value < 0
3965
+ ) {
3966
+ return null
3967
+ }
3968
+ // Constant division by zero is a Go COMPILE error (`invalid operation:
3969
+ // division by zero`), unlike JS's Infinity — declining keeps that shape
3970
+ // on the raw-passthrough path instead of breaking the build.
3971
+ if (parsed.op === '/' && right.value === 0) return null
3972
+ // The `??` fallback itself must be a NUMERIC literal before delegating
3973
+ // to the fold: `extractPropFallbackFromParsed` happily matches a string
3974
+ // fallback (`(props.label ?? 'x') + 2`), which would hoist
3975
+ // `var label string = "x"` and emit `Label: label + 2,` — invalid Go
3976
+ // (string + int), a compile break where the pre-existing behavior at
3977
+ // least built (Copilot review on #2694). JS's `'x' + 2` is string
3978
+ // concatenation besides, so a numeric compose could never be faithful
3979
+ // for it — the shape belongs on the raw-passthrough path until someone
3980
+ // lowers it through ConcatStr deliberately. Gating on `??` (not `||`)
3981
+ // also keeps JS nullish semantics exact for numeric props, where a
3982
+ // caller's explicit `0` must NOT trigger the fallback.
3983
+ const coalesce = parsed.left
3984
+ if (
3985
+ coalesce.kind !== 'logical' ||
3986
+ coalesce.op !== '??' ||
3987
+ coalesce.right.kind !== 'literal' ||
3988
+ coalesce.right.literalType !== 'number'
3989
+ ) {
3990
+ return null
3991
+ }
3992
+ const inner = this.extractPropFallbackFromParsed(parsed.left)
3993
+ if (!inner) return null
3994
+ return { ...inner, operator: parsed.op, operand: String(right.value) }
3995
+ }
3996
+
3677
3997
  /**
3678
3998
  * Extract the prop name from a signal's `props.xxx`-pattern initialValue,
3679
3999
  * e.g. `"props.initial ?? 0"` → `"initial"`, `"props.checked"` → `"checked"`.
@@ -22,6 +22,7 @@ import type {
22
22
  IRNode,
23
23
  LoweringMatcher,
24
24
  MemoInfo,
25
+ ParsedExpr,
25
26
  SsrSeedPlan,
26
27
  TypeDefinition,
27
28
  TypeInfo,
@@ -262,3 +263,36 @@ export class CompileState {
262
263
  * `strings` is added to the generated types file's import block. */
263
264
  needsStringsImport = false
264
265
  }
266
+
267
+ /**
268
+ * Resolve a signal's initializer through the SSR seed plan's const-hop
269
+ * inlining (#2685, `resolveThroughLocalConsts` in
270
+ * `packages/jsx/src/ssr-seed-plan.ts`) before prop-fallback shape matching,
271
+ * so a component-scope `const` sitting between a `props.X` read and
272
+ * `createSignal` (`const mid = props.label; createSignal(mid ?? 'Default')`)
273
+ * doesn't hide the `props.X ?? <literal>` shape from
274
+ * `extractPropFallbackFromParsed` / `collectNullishConsumedPropNames`'s
275
+ * signal-seed loop — both need the SAME resolved tree (single door, not two
276
+ * divergent walks: a mismatch between them is exactly what let the field-type
277
+ * flip and the fallback-var construction disagree on nullish handling for
278
+ * the const-hop shape).
279
+ *
280
+ * Falls back to the signal's own `parsed` when the plan didn't classify this
281
+ * signal `derived` (opaque for an unrelated reason, e.g. a free identifier
282
+ * genuinely out of scope) — never invents a substitution the plan itself
283
+ * didn't make.
284
+ *
285
+ * `signal` takes the minimal structural shape (not the internal `SignalInfo`
286
+ * type, which `@barefootjs/jsx`'s public index doesn't export — the same
287
+ * inline-shape convention `memo-compute.ts`'s extracted helpers already use
288
+ * for signal params).
289
+ */
290
+ export function resolveSignalParsedThroughSeedPlan(
291
+ state: CompileState,
292
+ signal: { getter: string; parsed?: ParsedExpr },
293
+ ): ParsedExpr | undefined {
294
+ const step = state.ssrSeedPlan.steps.find(
295
+ s => s.kind === 'derived' && s.origin === 'signal' && s.name === signal.getter,
296
+ )
297
+ return step?.kind === 'derived' ? step.parsed : signal.parsed
298
+ }
@@ -167,6 +167,16 @@ export interface PropFallbackVar {
167
167
  * distinguishable from an absent one.
168
168
  */
169
169
  assertType?: string
170
+ /**
171
+ * #2683: set when this fallback var was hoisted from a signal's
172
+ * COLLISION-DERIVATION initializer (`(props.X ?? <lit>) <op> <int>`, where
173
+ * the signal's Go field name collides with prop X's) rather than a plain
174
+ * `props.X ?? <lit>`. `varName` above still holds only the coalesced RAW
175
+ * value (the presence-check declaration is unchanged) — this records the
176
+ * arithmetic wrap the props-field loop applies ON TOP of it so the shared
177
+ * field carries the FULLY DERIVED value, not just the coalesce result.
178
+ */
179
+ collisionWrap?: { operator: string; operand: string }
170
180
  }
171
181
 
172
182
  /**
@@ -9,6 +9,8 @@ import type { ComponentIR, IRMetadata, IRNode, ParsedExpr } from '@barefootjs/js
9
9
  import { isBooleanAttr } from '@barefootjs/jsx'
10
10
 
11
11
  import type { GoEmitContext } from '../emit-context.ts'
12
+ import { resolveSignalParsedThroughSeedPlan } from '../lib/compile-state.ts'
13
+ import { capitalizeFieldName } from '../lib/go-naming.ts'
12
14
  import { typeInfoToGo } from '../type/type-codegen.ts'
13
15
 
14
16
  /**
@@ -218,7 +220,36 @@ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: Componen
218
220
  // tree — reuse the seam's existing `props.X ?? <literal>` recognizer. The
219
221
  // zero-equivalent exclusion matches on the Go-formatted fallback literal.
220
222
  for (const signal of ir.metadata.signals) {
221
- const match = ctx.extractPropFallback(signal.initialValue, signal.parsed)
223
+ // Resolved through the seed plan's const-hop inlining (#2685) — see
224
+ // `resolveSignalParsedThroughSeedPlan` — so a component-scope `const`
225
+ // between `props.X` and `createSignal` doesn't leave this loop and
226
+ // `collectPropFallbackVars`'s identical extraction disagreeing on
227
+ // whether the prop needs the nillable flip.
228
+ const resolvedParsed = resolveSignalParsedThroughSeedPlan(ctx.state, signal)
229
+ let match = ctx.extractPropFallback(signal.initialValue, resolvedParsed)
230
+ // #2683: the idempotent fold above declines a non-idempotent derivation
231
+ // (`(props.count ?? 1) * 2`) at the top level. When that signal's Go
232
+ // field name COLLIDES with the prop's own field, the props-struct
233
+ // emitter composes the derivation onto the SHARED field (see
234
+ // `collectPropFallbackVars`'s identical collision gate) — which needs
235
+ // the SAME nillable flip an ordinary `??` fallback gets, so an absent
236
+ // prop (nil) stays distinguishable from an explicit zero-equivalent
237
+ // value. Gated strictly on the collision, matching that other site, so
238
+ // a differently-named signal deriving from the same prop (not
239
+ // colliding — its own dedicated field, untouched by this fix) doesn't
240
+ // spuriously flip the prop's type.
241
+ if (!match) {
242
+ const collision = ctx.extractCollisionDerivation(resolvedParsed)
243
+ if (collision) {
244
+ const collisionParam = ir.metadata.propsParams.find(p => p.name === collision.propName)
245
+ const collisionField = collisionParam
246
+ ? capitalizeFieldName(collisionParam.sourceName ?? collision.propName)
247
+ : null
248
+ if (collisionField && capitalizeFieldName(signal.getter) === collisionField) {
249
+ match = { propName: collision.propName, goFallback: collision.goFallback }
250
+ }
251
+ }
252
+ }
222
253
  if (!match || !optionalParams.has(match.propName)) continue
223
254
  const f = match.goFallback
224
255
  if (f === '""' || f === 'false' || f === 'nil' || Number(f) === 0) continue
@@ -6,13 +6,13 @@
6
6
  * file even when the set is empty — the next divergence lands here, not in
7
7
  * a re-created file.
8
8
  *
9
- * Empty — #2630's `static-array-from-props-with-component-precomputed`
10
- * divergence graduated once the harness (`test-render.ts`'s
9
+ * (#2630's `static-array-from-props-with-component-precomputed` divergence
10
+ * graduated once the harness (`test-render.ts`'s
11
11
  * `buildDynamicChildLoopSeeding`, despite the name — see its doc comment)
12
12
  * learned to seed a prop-backed static child-component loop's Props slice
13
13
  * the same way it already seeded a signal-backed dynamic one: the adapter's
14
14
  * own `emission` was never the bug, only this harness's route-handler
15
- * stand-in was missing the prop-derived case.
15
+ * stand-in was missing the prop-derived case.)
16
16
  */
17
17
 
18
18
  import type { RenderDivergences } from '@barefootjs/jsx'
@@ -914,11 +914,28 @@ function goSliceElemType(
914
914
  * `[]any{…}`/`map[string]interface{}{…}` for that field no longer compiles
915
915
  * against it the way it always did against the old `map[string]interface{}`
916
916
  * element type.
917
+ *
918
+ * An ARRAY element (`elemType` itself starting with `[]`, i.e. the field is
919
+ * doubly-nested — `Rows [][]int`) recurses with the INNER element type
920
+ * (`int`) instead of falling to `goArrayLiteralFromArray`'s generic
921
+ * `[]any{…}` — #2677 widened the analyzer's destructured-parameter gate to
922
+ * resolve a nested-array prop (`{ rows }: { rows: number[][] }`) to a real
923
+ * `[][]int` field (previously `unknown` → `interface{}` → `[]any`, which
924
+ * `goArrayLiteralFromArray`'s untyped literal always compiled against fine).
925
+ * Without this, the harness's OWN convenience literal-builder — not the
926
+ * production adapter's `typeInfoToGo`, which already recurses correctly —
927
+ * bakes `[]any{1, 2}` for a `[1, 2]` row, and `[][]int{[]any{1, 2}, …}`
928
+ * fails to compile against the now-concrete field.
917
929
  */
918
930
  function goTypedSliceLiteralFromArray(arr: unknown[], elemType: string, goTypes?: string): string {
919
931
  const entries = arr.map(v => {
920
932
  if (v instanceof Date) return goStringLit(v.toISOString())
921
- if (v && typeof v === 'object' && !Array.isArray(v)) {
933
+ if (Array.isArray(v)) {
934
+ return elemType.startsWith('[]')
935
+ ? goTypedSliceLiteralFromArray(v, elemType.slice(2), goTypes)
936
+ : goArrayLiteralFromArray(v)
937
+ }
938
+ if (v && typeof v === 'object') {
922
939
  return goStructLiteral(v as Record<string, unknown>, elemType, goTypes)
923
940
  }
924
941
  if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`