@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.
- package/dist/adapter/emit-context.d.ts +16 -0
- package/dist/adapter/emit-context.d.ts.map +1 -1
- package/dist/adapter/go-template-adapter.d.ts +82 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +119 -9
- package/dist/adapter/lib/compile-state.d.ts +28 -1
- package/dist/adapter/lib/compile-state.d.ts.map +1 -1
- package/dist/adapter/lib/types.d.ts +13 -0
- package/dist/adapter/lib/types.d.ts.map +1 -1
- package/dist/adapter/props/prop-types.d.ts.map +1 -1
- package/dist/index.js +119 -9
- package/dist/render-divergences.d.ts +3 -3
- package/dist/vite.js +154 -11
- package/package.json +5 -5
- package/src/__tests__/go-template-adapter.test.ts +345 -8
- package/src/adapter/emit-context.ts +14 -0
- package/src/adapter/go-template-adapter.ts +330 -10
- package/src/adapter/lib/compile-state.ts +34 -0
- package/src/adapter/lib/types.ts +10 -0
- package/src/adapter/props/prop-types.ts +32 -1
- package/src/render-divergences.ts +3 -3
- package/src/test-render.ts +18 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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(/"/g, '"')
|
|
6070
6360
|
.replace(/"/g, '"')
|
|
6071
6361
|
const payload = JSON.parse(decoded)
|
|
6072
|
-
|
|
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(/"/g, '"').replace(/"/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
|