@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.
@@ -944,7 +944,8 @@ export function List() {
944
944
  // A struct is synthesised with one field per inferred key + Go type.
945
945
  expect(types).toMatch(/type \w+ struct \{[\s\S]*ID string[\s\S]*N int[\s\S]*Ok bool[\s\S]*\}/)
946
946
  // The signal field is a slice of the synthesised struct, not []interface{}.
947
- expect(types).toMatch(/Items \[\]\w+ `json:"items"`/)
947
+ // Excluded from bf-p (json:"-") — component-internal signal state (#2672).
948
+ expect(types).toMatch(/Items \[\]\w+ `json:"-"`/)
948
949
  expect(types).not.toContain('Items []interface{}')
949
950
  // The initial items are baked, not nil.
950
951
  expect(types).not.toContain('Items: nil,')
@@ -1120,7 +1121,11 @@ export function Rows() {
1120
1121
  expect(types).toContain('DataX string `json:"data-x"`')
1121
1122
  expect(types).not.toContain('"Data-x"')
1122
1123
  expect(types).toContain('Row{ID: "r1", Meta: RowMeta{DataX: "v"}}')
1123
- expect(types).not.toContain('map[string]interface{}')
1124
+ // `Meta` resolved to the real synthesized struct, not the
1125
+ // `map[string]interface{}` fallback — scoped to the field itself
1126
+ // since `BfCallerProps map[string]interface{}` (#2684) legitimately
1127
+ // appears elsewhere in every generated Props struct now.
1128
+ expect(types).not.toMatch(/Meta map\[string\]interface\{\}/)
1124
1129
  })
1125
1130
 
1126
1131
  test('snake_case keys keep their underscore in the generated field name (#2089 review)', () => {
@@ -1698,6 +1703,56 @@ export function Box({ children }: { children: any }) {
1698
1703
  const types = adapter.generateTypes(ir)!
1699
1704
  expect(types).toMatch(/Children\s+\S+\s+`json:"-"`/)
1700
1705
  })
1706
+
1707
+ test('signal and memo fields are excluded from bf-p serialization — user props only (#2672)', () => {
1708
+ // A signal-bearing component whose only DECLARED prop is `label`, plus
1709
+ // a signal (`count`, not itself a prop) and a memo derived from it
1710
+ // (`doubled`). Only `label` may carry a real json tag — `count` and
1711
+ // `doubled` are component-internal state/derivation the client
1712
+ // re-derives itself and never reads back off `_p`.
1713
+ const adapter = new GoTemplateAdapter()
1714
+ const ir = compileToIR(`
1715
+ 'use client'
1716
+ import { createSignal, createMemo } from '@barefootjs/client'
1717
+ export function Counter(props: { label: string }) {
1718
+ const [count, setCount] = createSignal(0)
1719
+ const doubled = createMemo(() => count() * 2)
1720
+ return <button onClick={() => setCount(count() + 1)}>{props.label}: {count()} / {doubled()}</button>
1721
+ }
1722
+ `, adapter)
1723
+ const types = adapter.generateTypes(ir)!
1724
+ // The real prop keeps its real tag — hydration reads `_p.label`.
1725
+ expect(types).toMatch(/Label\s+\S+\s+`json:"label"`/)
1726
+ // The signal field is component-internal state, not caller input.
1727
+ expect(types).toMatch(/Count\s+\S+\s+`json:"-"`/)
1728
+ // The memo field is component-internal derivation, not caller input.
1729
+ expect(types).toMatch(/Doubled\s+\S+\s+`json:"-"`/)
1730
+ })
1731
+
1732
+ test('prop-backed signal default still keeps the PROP field a real tag (#2672)', () => {
1733
+ // Mirrors the `signal-default-from-jsx` adapter-tests fixture: `x` is a
1734
+ // declared PROP whose default happens to come from a signal initial
1735
+ // value (keeps a real tag, seeds client-side `createSignal(_p.x ?? 7)`),
1736
+ // while `incremented` is a pure memo derivation the client never reads
1737
+ // off `_p`. Flipping signal/memo fields to `json:"-"` must NOT touch
1738
+ // this prop field — it is the loop-guard case CLAUDE.md's "don't flip
1739
+ // prop-backed fields" warns against.
1740
+ const adapter = new GoTemplateAdapter()
1741
+ const ir = compileToIR(`
1742
+ 'use client'
1743
+ import { createSignal, createMemo } from '@barefootjs/client'
1744
+ export function SignalDefaultFromJsx(props: { x?: number }) {
1745
+ const [x, setX] = createSignal(props.x ?? 7)
1746
+ const incremented = createMemo(() => x() + 1)
1747
+ return <div onClick={() => setX(x() + 1)}>{x()} / {incremented()}</div>
1748
+ }
1749
+ `, adapter)
1750
+ const types = adapter.generateTypes(ir)!
1751
+ // The prop-backed field keeps its real tag — hydration reads `_p.x`.
1752
+ expect(types).toMatch(/X\s+\S+\s+`json:"x"`/)
1753
+ // The memo field is component-internal derivation, not caller input.
1754
+ expect(types).toMatch(/Incremented\s+\S+\s+`json:"-"`/)
1755
+ })
1701
1756
  })
