@barefootjs/go-template 0.31.10 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/dist/adapter/emit-context.d.ts +16 -0
  2. package/dist/adapter/emit-context.d.ts.map +1 -1
  3. package/dist/adapter/expr/helper-inline.d.ts.map +1 -1
  4. package/dist/adapter/go-template-adapter.d.ts +75 -1
  5. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  6. package/dist/adapter/index.js +165 -15
  7. package/dist/adapter/lib/types.d.ts +13 -0
  8. package/dist/adapter/lib/types.d.ts.map +1 -1
  9. package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
  10. package/dist/adapter/memo/memo-value.d.ts.map +1 -1
  11. package/dist/adapter/memo/template-interp.d.ts.map +1 -1
  12. package/dist/adapter/props/prop-types.d.ts.map +1 -1
  13. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -1
  14. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -1
  15. package/dist/adapter/value/value-lowering.d.ts +15 -12
  16. package/dist/adapter/value/value-lowering.d.ts.map +1 -1
  17. package/dist/index.js +166 -17
  18. package/dist/render-divergences.d.ts.map +1 -1
  19. package/dist/vite.js +259 -52
  20. package/package.json +5 -5
  21. package/src/__tests__/go-template-adapter.test.ts +246 -6
  22. package/src/adapter/emit-context.ts +14 -0
  23. package/src/adapter/expr/helper-inline.ts +4 -2
  24. package/src/adapter/go-template-adapter.ts +296 -9
  25. package/src/adapter/lib/types.ts +10 -0
  26. package/src/adapter/memo/memo-compute.ts +12 -1
  27. package/src/adapter/memo/memo-value.ts +3 -0
  28. package/src/adapter/memo/template-interp.ts +5 -0
  29. package/src/adapter/props/prop-types.ts +26 -1
  30. package/src/adapter/spread/spread-codegen.ts +8 -0
  31. package/src/adapter/value/parsed-literal-to-go.ts +7 -0
  32. package/src/adapter/value/value-lowering.ts +96 -22
  33. package/src/render-divergences.ts +2 -23
@@ -58,6 +58,7 @@ import {
58
58
  asCallbackMethodCall,
59
59
  sortComparatorFromArrow,
60
60
  emitParsedExpr,
61
+ groupObjectLiteralSegments,
61
62
  emitIRNode,
62
63
  emitAttrValue,
63
64
  augmentInheritedPropAccesses,
@@ -255,6 +256,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
255
256
  extractPropNameFromInitialValue: (initialValue, preParsed) =>
256
257
  this.extractPropNameFromInitialValue(initialValue, preParsed),
257
258
  extractPropFallback: (initialValue, preParsed) => this.extractPropFallback(initialValue, preParsed),
259
+ extractCollisionDerivation: (parsed) => this.extractCollisionDerivation(parsed),
258
260
  resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
259
261
  }
260
262
 
@@ -1369,6 +1371,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1369
1371
  if (el.kind !== 'object-literal') return null
1370
1372
  const seen = new Set<string>()
1371
1373
  for (const prop of el.properties) {
1374
+ // A spread (`{ ...t, a: 1 }`, #2696 Step 2) isn't a plain per-key
1375
+ // scalar shape this fast path bakes — fall back (the caller's
1376
+ // `ts.createSourceFile` path, or refusal) rather than mis-baking.
1377
+ if (prop.kind === 'spread') return null
1372
1378
  if (prop.shorthand) return null
1373
1379
  const key = prop.key
1374
1380
  if (!GO_IDENTIFIER.test(key)) return null
@@ -1873,6 +1879,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1873
1879
  lines.push('')
1874
1880
  }
1875
1881
 
1882
+ this.emitCallerPropsInit(lines, ir, nestedComponents, staticWithoutBody, staticWithBody, dynamicWithBody, emittedWrapperVars, propTypeOverrides)
1883
+
1876
1884
  lines.push(`\treturn ${propsTypeName}{`)
1877
1885
  lines.push('\t\tScopeID: scopeID,')
1878
1886
  // Host context, for when *this* component is itself a slot-attached child.
@@ -1881,6 +1889,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1881
1889
  if (this.usesSearchParams(ir)) {
1882
1890
  lines.push('\t\tSearchParams: in.SearchParams,')
1883
1891
  }
