@barefootjs/go-template 0.31.8 → 0.31.10

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,')
@@ -1092,15 +1093,18 @@ export function Rows() {
1092
1093
  expect(types).toContain('Rows: []Row{Row{DataID: "a"}},')
1093
1094
  })
1094
1095
 
1095
- test('bakes a non-Go-identifier key inside a nested INLINE object with the same sanitizer as the accessor side (#2089 review)', () => {
1096
- // A nested inline-object property (`meta: { 'data-x': string }` — no
1097
- // named Go struct, lowered to map[string]interface{}) bakes as a Go map
1098
- // literal. The map KEY must be produced by `goFieldNameForKey` — the
1099
- // same function `buildSegmentAccessor`/`structFieldsFor` use so the
1100
- // emitted accessor (`.Meta.DataX`, an exact-string MapIndex on maps)
1101
- // actually finds the value. `capitalizeFieldName` alone would bake
1102
- // `"Data-x"`, a key no emitted accessor can reach, silently rendering
1103
- // empty (flagged by Copilot on PR #2089).
1096
+ test('bakes a non-Go-identifier key inside a nested INLINE object via the sanitizer-named synthesized struct (#2089 review, superseded by #2674)', () => {
1097
+ // A nested inline-object property (`meta: { 'data-x': string }`) used
1098
+ // to have no named Go struct and lower to `map[string]interface{}`,
1099
+ // baked as a Go map literal with a `goFieldNameForKey`-sanitized key
1100
+ // (`"DataX"`, not the invalid-accessor `"Data-x"` — #2089). #2674's
1101
+ // struct-synthesis pre-pass now gives it a real struct (`RowMeta`)
1102
+ // instead the FIELD name still comes from the same sanitizer
1103
+ // (`goFieldNameForKey`), so the accessor (`.Meta.DataX`) and the json
1104
+ // tag (the ORIGINAL unsanitized source key, `"data-x"` — the struct
1105
+ // field's json tag, not the map-baking convention's capitalized key)
1106
+ // both still resolve correctly; only the container changed from a map
1107
+ // to a struct.
1104
1108
  const adapter = new GoTemplateAdapter()
1105
1109
  const ir = compileToIR(`
1106
1110
  "use client"
@@ -1113,8 +1117,11 @@ export function Rows() {
1113
1117
  }
1114
1118
  `)
1115
1119
  const types = adapter.generate(ir).types!
1116
- expect(types).toContain('map[string]interface{}{"DataX": "v"}')
1120
+ expect(types).toContain('type RowMeta struct {')
1121
+ expect(types).toContain('DataX string `json:"data-x"`')
1117
1122
  expect(types).not.toContain('"Data-x"')
1123
+ expect(types).toContain('Row{ID: "r1", Meta: RowMeta{DataX: "v"}}')
1124
+ expect(types).not.toContain('map[string]interface{}')
1118
1125
  })
1119
1126
 
1120
1127
  test('snake_case keys keep their underscore in the generated field name (#2089 review)', () => {
@@ -1692,6 +1699,56 @@ export function Box({ children }: { children: any }) {
1692
1699
  const types = adapter.generateTypes(ir)!
1693
1700
  expect(types).toMatch(/Children\s+\S+\s+`json:"-"`/)
1694
1701
  })
1702
+
1703
+ test('signal and memo fields are excluded from bf-p serialization — user props only (#2672)', () => {
1704
+ // A signal-bearing component whose only DECLARED prop is `label`, plus
1705
+ // a signal (`count`, not itself a prop) and a memo derived from it
1706
+ // (`doubled`). Only `label` may carry a real json tag — `count` and
1707
+ // `doubled` are component-internal state/derivation the client
1708
+ // re-derives itself and never reads back off `_p`.
1709
+ const adapter = new GoTemplateAdapter()
1710
+ const ir = compileToIR(`
1711
+ 'use client'
1712
+ import { createSignal, createMemo } from '@barefootjs/client'
1713
+ export function Counter(props: { label: string }) {
1714
+ const [count, setCount] = createSignal(0)
1715
+ const doubled = createMemo(() => count() * 2)
1716
+ return <button onClick={() => setCount(count() + 1)}>{props.label}: {count()} / {doubled()}</button>
1717
+ }
1718
+ `, adapter)
1719
+ const types = adapter.generateTypes(ir)!
1720
+ // The real prop keeps its real tag — hydration reads `_p.label`.
1721
+ expect(types).toMatch(/Label\s+\S+\s+`json:"label"`/)
1722
+ // The signal field is component-internal state, not caller input.
1723
+ expect(types).toMatch(/Count\s+\S+\s+`json:"-"`/)
1724
+ // The memo field is component-internal derivation, not caller input.
1725
+ expect(types).toMatch(/Doubled\s+\S+\s+`json:"-"`/)
1726
+ })
1727
+
1728
+ test('prop-backed signal default still keeps the PROP field a real tag (#2672)', () => {
1729
+ // Mirrors the `signal-default-from-jsx` adapter-tests fixture: `x` is a
1730
+ // declared PROP whose default happens to come from a signal initial
1731
+ // value (keeps a real tag, seeds client-side `createSignal(_p.x ?? 7)`),
1732
+ // while `incremented` is a pure memo derivation the client never reads
1733
+ // off `_p`. Flipping signal/memo fields to `json:"-"` must NOT touch
1734
+ // this prop field — it is the loop-guard case CLAUDE.md's "don't flip
1735
+ // prop-backed fields" warns against.
1736
+ const adapter = new GoTemplateAdapter()
1737
+ const ir = compileToIR(`
1738
+ 'use client'
1739
+ import { createSignal, createMemo } from '@barefootjs/client'
1740
+ export function SignalDefaultFromJsx(props: { x?: number }) {
1741
+ const [x, setX] = createSignal(props.x ?? 7)
1742
+ const incremented = createMemo(() => x() + 1)
1743
+ return <div onClick={() => setX(x() + 1)}>{x()} / {incremented()}</div>
1744
+ }
1745
+ `, adapter)
1746
+ const types = adapter.generateTypes(ir)!
1747
+ // The prop-backed field keeps its real tag — hydration reads `_p.x`.
1748
+ expect(types).toMatch(/X\s+\S+\s+`json:"x"`/)
1749
+ // The memo field is component-internal derivation, not caller input.
1750
+ expect(types).toMatch(/Incremented\s+\S+\s+`json:"-"`/)
1751
+ })
1695
1752
  })
1696
1753
 
1697
1754
  describe('generateTypes', () => {
@@ -4854,6 +4911,39 @@ export function Counter({ count: initialCount }: { count: number }) {
4854
4911
  expect((types.match(/json:"count"/g) ?? []).length).toBe(1)
4855
4912
  })
4856
4913
 
4914
+ // #2672 near-miss: a prop-derived dynamic loop's nested-array field is
4915
+ // normally redundant (the client re-derives every row from the real prop
4916
+ // field, e.g. `_p.items`) and gets `json:"-"`. But when the array field's
4917
+ // Go name collides with its OWN driving prop's Go name — `toggleItems`
4918
+ // driving a `.map()` into `<ToggleItem>` both capitalize to `ToggleItems`
4919
+ // — `emitPropsDataFields` already shadows the prop's own field to avoid a
4920
+ // Go redeclaration, so the nested-array field is the ONLY struct field
4921
+ // carrying that prop's data. Flipping it to `-` there would silently drop
4922
+ // caller input from `bf-p` instead of trimming a redundant copy. Caught by
4923
+ // the `hydration-props-inventory` oracle against the `toggle-shared`
4924
+ // fixture (a real `integrations/shared/components/Toggle.tsx` shape)
4925
+ // before landing; this pins the same shape directly.
4926
+ test('a prop-derived nested-array field keeps a real tag when it shadows its own driving prop (#2672)', () => {
4927
+ const result = compileJSX(`
4928
+ type ToggleItemProps = { label: string; defaultOn?: boolean }
4929
+ function ToggleItem(props: ToggleItemProps) {
4930
+ return <div>{props.label}</div>
4931
+ }
4932
+ type ToggleProps = { toggleItems: ToggleItemProps[] }
4933
+ export function Toggle({ toggleItems }: ToggleProps) {
4934
+ return <div>{toggleItems.map((item) => <ToggleItem key={item.label} label={item.label} defaultOn={item.defaultOn} />)}</div>
4935
+ }
4936
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4937
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4938
+ const types = result.files.find(f => f.type === 'types')!.content
4939
+ const togglePropsAndAfter = types.slice(types.indexOf('type ToggleProps struct'))
4940
+ // The shadowed scalar prop field is absent...
4941
+ expect(togglePropsAndAfter.slice(0, togglePropsAndAfter.indexOf('}'))).not.toMatch(/\bItems\b/)
4942
+ // ...and the nested-array field is the sole carrier, with a REAL tag —
4943
+ // not `json:"-"`, which would drop `toggleItems` from `bf-p` entirely.
4944
+ expect((togglePropsAndAfter.match(/json:"toggleItems"/g) ?? []).length).toBe(1)
4945
+ })
4946
+
4857
4947
  // The nested-array skip check must union both namings at all four sites
4858
4948
  // (`isNestedArrayShadowed`) — a one-sided check lets Input and
4859
4949
  // Props/NewProps disagree on whether an aliased prop's field exists.
@@ -5029,11 +5119,14 @@ export function TodoItem(props: Props) {
5029
5119
  // field Done in type TodoItemProps`) or — since `bf_sort_eval`-style
5030
5120
  // evaluators are untouched by this fix, only the html/template dot-path
5031
5121
  // is — renders wrong. Post-fix: only the not-done todo (id 2) survives
5032
- // the 'active' filter.
5033
- expect(html).not.toContain('Eat breakfast')
5034
- expect(html).toContain('Write tests')
5035
- expect(html).toContain('data-key="2"')
5036
- expect(html).not.toContain('data-key="1"')
5122
+ // the 'active' filter. The bf-p hydration payload legitimately carries
5123
+ // the full unfiltered `initialTodos` (both quote styles, mirroring
5124
+ // `normalizeHTML`), so the assertions target the rendered list only.
5125
+ const rendered = html.replace(/\s*bf-p=(?:"[^"]*"|'[^']*')/g, '')
5126
+ expect(rendered).not.toContain('Eat breakfast')
5127
+ expect(rendered).toContain('Write tests')
5128
+ expect(rendered).toContain('data-key="2"')
5129
+ expect(rendered).not.toContain('data-key="1"')
5037
5130
  })
5038
5131
  })
5039
5132
 
@@ -5909,3 +6002,170 @@ export function Counter() {
5909
6002
  expect(template).not.toContain('<link')
5910
6003
  })