1702
1757
 
1703
1758
  describe('generateTypes', () => {
@@ -4860,6 +4915,39 @@ export function Counter({ count: initialCount }: { count: number }) {
4860
4915
  expect((types.match(/json:"count"/g) ?? []).length).toBe(1)
4861
4916
  })
4862
4917
 
4918
+ // #2672 near-miss: a prop-derived dynamic loop's nested-array field is
4919
+ // normally redundant (the client re-derives every row from the real prop
4920
+ // field, e.g. `_p.items`) and gets `json:"-"`. But when the array field's
4921
+ // Go name collides with its OWN driving prop's Go name — `toggleItems`
4922
+ // driving a `.map()` into `<ToggleItem>` both capitalize to `ToggleItems`
4923
+ // — `emitPropsDataFields` already shadows the prop's own field to avoid a
4924
+ // Go redeclaration, so the nested-array field is the ONLY struct field
4925
+ // carrying that prop's data. Flipping it to `-` there would silently drop
4926
+ // caller input from `bf-p` instead of trimming a redundant copy. Caught by
4927
+ // the `hydration-props-inventory` oracle against the `toggle-shared`
4928
+ // fixture (a real `integrations/shared/components/Toggle.tsx` shape)
4929
+ // before landing; this pins the same shape directly.
4930
+ test('a prop-derived nested-array field keeps a real tag when it shadows its own driving prop (#2672)', () => {
4931
+ const result = compileJSX(`
4932
+ type ToggleItemProps = { label: string; defaultOn?: boolean }
4933
+ function ToggleItem(props: ToggleItemProps) {
4934
+ return <div>{props.label}</div>
4935
+ }
4936
+ type ToggleProps = { toggleItems: ToggleItemProps[] }
4937
+ export function Toggle({ toggleItems }: ToggleProps) {
4938
+ return <div>{toggleItems.map((item) => <ToggleItem key={item.label} label={item.label} defaultOn={item.defaultOn} />)}</div>
4939
+ }
4940
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4941
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4942
+ const types = result.files.find(f => f.type === 'types')!.content
4943
+ const togglePropsAndAfter = types.slice(types.indexOf('type ToggleProps struct'))
4944
+ // The shadowed scalar prop field is absent...
4945
+ expect(togglePropsAndAfter.slice(0, togglePropsAndAfter.indexOf('}'))).not.toMatch(/\bItems\b/)
4946
+ // ...and the nested-array field is the sole carrier, with a REAL tag —
4947
+ // not `json:"-"`, which would drop `toggleItems` from `bf-p` entirely.
4948
+ expect((togglePropsAndAfter.match(/json:"toggleItems"/g) ?? []).length).toBe(1)
4949
+ })
4950
+
4863
4951
  // The nested-array skip check must union both namings at all four sites
4864
4952
  // (`isNestedArrayShadowed`) — a one-sided check lets Input and
4865
4953
  // Props/NewProps disagree on whether an aliased prop's field exists.
@@ -4927,7 +5015,10 @@ export function Foo({ q: searchParams }: { q: string }) {
4927
5015
  expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4928
5016
  const types = result.files.find(f => f.type === 'types')!.content
4929
5017
  const structStart = types.indexOf('type FooProps struct')
4930
- const fooPropsBody = types.slice(structStart, types.indexOf('}', structStart))
5018
+ // The struct's actual closing brace is the first `}` that starts its
5019
+ // OWN line (fields are tab-indented) — a bare `indexOf('}', ...)` would
5020
+ // stop early at the embedded `}` inside `BfCallerProps map[string]interface{}` (#2684).
5021
+ const fooPropsBody = types.slice(structStart, types.indexOf('\n}', structStart))
4931
5022
  // Exactly one "SearchParams" field in the struct body — the prop's
4932
5023
  // own — never a second `SearchParams bf.SearchParams` reader field
4933
5024
  // colliding with it (a Go redeclaration error).
@@ -4937,6 +5028,188 @@ export function Foo({ q: searchParams }: { q: string }) {
4937
5028
  })
4938
5029
  })
4939
5030
 
5031
+ // #2684: `bf-p` must carry only what the caller actually passed — required
5032
+ // props always, optional ones only when supplied — never the author's
5033
+ // baked default (`x ?? 7`), never `null` for an omitted optional, never a
5034
+ // Go zero value standing in for "unset". `NewXxxProps` now ALSO populates a
5035
+ // `BfCallerProps map[string]interface{}` sidecar (marshaled by `BfPropsAttr`
5036
+ // INSTEAD OF the whole struct) with exactly the caller-supplied, raw
5037
+ // (undefaulted) values — see the field's doc comment in
5038
+ // `emitPropsStructHeader` for the two-consumers rationale.
5039
+ describe('GoTemplateAdapter - BfCallerProps hydration sidecar (#2684)', () => {
5040
+ test('NewXxxProps populates BfCallerProps for the three classes: required, nullish-consumed optional, concrete-typed optional residual', () => {
5041
+ const result = compileJSX(`
5042
+ 'use client'
5043
+ import { createSignal } from '@barefootjs/client'
5044
+ export function C(props: { x?: number; label?: string; name: string }) {
5045
+ const [x] = createSignal(props.x ?? 7)
5046
+ return <div data-name={props.name}>{x()}</div>
5047
+ }
5048
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5049
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
5050
+ const types = result.files.find(f => f.type === 'types')!.content
5051
+ // The sidecar field itself, on the Props struct.
5052
+ expect(types).toContain('BfCallerProps map[string]interface{} `json:"-"`')
5053
+
5054
+ const newProps = types.slice(types.indexOf('func NewCProps'))
5055
+ expect(newProps).toContain('bfCallerProps := map[string]interface{}{}')
5056
+ // Class 1 — required prop: always included, raw `in.Name` (never a
5057
+ // baked/defaulted value — there is none for a required prop anyway).
5058
+ expect(newProps).toContain('bfCallerProps["name"] = in.Name')
5059
+ // Class 2 — optional, nullish-consumed (`??`) prop: flips to `interface{}`
5060
+ // (#2248) and is included ONLY when the caller actually passed something.
5061
+ // Critically, the RAW `in.X` goes in the map, never the baked default
5062
+ // `7` the hoisted fallback var applies to the template-facing field.
5063
+ expect(newProps).toContain('if in.X != nil {')
5064
+ expect(newProps).toContain('bfCallerProps["x"] = in.X')
5065
+ expect(newProps).not.toMatch(/bfCallerProps\["x"\]\s*=\s*7/)
5066
+ // Class 3 — optional prop that resolves to a CONCRETE type (`label` is
5067
+ // never consumed nullish/attr/text/presence-wise, so it stays a plain
5068
+ // `string`): presence is unknowable from Input alone, so it's included
5069
+ // unconditionally — a documented residual, not silently dropped.
5070
+ expect(newProps).toContain('bfCallerProps["label"] = in.Label')
5071
+
5072
+ // The struct literal wires the local into the field.
5073
+ expect(newProps).toContain('BfCallerProps: bfCallerProps,')
5074
+ })
5075
+
5076
+ test('a required nested-array-shadowed prop (#2672/#2525) is carried via its reshaped array local, not silently dropped', () => {
5077
+ const result = compileJSX(`
5078
+ type ToggleItemProps = { label: string; defaultOn?: boolean }
5079
+ function ToggleItem(props: ToggleItemProps) {
5080
+ return <div>{props.label}</div>
5081
+ }
5082
+ type ToggleProps = { toggleItems: ToggleItemProps[] }
5083
+ export function Toggle({ toggleItems }: ToggleProps) {
5084
+ return <div>{toggleItems.map((item) => <ToggleItem key={item.label} label={item.label} defaultOn={item.defaultOn} />)}</div>
5085
+ }
5086
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5087
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
5088
+ const types = result.files.find(f => f.type === 'types')!.content
5089
+ const newProps = types.slice(types.indexOf('func NewToggleProps'))
5090
+ // The shadowed prop has no `in.ToggleItems` scalar field to read — its
5091
+ // data is carried via the already-built reshaped array local instead.
5092
+ // `toggleItems` is REQUIRED here, so it's unconditional — same
5093
+ // required-stays-unconditional rule as the main loop.
5094
+ expect(newProps).toContain('bfCallerProps["toggleItems"] = toggleItems')
5095
+ expect(newProps).not.toContain('if in.ToggleItems != nil {')
5096
+ })
5097
+ })
5098
+
5099
+ describe('GoTemplateAdapter - collision-derivation lowering (#2683)', () => {
5100
+ test('a signal colliding with its own prop composes the presence-check fold with the surrounding arithmetic', () => {
5101
+ const result = compileJSX(`
5102
+ 'use client'
5103
+ import { createSignal } from '@barefootjs/client'
5104
+ export function C(props: { count?: number }) {
5105
+ const [count, setCount] = createSignal((props.count ?? 1) * 2)
5106
+ return <span>{count()}</span>
5107
+ }
5108
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5109
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
5110
+ const types = result.files.find(f => f.type === 'types')!.content
5111
+ // The collision flips the field to the nillable representation — the
5112
+ // SAME flip an ordinary `??`-consumed optional prop gets (#2248) — so an
5113
+ // absent prop (nil) and an explicit `0` stay distinguishable.
5114
+ expect(types).toContain('Count interface{}')
5115
+ const newProps = types.slice(types.indexOf('func NewCProps'))
5116
+ // The presence-check fold (`extractPropFallbackFromParsed`'s shape,
5117
+ // reused unchanged) hoists the RAW coalesced value into a local...
5118
+ expect(newProps).toContain('var count int = 1')
5119
+ expect(newProps).toContain('if in.Count != nil {')
5120
+ expect(newProps).toContain('count = bf.ToInt(in.Count)')
5121
+ // ...and the shared field carries the FULLY DERIVED value — the local
5122
+ // with the surrounding `* 2` composed back on, not just the coalesce
5123
+ // result.
5124
+ expect(newProps).toContain('Count: count * 2,')
5125
+ // `BfCallerProps` (#2684) keeps carrying the RAW caller value, never the
5126
+ // derived one.
5127
+ expect(newProps).toContain('if in.Count != nil {')
5128
+ expect(newProps).toContain('bfCallerProps["count"] = in.Count')
5129
+ expect(newProps).not.toMatch(/bfCallerProps\["count"\]\s*=\s*count/)
5130
+ })
5131
+
5132
+ test('a NON-NUMERIC `??` fallback declines collision-derivation — raw passthrough, never invalid Go (Copilot review, #2694)', () => {
5133
+ // `(props.label ?? 'x') + 2` would otherwise hoist `var label string =
5134
+ // "x"` and emit `Label: label + 2,` — invalid Go (string + int), a
5135
+ // compile BREAK where the pre-fix behavior at least built. JS semantics
5136
+ // are string concatenation here besides, so a numeric compose could
5137
+ // never be faithful; the shape stays on the raw-passthrough path.
5138
+ const result = compileJSX(`
5139
+ 'use client'
5140
+ import { createSignal } from '@barefootjs/client'
5141
+ export function C(props: { label?: string }) {
5142
+ const [label, setLabel] = createSignal((props.label ?? 'x') + 2)
5143
+ return <span>{label()}</span>
5144
+ }
5145
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5146
+ const types = result.files.find(f => f.type === 'types')!.content
5147
+ const newProps = types.slice(types.indexOf('func NewCProps'))
5148
+ expect(newProps).not.toContain('label + 2')
5149
+ expect(newProps).not.toContain('var label string = "x"')
5150
+ // The field keeps the pre-existing raw passthrough.
5151
+ expect(newProps).toContain('Label: in.Label,')
5152
+ })
5153
+
5154
+ test('a `/ 0` operand declines collision-derivation — Go constant division by zero is a compile error', () => {
5155
+ const result = compileJSX(`
5156
+ 'use client'
5157
+ import { createSignal } from '@barefootjs/client'
5158
+ export function C(props: { count?: number }) {
5159
+ const [count, setCount] = createSignal((props.count ?? 1) / 0)
5160
+ return <span>{count()}</span>
5161
+ }
5162
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5163
+ const types = result.files.find(f => f.type === 'types')!.content
5164
+ const newProps = types.slice(types.indexOf('func NewCProps'))
5165
+ expect(newProps).not.toContain('count / 0')
5166
+ expect(newProps).toContain('Count: in.Count,')
5167
+ })
5168
+
5169
+ test('the same collision reached through a component-scope const hop (#2685) lowers identically', () => {
5170
+ const direct = compileJSX(`
5171
+ 'use client'
5172
+ import { createSignal } from '@barefootjs/client'
5173
+ export function Direct(props: { count?: number }) {
5174
+ const [count, setCount] = createSignal((props.count ?? 1) * 2)
5175
+ return <span>{count()}</span>
5176
+ }
5177
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5178
+ const viaConst = compileJSX(`
5179
+ 'use client'
5180
+ import { createSignal } from '@barefootjs/client'
5181
+ export function ViaConst(props: { count?: number }) {
5182
+ const mid = props.count
5183
+ const [count, setCount] = createSignal((mid ?? 1) * 2)
5184
+ return <span>{count()}</span>
5185
+ }
5186
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5187
+ const directTypes = direct.files.find(f => f.type === 'types')!.content
5188
+ const viaConstTypes = viaConst.files.find(f => f.type === 'types')!.content
5189
+ expect(directTypes.slice(directTypes.indexOf('func NewDirectProps'))).toContain('Count: count * 2,')
5190
+ expect(viaConstTypes.slice(viaConstTypes.indexOf('func NewViaConstProps'))).toContain('Count: count * 2,')
5191
+ })
5192
+
5193
+ test('a differently-named signal deriving from the same prop (no collision) is untouched — its own field, not the shared one', () => {
5194
+ const result = compileJSX(`
5195
+ 'use client'
5196
+ import { createSignal } from '@barefootjs/client'
5197
+ export function NonCollide(props: { count?: number }) {
5198
+ const [doubled, setDoubled] = createSignal((props.count ?? 1) * 2)
5199
+ return <span>{doubled()}</span>
5200
+ }
5201
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5202
+ const types = result.files.find(f => f.type === 'types')!.content
5203
+ // No collision here — `count`'s own field stays the plain concrete type,
5204
+ // byte-identical to before this fix.
5205
+ expect(types).toContain('Count int')
5206
+ expect(types).not.toContain('Count interface{}')
5207
+ const newProps = types.slice(types.indexOf('func NewNonCollideProps'))
5208
+ expect(newProps).not.toContain('var count int')
5209
+ expect(newProps).toContain('Count: in.Count,')
5210
+ })
5211
+ })
5212
+
4940
5213
  // #2228: a `.filter(t => …).map(todo => <Child todo={todo} .../>)` loop whose
4941
5214
  // body is a single child component ranges the WRAPPER slice (`.TodoItems`,
4942
5215
  // `.{ChildName}s` — see #2130 above), so `{{if}}`'s dot context for the
@@ -5948,9 +6221,11 @@ export { TaggedList }
5948
6221
  expect(types).toContain('Title string `json:"title"`')
5949
6222
  expect(types).toContain('Tags []string `json:"tags"`')
5950
6223
  // The Input/Props field is a typed struct slice, not the old
5951
- // `[]map[string]interface{}` fallback.
6224
+ // `[]map[string]interface{}` fallback. Scoped to the field itself —
6225
+ // `BfCallerProps map[string]interface{}` (#2684) legitimately appears
6226
+ // elsewhere in every generated Props struct now.
5952
6227
  expect(types).toMatch(/Items \[\]TaggedListItemsItem/)
5953
- expect(types).not.toContain('map[string]interface{}')
6228
+ expect(types).not.toMatch(/Items \[?\]?map\[string\]interface\{\}/)
5954
6229
  })
5955
6230
 
5956
6231
  test('nested anonymous property inside a NAMED type synthesizes a named struct (case ii, Row.user)', () => {
@@ -5984,7 +6259,10 @@ export function NestedNames() {
5984
6259
  // The signal's inline initial value bakes through the STRUCT literal
5985
6260
  // path, not `bakeInlineObjectAsGoMap`'s capitalized-key map convention.
5986
6261
  expect(types).toContain('Row{ID: "r1", User: RowUser{Name: "Ada"}}')
5987
- expect(types).not.toContain('map[string]interface{}')
6262
+ // Scoped to the `User` field itself — `BfCallerProps
6263
+ // map[string]interface{}` (#2684) legitimately appears elsewhere in
6264
+ // every generated Props struct now.
6265
+ expect(types).not.toMatch(/User map\[string\]interface\{\}/)
5988
6266
  })
5989
6267
 
5990
6268
  test('a synthesized-name collision gracefully falls back to the pre-#2674 map convention, not a regression', () => {
@@ -6045,7 +6323,15 @@ export { TaggedList }
6045
6323
  expect(payload).toEqual({ items: [{ title: 'Alpha', tags: ['a', 'b'] }] })
6046
6324
  })
6047
6325
 
6048
- test('real go-run render: bf-p carries camelCase keys for a nested anonymous object inside a named type (case ii)', async () => {
6326
+ test('real go-run render: a signal-backed nested anonymous object is baked into SSR HTML but excluded from bf-p (#2672)', async () => {
6327
+ // `rows` here is a SIGNAL, not a prop — `NestedNames` takes no props at
6328
+ // all. Its data still resolves the synthesized-struct-type branch of
6329
+ // `emitPropsDataFields` (the case ii struct-synthesis this describe
6330
+ // block exists to cover), so this doubles as a real-go-run confirmation
6331
+ // that branch's json tag is also `-` now, not just the general one.
6332
+ // `{{.Field}}` SSR access is unaffected by json tags — the rendered
6333
+ // `<li>Ada</li>` still bakes correctly; only `bf-p` (component-internal
6334
+ // signal state, not caller input) drops the redundant `rows` key.
6049
6335
  const source = `
6050
6336
  "use client"
6051
6337
  import { createSignal } from "@barefootjs/client"
@@ -6063,12 +6349,63 @@ export function NestedNames() {
6063
6349
  if (err instanceof GoNotAvailableError) return // Go toolchain not installed on this host
6064
6350
  throw err
6065
6351
  }
6352
+ // SSR HTML still bakes the signal's initial data — unaffected by the
6353
+ // json tag, since `{{.Field}}` template access doesn't consult it.
6354
+ expect(html).toContain('<li')
6355
+ expect(html).toContain('Ada')
6066
6356
  const bfPMatch = html.match(/bf-p="([^"]*)"/)
6067
6357
  expect(bfPMatch).not.toBeNull()
6068
6358
  const decoded = bfPMatch![1]
6069
6359
  .replace(/&#34;/g, '"')
6070
6360
  .replace(/&quot;/g, '"')
6071
6361
  const payload = JSON.parse(decoded)
6072
- expect(payload).toEqual({ rows: [{ id: 'r1', user: { name: 'Ada' } }] })
6362
+ // No props at all on this component bf-p carries nothing. Still a
6363
+ // real, present `bf-p="{}"` (#2684's sidecar substitution isn't gated
6364
+ // on emptiness — see `BfPropsAttr`'s doc comment for why).
6365
+ expect(payload).toEqual({})
6366
+ })
6367
+
6368
+ // #2684 end-to-end: an omitted optional prop must not resurrect the
6369
+ // author's default, or a `null`, in the wire payload — and an EXPLICIT
6370
+ // falsy/zero value the caller DID pass must still come through.
6371
+ test('real go-run render: bf-p carries only caller-supplied keys — omitted optional absent, explicit zero present, required always present', async () => {
6372
+ const source = `
6373
+ 'use client'
6374
+ import { createSignal } from '@barefootjs/client'
6375
+ export function C(props: { x?: number; label?: string; name: string }) {
6376
+ const [x] = createSignal(props.x ?? 7)
6377
+ return <div data-name={props.name}>{x()}</div>
6378
+ }
6379
+ export { C }
6380
+ `
6381
+ let htmlOmitted: string
6382
+ let htmlExplicitZero: string
6383
+ try {
6384
+ htmlOmitted = await renderGoTemplateComponent({
6385
+ source,
6386
+ adapter: new GoTemplateAdapter(),
6387
+ props: { name: 'Ada' },
6388
+ })
6389
+ htmlExplicitZero = await renderGoTemplateComponent({
6390
+ source,
6391
+ adapter: new GoTemplateAdapter(),
6392
+ props: { name: 'Ada', x: 0 },
6393
+ })
6394
+ } catch (err) {
6395
+ if (err instanceof GoNotAvailableError) return // Go toolchain not installed on this host
6396
+ throw err
6397
+ }
6398
+ const decode = (html: string) => {
6399
+ const m = html.match(/bf-p="([^"]*)"/)
6400
+ expect(m).not.toBeNull()
6401
+ return JSON.parse(m![1].replace(/&#34;/g, '"').replace(/&quot;/g, '"'))
6402
+ }
6403
+ // `x` omitted: no baked `7`, no `null` — the key is simply absent.
6404
+ // `label` (class-3 residual, no consumption anywhere) still shows up at
6405
+ // its Go zero value — documented, not silently dropped.
6406
+ expect(decode(htmlOmitted)).toEqual({ name: 'Ada', label: '' })
6407
+ // `x: 0` explicitly passed: present with the caller's real value, not
6408
+ // coalesced away or confused with "absent".
6409
+ expect(decode(htmlExplicitZero)).toEqual({ name: 'Ada', label: '', x: 0 })
6073
6410
  })
6074
6411
  })
@@ -59,6 +59,20 @@ export interface GoEmitContext {
59
59
  preParsed?: ParsedExpr,
60
60
  ): { propName: string; goFallback: string } | null
61
61
 
62
+ /**
63
+ * #2683: match the collision-derivation shape `(props.X ?? <lit>) <op>
64
+ * <int>` against an already-resolved `ParsedExpr` — the ONE non-idempotent
65
+ * form this adapter faithfully lowers when a signal's Go field name
66
+ * collides with its own prop's field. Composes
67
+ * {@link extractPropFallback}'s structural presence-check recognition
68
+ * (applied to the embedded `??` subtree) with the same non-negative-
69
+ * integer arithmetic wrap the memo-computation emitter already supports
70
+ * for a bare `props.X <op> N`. Returns null for any other shape.
71
+ */
72
+ extractCollisionDerivation(
73
+ parsed: ParsedExpr | undefined,
74
+ ): { propName: string; goFallback: string; operator: string; operand: string } | null
75
+
62
76
  /**
63
77
  * Inline a module string const by name as a Go double-quoted literal
64
78
  * (`"<escaped>"`), or null when the name is not such a const (loop vars and