1892
+ lines.push('\t\tBfCallerProps: bfCallerProps,')
1884
1893
 
1885
1894
  const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
1886
1895
 
@@ -1918,7 +1927,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1918
1927
  if (this.isNestedArrayShadowed(param, nestedArrayFields)) continue
1919
1928
  const hoisted = propFallbackVars.get(param.name)
1920
1929
  if (hoisted) {
1921
- lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
1930
+ // #2683: a `collisionWrap` entry means this field is ALSO a
1931
+ // colliding signal's shared field — `hoisted.varName` alone only
1932
+ // carries the coalesced `props.X ?? <lit>` value, so the surrounding
1933
+ // arithmetic (`* 2`, etc.) is composed back on here, giving the
1934
+ // FULLY DERIVED value the signal's own initializer computes. Without
1935
+ // this the field would silently carry just the coalesce result (the
1936
+ // same class of bug this fix addresses, one operator short).
1937
+ const value = hoisted.collisionWrap
1938
+ ? `${hoisted.varName} ${hoisted.collisionWrap.operator} ${hoisted.collisionWrap.operand}`
1939
+ : hoisted.varName
1940
+ lines.push(`\t\t${fieldName}: ${value},`)
1922
1941
  } else {
1923
1942
  const paramDefault = goPropDefault(param.defaultValue)
1924
1943
  const memoFold = memoFallbacks.get(fieldName)
@@ -2038,6 +2057,139 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2038
2057
  lines.push('}')
2039
2058
  }
2040
2059
 