5911
6004
  })
6005
+
6006
+ describe('GoTemplateAdapter - #2674 anonymous object types synthesize named structs', () => {
6007
+ // Plan A: a type with no name — an inline array-element type or a nested
6008
+ // anonymous property inside a named type — used to lower to
6009
+ // `map[string]interface{}` with DELIBERATELY PascalCased keys
6010
+ // (`bakeInlineObjectAsGoMap`, #2087/#1487: `html/template`'s dot access on
6011
+ // a map does an exact-string `MapIndex`). SSR rendered fine off that
6012
+ // convention, but `BfPropsAttr`'s `json.Marshal` ships the SAME map, so
6013
+ // the hydration payload leaked Go casing (`{"Name":"Ada"}` instead of
6014
+ // `{"name":"Ada"}`). `emitSynthPropStructs` now synthesizes a
6015
+ // deterministically-named, json-tagged struct for these types instead, so
6016
+ // the SAME value bakes as a typed struct literal and `json.Marshal`
6017
+ // produces the correct camelCase payload without changing SSR at all.
6018
+
6019
+ test('inline array-element type synthesizes a named json-tagged struct (case i)', () => {
6020
+ // `items: { id: number; tags: string[] }[]` has no backing
6021
+ // `TypeDefinition` at all — only `ir.metadata.propsParams`' own
6022
+ // `TypeInfo` tree carries its shape.
6023
+ const adapter = new GoTemplateAdapter()
6024
+ const ir = compileToIR(`
6025
+ function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
6026
+ return <ul>{props.items.map((p) => <li key={p.title}>{p.title}</li>)}</ul>
6027
+ }
6028
+ export { TaggedList }
6029
+ `)
6030
+ const types = adapter.generateTypes(ir)!
6031
+ expect(types).toContain('type TaggedListItemsItem struct {')
6032
+ expect(types).toContain('Title string `json:"title"`')
6033
+ expect(types).toContain('Tags []string `json:"tags"`')
6034
+ // The Input/Props field is a typed struct slice, not the old
6035
+ // `[]map[string]interface{}` fallback.
6036
+ expect(types).toMatch(/Items \[\]TaggedListItemsItem/)
6037
+ expect(types).not.toContain('map[string]interface{}')
6038
+ })
6039
+
6040
+ test('nested anonymous property inside a NAMED type synthesizes a named struct (case ii, Row.user)', () => {
6041
+ // `Row` is a real `TypeDefinition`; its `user` property has no name of
6042
+ // its own — only `ir.metadata.typeDefinitions` carries `Row`'s property
6043
+ // list (a `{kind:'interface', raw:'Row'}` prop reference does not).
6044
+ const adapter = new GoTemplateAdapter()
6045
+ const ir = compileToIR(`
6046
+ "use client"
6047
+ import { createSignal } from "@barefootjs/client"
6048
+
6049
+ type Row = { id: string; user: { name: string } }
6050
+ export function NestedNames() {
6051
+ const [rows, setRows] = createSignal<Row[]>([
6052
+ { id: "r1", user: { name: "Ada" } },
6053
+ { id: "r2", user: { name: "Grace" } },
6054
+ ])
6055
+ return (
6056
+ <ul onClick={() => setRows((r) => r)}>
6057
+ {rows().map(({ id, user: { name } }) => (
6058
+ <li key={id}>{name}</li>
6059
+ ))}
6060
+ </ul>
6061
+ )
6062
+ }
6063
+ `)
6064
+ const types = adapter.generateTypes(ir)!
6065
+ expect(types).toContain('type RowUser struct {')
6066
+ expect(types).toContain('Name string `json:"name"`')
6067
+ expect(types).toContain('User RowUser `json:"user"`')
6068
+ // The signal's inline initial value bakes through the STRUCT literal
6069
+ // path, not `bakeInlineObjectAsGoMap`'s capitalized-key map convention.
6070
+ expect(types).toContain('Row{ID: "r1", User: RowUser{Name: "Ada"}}')
6071
+ expect(types).not.toContain('map[string]interface{}')
6072
+ })
6073
+
6074
+ test('a synthesized-name collision gracefully falls back to the pre-#2674 map convention, not a regression', () => {
6075
+ // `synthesizeStructFromSignal`'s existing collision precedent
6076
+ // (#1680, `keeps nil when the synthesised name collides with a user
6077
+ // type`), mirrored for `emitSynthPropStructs`: when the deterministic
6078
+ // name (`Row><Prop>`) is already taken by a real user type, synthesis
6079
+ // is declined for that ONE type and it keeps the historical map
6080
+ // fallback — SSR stays correct (`bakeInlineObjectAsGoMap` still bakes
6081
+ // it), only the hydration-payload casing fix doesn't apply to it.
6082
+ const adapter = new GoTemplateAdapter()
6083
+ const ir = compileToIR(`
6084
+ "use client"
6085
+ import { createSignal } from "@barefootjs/client"
6086
+
6087
+ type RowUser = { handle: string }
6088
+ type Row = { id: string; user: { name: string } }
6089
+ export function NestedNames() {
6090
+ const [rows] = createSignal<Row[]>([{ id: "r1", user: { name: "Ada" } }])
6091
+ return <ul>{rows().map(({ id, user: { name } }) => <li key={id}>{name}</li>)}</ul>
6092
+ }
6093
+ `)
6094
+ const types = adapter.generateTypes(ir)!
6095
+ // The user's own `RowUser` struct is emitted, untouched...
6096
+ expect(types).toContain('type RowUser struct {')
6097
+ expect(types).toContain('Handle string `json:"handle"`')
6098
+ // ...and `Row.user` — whose synthesized name collides with it — falls
6099
+ // back to the map convention rather than being silently mistyped as
6100
+ // the unrelated user type.
6101
+ expect(types).toMatch(/User map\[string\]interface\{\}/)
6102
+ expect(types).toContain('map[string]interface{}{"Name": "Ada"}')
6103
+ })
6104
+
6105
+ test('real go-run render: bf-p carries camelCase keys for an inline array-element prop (case i)', async () => {
6106
+ const source = `
6107
+ function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
6108
+ return <ul>{props.items.map((p) => <li key={p.title}>{p.title}</li>)}</ul>
6109
+ }
6110
+ export { TaggedList }
6111
+ `
6112
+ let html: string
6113
+ try {
6114
+ html = await renderGoTemplateComponent({
6115
+ source,
6116
+ adapter: new GoTemplateAdapter(),
6117
+ props: { items: [{ title: 'Alpha', tags: ['a', 'b'] }] },
6118
+ })
6119
+ } catch (err) {
6120
+ if (err instanceof GoNotAvailableError) return // Go toolchain not installed on this host
6121
+ throw err
6122
+ }
6123
+ const bfPMatch = html.match(/bf-p="([^"]*)"/)
6124
+ expect(bfPMatch).not.toBeNull()
6125
+ const decoded = bfPMatch![1]
6126
+ .replace(/&#34;/g, '"')
6127
+ .replace(/&quot;/g, '"')
6128
+ const payload = JSON.parse(decoded)
6129
+ expect(payload).toEqual({ items: [{ title: 'Alpha', tags: ['a', 'b'] }] })
6130
+ })
6131
+
6132
+ test('real go-run render: a signal-backed nested anonymous object is baked into SSR HTML but excluded from bf-p (#2672)', async () => {
6133
+ // `rows` here is a SIGNAL, not a prop — `NestedNames` takes no props at
6134
+ // all. Its data still resolves the synthesized-struct-type branch of
6135
+ // `emitPropsDataFields` (the case ii struct-synthesis this describe
6136
+ // block exists to cover), so this doubles as a real-go-run confirmation
6137
+ // that branch's json tag is also `-` now, not just the general one.
6138
+ // `{{.Field}}` SSR access is unaffected by json tags — the rendered
6139
+ // `<li>Ada</li>` still bakes correctly; only `bf-p` (component-internal
6140
+ // signal state, not caller input) drops the redundant `rows` key.
6141
+ const source = `
6142
+ "use client"
6143
+ import { createSignal } from "@barefootjs/client"
6144
+
6145
+ type Row = { id: string; user: { name: string } }
6146
+ export function NestedNames() {
6147
+ const [rows] = createSignal<Row[]>([{ id: "r1", user: { name: "Ada" } }])
6148
+ return <ul>{rows().map(({ id, user: { name } }) => <li key={id}>{name}</li>)}</ul>
6149
+ }
6150
+ `
6151
+ let html: string
6152
+ try {
6153
+ html = await renderGoTemplateComponent({ source, adapter: new GoTemplateAdapter() })
6154
+ } catch (err) {
6155
+ if (err instanceof GoNotAvailableError) return // Go toolchain not installed on this host
6156
+ throw err
6157
+ }
6158
+ // SSR HTML still bakes the signal's initial data — unaffected by the
6159
+ // json tag, since `{{.Field}}` template access doesn't consult it.
6160
+ expect(html).toContain('<li')
6161
+ expect(html).toContain('Ada')
6162
+ const bfPMatch = html.match(/bf-p="([^"]*)"/)
6163
+ expect(bfPMatch).not.toBeNull()
6164
+ const decoded = bfPMatch![1]
6165
+ .replace(/&#34;/g, '"')
6166
+ .replace(/&quot;/g, '"')
6167
+ const payload = JSON.parse(decoded)
6168
+ // No props at all on this component — bf-p carries nothing.
6169
+ expect(payload).toEqual({})
6170
+ })
6171
+ })