2060
+ /**
2061
+ * Build the `bfCallerProps` local (assigned to `BfCallerProps` in the
2062
+ * returned struct) — the hydration-only, caller-supplied-keys-only view
2063
+ * of this component's props (#2684). See the field's doc comment
2064
+ * (`emitPropsStructHeader`) for the two-consumers rationale.
2065
+ *
2066
+ * Three classes, mirroring the issue's taxonomy — gated on `param.optional`
2067
+ * (NOT on the resolved Go type's nillability alone, even though that reads
2068
+ * as the more "obvious" test): a REQUIRED prop must stay unconditional
2069
+ * even when its resolved type happens to be nillable (`ctx: unknown`, the
2070
+ * `array-flatmap-thisarg` fixture passes `ctx: null` explicitly) — Go's
2071
+ * `interface{}` cannot tell "explicit null" from "omitted" once
2072
+ * unmarshaled (both are the nil zero value), so nil-checking a required
2073
+ * field would silently turn an intentional `null` into a dropped key.
2074
+ * 1. Required prop → always included, raw `in.<InputField>` (never the
2075
+ * defaulted Props-field value — a caller who passes the same value
2076
+ * as the author's default is indistinguishable from one who didn't,
2077
+ * but that's inherent to a required prop having no "omitted" state).
2078
+ * 2. Optional prop whose resolved Go type is nillable (`interface{}`,
2079
+ * `map[string]interface{}`, or any slice `[]T`) → included only
2080
+ * when `in.<InputField> != nil`, i.e. only when the caller actually
2081
+ * passed something.
2082
+ * 3. Optional prop that nonetheless resolved to a CONCRETE type (a
2083
+ * string-union alias like `placement?: 'top' | 'bottom'`, a bare
2084
+ * scalar never consumed nullish/attr/text/presence-wise, OR a type
2085
+ * that resolves to `interface{}` some OTHER way — e.g. an inherited
2086
+ * `extends` clause the analyzer can't fully resolve — while
2087
+ * `param.optional` itself is, possibly inaccurately, `false`) —
2088
+ * presence is unknowable from Input alone, so it's included
2089
+ * unconditionally, same as class 1. A documented residual, not
2090
+ * silently dropped; see the PR that introduced this method for named
2091
+ * instances found in the corpus (e.g. `textarea`'s `rows`, inherited
2092
+ * through `TextareaHTMLAttributes`). Do NOT widen these props' types
2093
+ * to "fix" this — that's `resolvePropGoType`'s call, not this
2094
+ * method's, and doing so would risk the same `ctx`-style regression.
2095
+ *
2096
+ * `children` is excluded outright (#1952 — a separately-declared
2097
+ * position, not relitigated here). Rest-props bags are never added to
2098
+ * the Props struct's JSON at all (checked: no field exists for them
2099
+ * outside Input), so nothing to exclude here either — already at parity
2100
+ * with the reference, which never serializes rest keys.
2101
+ *
2102
+ * A prop shadowed by its own driving nested-array field (`isNestedArrayShadowed`
2103
+ * — e.g. `toggleItems` colliding with a `<ToggleItem>` loop's derived
2104
+ * `ToggleItems` field, #2672/#2525) has NO Props/Input field of its own;
2105
+ * the reshaped nested-array local (`varName`, already built above by the
2106
+ * static/dynamic body-wrapper emission) is its only remaining carrier, so
2107
+ * that's what gets keyed in here instead of `in.<InputField>`. KNOWN
2108
+ * RESIDUAL: each item within that array is still the OLD whole-struct
2109
+ * marshal (baked author defaults / null-for-absent / zero-for-required
2110
+ * inside every item) — recursing the sidecar into embedded Props-struct
2111
+ * arrays needs either a generated `MarshalJSON` per Props type or a
2112
+ * runtime array-flattening helper, a bigger surface this method's design
2113
+ * doesn't cover. Reported, not chased (see PR description).
2114
+ */
2115
+ private emitCallerPropsInit(
2116
+ lines: string[],
2117
+ ir: ComponentIR,
2118
+ nestedComponents: NestedComponentInfo[],
2119
+ staticWithoutBody: NestedComponentInfo[],
2120
+ staticWithBody: NestedComponentInfo[],
2121
+ dynamicWithBody: NestedComponentInfo[],
2122
+ emittedWrapperVars: Set<string>,
2123
+ propTypeOverrides: Map<string, string>,
2124
+ ): void {
2125
+ lines.push('\tbfCallerProps := map[string]interface{}{}')
2126
+ const bfCallerPropsTakenTags = new Set<string>()
2127
+ const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
2128
+
2129
+ for (const param of ir.metadata.propsParams) {
2130
+ // Children are DOM content, not hydration data (#1952) — excluded the
2131
+ // same way `emitPropsDataFields` excludes them from the Props struct.
2132
+ if (param.name === 'children') continue
2133
+ // Shadowed by its own driving nested-array field (#2672/#2525) — no
2134
+ // Input field exists for it at all; handled below via the array local.
2135
+ if (this.isNestedArrayShadowed(param, nestedArrayFields)) continue
2136
+ // Same tag algorithm `emitPropsDataFields` uses for the Props field's
2137
+ // own `json:"…"` tag, re-derived against a FRESH set: props are always
2138
+ // processed first, in the same order, over an empty set in BOTH
2139
+ // places, so the two computations can't disagree.
2140
+ const callerKey = this.claimJsonTag(this.toJsonTag(param.sourceName ?? param.name), bfCallerPropsTakenTags)
2141
+ if (callerKey === '-') continue // tag collision — dropped from the wire, same as the Props field itself would be
2142
+ const inputField = `in.${capitalizeFieldName(param.sourceName ?? param.name)}`
2143
+ const goType = resolvePropGoType(this.emitCtx, param, propTypeOverrides)
2144
+ const isNillable = goType === 'interface{}' || goType === 'map[string]interface{}' || goType.startsWith('[]')
2145
+ // `param.optional` gates the branch (not "isNillable alone") because a
2146
+ // REQUIRED prop must stay unconditional even when its resolved type
2147
+ // happens to be nillable (`ctx: unknown`, #2094's array-flatmap-thisarg
2148
+ // fixture passes `ctx: null` explicitly) — Go's `interface{}` cannot
2149
+ // tell "explicit null" from "omitted" once unmarshaled (both are the
2150
+ // nil zero value), so a nil-check here would silently turn an
2151
+ // intentional `null` into a dropped key, regressing a fixture that
2152
+ // matched the reference before this method existed. `resolvePropGoType`
2153
+ // never applies its nillable flip to a required prop, so this ONLY
2154
+ // affects the rare case where a type resolves to `interface{}` some
2155
+ // OTHER way (e.g. `unknown`, or a type inherited through an
2156
+ // unresolved external `extends` clause) while `param.optional` is
2157
+ // (accurately or not) false — see the method docstring's residual list.
2158
+ if (param.optional && isNillable) {
2159
+ lines.push(`\tif ${inputField} != nil {`)
2160
+ lines.push(`\t\tbfCallerProps["${callerKey}"] = ${inputField}`)
2161
+ lines.push(`\t}`)
2162
+ } else {
2163
+ // Required (class 1), or optional-but-concrete residual (class 3),
2164
+ // or optional-but-misresolved-required residual — see docstring.
2165
+ lines.push(`\tbfCallerProps["${callerKey}"] = ${inputField}`)
2166
+ }
2167
+ }
2168
+
2169
+ // Shadowed nested-array props — see method docstring.
2170
+ for (const nested of [...staticWithoutBody, ...staticWithBody, ...dynamicWithBody]) {
2171
+ if (!nested.isPropDerived) continue
2172
+ const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2173
+ const isBuilt = staticWithoutBody.includes(nested) || emittedWrapperVars.has(varName)
2174
+ if (!isBuilt) continue
2175
+ const arrayFieldName = capitalizeFieldName(`${nested.name}s`)
2176
+ const param = ir.metadata.propsParams.find(
2177
+ p => capitalizeFieldName(p.name) === arrayFieldName || capitalizeFieldName(p.sourceName ?? p.name) === arrayFieldName,
2178
+ )
2179
+ if (!param || !this.isNestedArrayShadowed(param, nestedArrayFields)) continue
2180
+ const callerKey = this.claimJsonTag(this.toJsonTag(param.sourceName ?? param.name), bfCallerPropsTakenTags)
2181
+ if (callerKey === '-') continue
2182
+ if (param.optional) {
2183
+ lines.push(`\tif in.${nested.name}s != nil {`)
2184
+ lines.push(`\t\tbfCallerProps["${callerKey}"] = ${varName}`)
2185
+ lines.push(`\t}`)
2186
+ } else {
2187
+ lines.push(`\tbfCallerProps["${callerKey}"] = ${varName}`)
2188
+ }
2189
+ }
2190
+ lines.push('')
2191
+ }
2192
+
2041
2193
  private emitStaticChildInstances(lines: string[], ir: ComponentIR): void {
2042
2194
  const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
2043
2195
  for (const child of staticChildren) {
@@ -2741,6 +2893,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2741
2893
  if (this.usesSearchParams(ir)) {
2742
2894
  lines.push('\tSearchParams bf.SearchParams `json:"-"`')
2743
2895
  }
2896
+
2897
+ // #2684: the raw, CALLER-SUPPLIED view of this component's props —
2898
+ // populated by `NewXxxProps` with exactly the keys the caller passed
2899
+ // (required props always; optional ones only when not nil), holding
2900
+ // the UNDEFAULTED value. This exists because every prop field above
2901
+ // (e.g. `X`) serves TWO different consumers that want two different
2902
+ // values: the Go TEMPLATE wants the DEFAULTED value so `{{.X}}` renders
2903
+ // the author's fallback, while the HYDRATION PAYLOAD wants to know only
2904
+ // what the caller actually supplied — baking the author's default into
2905
+ // it makes an omitted prop indistinguishable from one explicitly passed
2906
+ // with that value, and a Go zero value indistinguishable from an
2907
+ // explicit zero/empty/false (the reference, Hono, only ever serializes
2908
+ // caller-passed keys). One field can't answer both questions at once,
2909
+ // so this sidecar gives the hydration payload its own carrier;
2910
+ // `BfPropsAttr` (runtime/bf.go) marshals THIS map instead of the whole
2911
+ // struct when it's present. `json:"-"` — never marshaled as an ordinary
2912
+ // struct field; `BfPropsAttr` reads it directly by name via reflection.
2913
+ lines.push('\tBfCallerProps map[string]interface{} `json:"-"`')
2744
2914
  }
2745
2915
 
2746
2916
  private emitPropsDataFields(
@@ -3236,6 +3406,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3236
3406
  if (parsed.kind !== 'object-literal') return null
3237
3407
  const entries: string[] = []
3238
3408
  for (const prop of parsed.properties) {
3409
+ // A spread (`{ ...t }`, #2696 Step 2) isn't a per-key member this Go
3410
+ // struct-literal lowering can express — fail the whole value, same as
3411
+ // any other unsupported member (the consumer keeps its default).
3412
+ if (prop.kind === 'spread') return null
3239
3413
  if (prop.shorthand) return null
3240
3414
  const goVal = this.lowerProviderMapMemberValue(prop.value, propsParams)
3241
3415
  if (goVal === null) return null
@@ -3588,7 +3762,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3588
3762
 
3589
3763
  const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
3590
3764
  for (const signal of ir.metadata.signals) {
3591
- const match = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
3765
+ let match = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
3766
+ // #2683: the idempotent fold above declines a non-idempotent
3767
+ // derivation (`(props.count ?? 1) * 2`) at the top level. When that
3768
+ // signal's Go field name COLLIDES with the prop's own field — the
3769
+ // exact condition under which the props-field loop below would
3770
+ // otherwise silently drop the derivation and emit the raw prop
3771
+ // instead — fall back to the collision-derivation matcher so the
3772
+ // coalesced value still gets hoisted; the props-field loop composes
3773
+ // the surrounding arithmetic back on top of it (`collisionWrap`).
3774
+ // Gated strictly on the collision so a differently-named signal
3775
+ // deriving from the same prop (not colliding) is untouched — that
3776
+ // path's own dedicated field keeps today's behavior byte-for-byte.
3777
+ let collisionWrap: { operator: string; operand: string } | undefined
3778
+ if (!match) {
3779
+ const collision = this.extractCollisionDerivation(this.resolvedSignalParsed(signal))
3780
+ if (collision) {
3781
+ const collisionParam = ir.metadata.propsParams.find(p => p.name === collision.propName)
3782
+ const collisionField = collisionParam
3783
+ ? capitalizeFieldName(collisionParam.sourceName ?? collision.propName)
3784
+ : null
3785
+ if (collisionField && capitalizeFieldName(signal.getter) === collisionField) {
3786
+ match = { propName: collision.propName, goFallback: collision.goFallback }
3787
+ collisionWrap = { operator: collision.operator, operand: collision.operand }
3788
+ }
3789
+ }
3790
+ }
3592
3791
  if (!match) continue
3593
3792
  if (result.has(match.propName)) continue
3594
3793
  const param = ir.metadata.propsParams.find(p => p.name === match.propName)
@@ -3648,6 +3847,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3648
3847
  goFallback: match.goFallback,
3649
3848
  zeroLiteral,
3650
3849
  ...(nullishLowered ? { assertType: concreteType } : {}),
3850
+ ...(collisionWrap ? { collisionWrap } : {}),
3651
3851
  })
3652
3852
  }
3653
3853
  return result
@@ -3742,6 +3942,67 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3742
3942
  return { propName, goFallback }
3743
3943
  }
3744
3944
 
3945
+ /**
3946
+ * #2683: match a signal's collision-derivation shape — `(props.X ?? <lit>)
3947
+ * <op> <int>` — the ONE non-idempotent shape this adapter faithfully lowers
3948
+ * when a signal's Go field name collides with its own prop's field (see the
3949
+ * collision-override loop in `collectPropFallbackVars` / the props-field
3950
+ * loop in `generateNewPropsFunction`). Composes two ALREADY-EXISTING
3951
+ * lowerings instead of adding a third: `extractPropFallbackFromParsed`
3952
+ * recognizes the embedded `props.X ?? <lit>` presence check exactly as it
3953
+ * does for the idempotent fold, and the wrap mirrors the SAME `<ref> <op>
3954
+ * <int>` arithmetic shape `memoInitialFromParsedBody`'s multiply-family
3955
+ * branch already supports for a bare `props.X <op> N` (non-negative
3956
+ * integer operand only, matching that branch's own restriction).
3957
+ *
3958
+ * Returns null for any other shape — the caller then keeps today's
3959
+ * raw-passthrough behavior (sound-or-loud; the remaining #2683 pin, if
3960
+ * any, covers whatever this doesn't reach).
3961
+ */
3962
+ private extractCollisionDerivation(
3963
+ parsed: ParsedExpr | undefined,
3964
+ ): { propName: string; goFallback: string; operator: string; operand: string } | null {
3965
+ if (!parsed || parsed.kind !== 'binary') return null
3966
+ if (!['*', '+', '-', '/'].includes(parsed.op)) return null
3967
+ const { right } = parsed
3968
+ if (
3969
+ right.kind !== 'literal' ||
3970
+ right.literalType !== 'number' ||
3971
+ typeof right.value !== 'number' ||
3972
+ !Number.isInteger(right.value) ||
3973
+ right.value < 0
3974
+ ) {
3975
+ return null
3976
+ }
3977
+ // Constant division by zero is a Go COMPILE error (`invalid operation:
3978
+ // division by zero`), unlike JS's Infinity — declining keeps that shape
3979
+ // on the raw-passthrough path instead of breaking the build.
3980
+ if (parsed.op === '/' && right.value === 0) return null
3981
+ // The `??` fallback itself must be a NUMERIC literal before delegating
3982
+ // to the fold: `extractPropFallbackFromParsed` happily matches a string
3983
+ // fallback (`(props.label ?? 'x') + 2`), which would hoist
3984
+ // `var label string = "x"` and emit `Label: label + 2,` — invalid Go
3985
+ // (string + int), a compile break where the pre-existing behavior at
3986
+ // least built (Copilot review on #2694). JS's `'x' + 2` is string
3987
+ // concatenation besides, so a numeric compose could never be faithful
3988
+ // for it — the shape belongs on the raw-passthrough path until someone
3989
+ // lowers it through ConcatStr deliberately. Gating on `??` (not `||`)
3990
+ // also keeps JS nullish semantics exact for numeric props, where a
3991
+ // caller's explicit `0` must NOT trigger the fallback.
3992
+ const coalesce = parsed.left
3993
+ if (
3994
+ coalesce.kind !== 'logical' ||
3995
+ coalesce.op !== '??' ||
3996
+ coalesce.right.kind !== 'literal' ||
3997
+ coalesce.right.literalType !== 'number'
3998
+ ) {
3999
+ return null
4000
+ }
4001
+ const inner = this.extractPropFallbackFromParsed(parsed.left)
4002
+ if (!inner) return null
4003
+ return { ...inner, operator: parsed.op, operand: String(right.value) }
4004
+ }
4005
+
3745
4006
  /**
3746
4007
  * Extract the prop name from a signal's `props.xxx`-pattern initialValue,
3747
4008
  * e.g. `"props.initial ?? 0"` → `"initial"`, `"props.checked"` → `"checked"`.
@@ -4739,12 +5000,33 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4739
5000
  return `bf_arr ${parts.join(' ')}`
4740
5001
  }
4741
5002
 
4742
- objectLiteral(_properties: ObjectLiteralProperty[], raw: string, _emit: (e: ParsedExpr) => string): string {
4743
- // The shared `isSupported` gate now admits an EMPTY object literal
4744
- // (`?? {}`) as `??`'s right operand (expression-parser.ts, `logical`
4745
- // case) every other adapter has a native `{}` dict/hashref literal to
4746
- // emit here, but `text/template` has none, so this dispatcher is the
4747
- // one place left to refuse the shape. Unlike the sibling adapters, Go
5003
+ objectLiteral(properties: ObjectLiteralProperty[], raw: string, emit: (e: ParsedExpr) => string): string {
5004
+ // A POPULATED literal is reachable here only in VALUE position (a
5005
+ // `.map()` receiver/callback body, an array-literal element, …) —
5006
+ // `isSupportedValue` admits it when every property value is itself
5007
+ // supported (expression-parser.ts, `checkSupport`'s `pos` parameter).
5008
+ // `text/template` has no map-literal syntax, so lower through the
5009
+ // variadic `bf_map` runtime helper (the object counterpart of
5010
+ // `arrayLiteral`'s `bf_arr`) instead of trying to spell a literal.
5011
+ if (properties.length > 0) {
5012
+ const wrap = (rendered: string) => (rendered.includes(' ') ? `(${rendered})` : rendered)
5013
+ const literalOf = (run: readonly Extract<ObjectLiteralProperty, { kind: 'prop' }>[]) =>
5014
+ `bf_map ${run.map(p => `"${escapeGoString(p.key)}" ${wrap(emit(p.value))}`).join(' ')}`
5015
+ if (!properties.some(p => p.kind === 'spread')) {
5016
+ return literalOf(properties as Extract<ObjectLiteralProperty, { kind: 'prop' }>[])
5017
+ }
5018
+ // Spread (`{ ...t, editing: false }`, #2696 Step 2): `bf_merge`
5019
+ // (runtime/eval.go, a `bf_map` sibling) is a variadic, null-safe map
5020
+ // merge — a non-map argument (nil included) is skipped rather than
5021
+ // panicking, matching JS's null/undefined-spread no-op — folding
5022
+ // every segment (a `bf_map` run, or a spread's own emitted value) in
5023
+ // ONE call, later segments winning, exactly JS spread's semantics.
5024
+ const segments = groupObjectLiteralSegments(properties, literalOf, e => wrap(emit(e)))
5025
+ return `bf_merge ${segments.join(' ')}`
5026
+ }
5027
+ // The EMPTY object literal (`?? {}`) reaches here as `??`'s right
5028
+ // operand (expression-parser.ts, `logical` case) — a RENDERED-position
5029
+ // admission, not a value-position one. Unlike the sibling adapters, Go
4748
5030
  // can't silently fall back to a safe sentinel text: `this.unsupported`'s
4749
5031
  // `[UNSUPPORTED: …]` marker would be spliced into a Go template action
4750
5032
  // (e.g. as an `or`/`and` operand) and break template parsing, and the
@@ -6054,7 +6336,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6054
6336
  // (icon registries, variant/size class maps), so this fully covers them.
6055
6337
  const carried = constInfo.parsed
6056
6338
  if (carried?.kind === 'object-literal') {
6057
- const hit = carried.properties.find(prop => prop.key === key)
6339
+ // A spread entry (`{ ...t }`, #2696 Step 2) has no static key to match
6340
+ // against — skip it, same as before this kind existed (a record const
6341
+ // containing spread already couldn't reach here as a whole).
6342
+ const hit = carried.properties.find(
6343
+ (prop): prop is Extract<ObjectLiteralProperty, { kind: 'prop' }> => prop.kind === 'prop' && prop.key === key,
6344
+ )
6058
6345
  if (hit && hit.value.kind === 'literal') {
6059
6346
  if (hit.value.literalType === 'string') return JSON.stringify(hit.value.value)
6060
6347
  if (hit.value.literalType === 'number') return hit.value.raw ?? String(hit.value.value)
@@ -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
  /**
@@ -592,6 +592,17 @@ export function memoInitialFromParsedBody(
592
592
  if (concatGo !== null) return concatGo
593
593
  }
594
594
 
595
+ // () => <object>.map(cb).join(sep) as the memo's WHOLE body (no `+`
596
+ // concatenation) — `map-object-literal-body`'s `ids` memo: `rows().map(t
597
+ // => ({ id: t.id, done: false })).map(r => r.id).join(',')`. The `+`-chain
598
+ // arm above only reaches `matchMapJoinChain` as ONE leaf of a
599
+ // concatenation; a bare chain memo never entered it (#2696 review).
600
+ const bareChain = matchMapJoinChain(body)
601
+ if (bareChain) {
602
+ const chainGo = mapJoinChainToGo(ctx, bareChain, signals, propsParams, propFallbackVars)
603
+ if (chainGo !== null) return chainGo
604
+ }
605
+
595
606
  return null
596
607
  }
597
608
 
@@ -954,7 +965,7 @@ export function collectPropsReadByCtorInit(
954
965
  e.elements.forEach(el => visit(el, bound))
955
966
  return
956
967
  case 'object-literal':
957
- for (const p of e.properties) visit(p.value, bound)
968
+ for (const p of e.properties) visit(p.kind === 'spread' ? p.expr : p.value, bound)
958
969
  return
959
970
  case 'array-method':
960
971
  visit(e.object, bound)
@@ -181,6 +181,9 @@ export function computeObjectMemoInitialValue(
181
181
  const env: CtorLowerEnv = { searchParamsVars: new Set(), params: new Map() }
182
182
  const entries: string[] = []
183
183
  for (const prop of retObj.properties) {
184
+ // A spread (`{ ...t }`, #2696 Step 2) has no per-key `.Params.<Field>`
185
+ // accessor to match against — bail, same as any other unsupported shape.
186
+ if (prop.kind === 'spread') return null
184
187
  // Bail on a shorthand property (`return { tag }`): its value is a bare
185
188
  // identifier whose name need not match a `.Params.<Field>` accessor.
186
189
  if (prop.shorthand) return null
@@ -200,6 +200,11 @@ function recordIndexInterpolationToGo(
200
200
 
201
201
  const entries: { key: string; value: { kind: 'number' | 'string'; text: string } }[] = []
202
202
  for (const prop of parsedConst.properties) {
203
+ // A spread (`{ ...t }`, #2696 Step 2) has no static key/value pair this
204
+ // record-index lowering can bake — bail, same as any other unsupported
205
+ // member (a record const containing spread already couldn't reach here
206
+ // as a whole before this kind existed).
207
+ if (prop.kind === 'spread') return null
203
208
  const v = prop.value
204
209
  if (v.kind === 'literal' && v.literalType === 'number') {
205
210
  entries.push({ key: prop.key, value: { kind: 'number', text: v.raw ?? String(v.value) } })
@@ -10,6 +10,7 @@ import { isBooleanAttr } from '@barefootjs/jsx'
10
10
 
11
11
  import type { GoEmitContext } from '../emit-context.ts'
12
12
  import { resolveSignalParsedThroughSeedPlan } from '../lib/compile-state.ts'
13
+ import { capitalizeFieldName } from '../lib/go-naming.ts'
13
14
  import { typeInfoToGo } from '../type/type-codegen.ts'
14
15
 
15
16
  /**
@@ -224,7 +225,31 @@ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: Componen
224
225
  // between `props.X` and `createSignal` doesn't leave this loop and
225
226
  // `collectPropFallbackVars`'s identical extraction disagreeing on
226
227
  // whether the prop needs the nillable flip.
227
- const match = ctx.extractPropFallback(signal.initialValue, resolveSignalParsedThroughSeedPlan(ctx.state, signal))
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
+ }
228
253
  if (!match || !optionalParams.has(match.propName)) continue
229
254
  const f = match.goFallback
230
255
  if (f === '""' || f === 'false' || f === 'nil' || Number(f) === 0) continue
@@ -143,6 +143,10 @@ function parsedObjectLiteralToGoMap(parsed: ParsedExpr | undefined): string | nu
143
143
  if (!parsed || parsed.kind !== 'object-literal') return null
144
144
  const entries: string[] = []
145
145
  for (const prop of parsed.properties) {
146
+ // A spread (`{ ...t }`, #2696 Step 2) has no static key/value pair this
147
+ // conservative Go-map bake can express — bail, same as any other
148
+ // unsupported shape.
149
+ if (prop.kind === 'spread') return null
146
150
  // Reject a numeric key (`{ 1: 'a' }`); `keyKind` distinguishes it from a
147
151
  // string `'1'` key.
148
152
  if (prop.keyKind === 'numeric') return null
@@ -386,6 +390,10 @@ function objectLiteralToGoSpreadMap(
386
390
  ): string | null {
387
391
  const entries: string[] = []
388
392
  for (const prop of obj.properties) {
393
+ // A spread (`{ ...t }`, #2696 Step 2) isn't a per-key entry this
394
+ // conditional-spread inline lowering can express — bail (the docstring
395
+ // already declares spread out-of-scope for this function).
396
+ if (prop.kind === 'spread') return null
389
397
  // Shorthand (`{ describedBy }`) is unsupported.
390
398
  if (prop.shorthand) return null
391
399
  // Reject a numeric key (`{ 1: x }`); `keyKind` distinguishes it from a
@@ -63,6 +63,9 @@ function bakeInlineObjectAsGoMap(ctx: GoEmitContext, expr: ParsedExpr): string |
63
63
  if (expr.kind !== 'object-literal') return null
64
64
  const entries: string[] = []
65
65
  for (const prop of expr.properties) {
66
+ // A spread (`{ ...t }`, #2696 Step 2) has no static key to bake as a Go
67
+ // map entry — bail, same as any other unsupported shape.
68
+ if (prop.kind === 'spread') return null
66
69
  if (prop.shorthand) return null
67
70
  const go =
68
71
  prop.value.kind === 'object-literal'
@@ -150,6 +153,10 @@ export function parsedLiteralToGo(
150
153
  if (!structFields) return null
151
154
  const entries: string[] = []
152
155
  for (const prop of expr.properties) {
156
+ // A spread (`{ ...t }`, #2696 Step 2) has no static key to match a
157
+ // struct field against — defer the whole object, same as any other
158
+ // unsupported shape.
159
+ if (prop.kind === 'spread') return null
153
160
  // A shorthand `{ a }` carries an identifier value → lowers to null below
154
161
  // and defers the whole object.
155
162
  const goField = structFields.get(prop.key)