@barefootjs/go-template 0.17.1 → 0.18.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.
@@ -6,12 +6,17 @@
6
6
 
7
7
  import { describe, test, expect } from 'bun:test'
8
8
  import { GoTemplateAdapter } from '../adapter/go-template-adapter'
9
- import {
10
- runAdapterConformanceTests,
11
- TemplatePrimitiveCaseId,
12
- } from '@barefootjs/adapter-tests'
9
+ import { runAdapterConformanceTests } from '@barefootjs/adapter-tests'
13
10
  import { renderGoTemplateComponent, GoNotAvailableError } from '@barefootjs/go-template/test-render'
14
- import { compileJSX, type ComponentIR, type IRExpression } from '@barefootjs/jsx'
11
+ import {
12
+ compileJSX,
13
+ analyzeComponent,
14
+ buildMetadata,
15
+ jsxToIR,
16
+ type ComponentIR,
17
+ type IRExpression,
18
+ } from '@barefootjs/jsx'
19
+ import { conformancePins } from '../conformance-pins'
15
20
 
16
21
  runAdapterConformanceTests({
17
22
  name: 'go-template',
@@ -65,135 +70,38 @@ runAdapterConformanceTests({
65
70
  // memo's field type is `[]any` and its constructor init serializes the
66
71
  // `.filter` predicate to the runtime evaluator. See the #2075 constructor
67
72
  // pins below.
68
- skipJsx: [],
73
+ //
74
+ // `context-provider-nullish-object-fallback` (#2087) is no longer skipped:
75
+ // the Go adapter now models an OBJECT-shaped `createContext` default
76
+ // (`ContextConsumer.defaultKind === 'object'`, `augment-inherited-props.ts`)
77
+ // as a real `map[string]interface{}` consumer field instead of falling
78
+ // through to the scalar `string` default, and `extendProviderContext`
79
+ // lowers the chart shape's `<Ctx.Provider value={{ config: props.config ??
80
+ // {} }}>` into a `map[string]interface{}` Go expression bound into the
81
+ // descendant's constructor call (`providerObjectValueToGoMap` /
82
+ // `lowerProviderMapMemberValue`). The consumer's `ctx.config.label` read
83
+ // lowers through the runtime's case-tolerant `bf_get` (`getFieldValue`,
84
+ // bf.go) instead of a plain `.Ctx.Config.Label` dot-chain, which would
85
+ // require an exact-cased struct/map field that never exists. See
86
+ // go-template-adapter.ts's `member()` `isMapRootedContextChain` branch.
69
87
  // Per-fixture build-time contracts for shapes the Go template
70
- // adapter intentionally refuses to lower. Lives here (not on the
71
- // shared fixtures) so adding a new adapter doesn't require touching
72
- // any cross-adapter file — every adapter declares its own
88
+ // adapter intentionally refuses to lower. Lives in `../conformance-pins`
89
+ // (not on the shared fixtures) so adding a new adapter doesn't require
90
+ // touching any cross-adapter file — every adapter declares its own
73
91
  // refusal set against the canonical fixture corpus.
74
- expectedDiagnostics: {
75
- // `style-object-dynamic` / `style-3-signals` no longer pinned — a
76
- // `style={{ … }}` object literal now lowers to a CSS string with dynamic
77
- // values interpolated (`background-color:{{.Color}};padding:8px`) via
78
- // `tryLowerStyleObject` (#1322).
79
- // Sibling-imported child component inside a loop body: the adapter
80
- // emits `{{template "X" .}}` which only resolves if the user has
81
- // compiled the sibling file and registered the template on the
82
- // same instance. BF103 makes that requirement loud. (The barefoot
83
- // CLI passes `siblingTemplatesRegistered: true` so CLI builds
84
- // suppress the diagnostic see compileJSX `siblingTemplatesRegistered`.)
85
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
86
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
87
- // call it inside a keyed `.map`. Same BF103 surface as
88
- // `static-array-children` above — pinned at adapter level so the
89
- // shared-component corpus stays adapter-neutral.
90
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
91
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
92
- // Array-destructure loop param (`([k, v]) => ...`): Go's `{{range
93
- // $a, $b := ...}}` only supports single-name bindings, so the
94
- // adapter would otherwise emit invalid template syntax.
95
- 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
96
- // Same destructure shape with a child component body — fires both
97
- // BF103 (imported child in loop) and BF104 (destructure param).
98
- 'static-array-from-props-with-component': [
99
- { code: 'BF103', severity: 'error' },
100
- { code: 'BF104', severity: 'error' },
101
- ],
102
- // (`style-3-signals` graduated alongside `style-object-dynamic` — see note
103
- // above; the `style={{ … }}` object now lowers to a CSS string.)
104
- // #1244 stress catalog: tagged-template-literal callees
105
- // (`cn\`base \${tone()}\``) likewise can't lower into Go template
106
- // syntax — same BF101 refusal.
107
- 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
108
- // #2038: a filter predicate whose body contains a NESTED callback call
109
- // (`t => !picked().some(p => …)` / `t => picked().find(p => …)`). The
110
- // evaluator refuses nested arrows and `renderFilterExpr` has no faithful
111
- // Go form for the inner call (its `call` arm used to silently drop the
112
- // arrow argument and render only the callee) — the compiler is loud
113
- // instead of lossy. The `/* @client */` twin
114
- // (`filter-nested-callback-predicate-client`) has no pin here: it must
115
- // render clean on every adapter, which asserts the suppression contract.
116
- // https://github.com/piconic-ai/barefootjs/issues/2038
117
- 'filter-nested-callback-predicate': [{ code: 'BF101', severity: 'error' }],
118
- 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error' }],
119
- // #1310: rest destructure in .map() callback. The object-rest shape read
120
- // via member access (`rest-destructure-object-in-map`) now lowers — each
121
- // binding resolves to a field on a synthetic `$__bf_item0` range var (the
122
- // reserved `__bf_item` name, depth-suffixed) and `rest.flag` →
123
- // `$__bf_item0.Flag` (`destructureBindingsSupportable`). The
124
- // other three stay refused: rest SPREAD (`{...rest}`) needs a residual
125
- // object, and array-index / nested paths (`[a, ...t]`, `{ cells: [h] }`)
126
- // need index/slice machinery Go's `{{range}}` can't express inline.
127
- 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
128
- 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
129
- 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
130
- // #1443: `[a, b].filter(Boolean).join(' ')` (registry Slot) now
131
- // lowers to `bf_join (bf_filter_truthy (bf_arr ...)) " "`. No
132
- // BF101 expected — pinned positively by the
133
- // `branch-local-filter-join-go` template-output test below.
134
- //
135
- // #1448 Tier A — JS Array / String methods that the Go template
136
- // adapter hasn't lowered yet. Each row drops once the
137
- // corresponding method PR lands. Hono / CSR pass these out of
138
- // the box (they evaluate JS at runtime) so the pin only applies
139
- // here.
140
- //
141
- // `array-includes` / `string-includes` no longer pinned — both
142
- // shapes lower via the shared `array-method` IR + the polymorphic
143
- // `bf_includes` runtime helper that dispatches on
144
- // `reflect.Kind()` (slice/array → element search, string →
145
- // substring search). The condition-position lowering picks up
146
- // the same emit through the `array-method` arm of
147
- // `renderConditionExpr` (#1448 Tier A first PR).
148
- //
149
- // Remaining fixtures land at expression position and surface BF101
150
- // via `convertExpressionToGo`. Distinct codes for the two paths is
151
- // pre-existing adapter behaviour, not something this catalog
152
- // should paper over — pinned literally here.
153
- // `array-indexOf` / `array-lastIndexOf` no longer pinned —
154
- // value-equality `bf_index_of` / `bf_last_index_of` Go runtime
155
- // helpers handle the shape (#1448 Tier A second PR).
156
- // `array-at` no longer pinned — the pre-existing `bf_at` runtime
157
- // helper now lowers `.at(i)` (#1448 Tier A third PR).
158
- // `array-concat` no longer pinned — the new `bf_concat` runtime
159
- // helper merges two arrays into a single `[]any` (#1448 Tier A
160
- // fourth PR).
161
- // `array-slice` no longer pinned — the new `bf_slice` runtime
162
- // helper carves out a sub-range with JS-compat clamping
163
- // (#1448 Tier A fifth PR).
164
- // `array-reverse` / `array-toReversed` no longer pinned —
165
- // both share the `bf_reverse` helper since SSR templates
166
- // render a snapshot and the JS mutate-vs-new distinction has
167
- // no template-level meaning (#1448 Tier A sixth PR).
168
- // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
169
- // pre-existing `bf_lower` / `bf_upper` runtime helpers wire to
170
- // the JS method names at the adapter layer (#1448 Tier A
171
- // seventh + eighth PRs).
172
- // `string-trim` no longer pinned — pre-existing `bf_trim`
173
- // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
174
- // ninth PR, closing out Tier A).
175
- // #2073 follow-up: a function-reference `.map(format)` callback has no
176
- // arrow body to serialize — not a CALLBACK_METHODS shape — so the
177
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
178
- // a broken template.
179
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
180
- },
181
- // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
182
- // via `GoTemplateAdapter.templatePrimitives` (#1188). The two
183
- // remaining cases stay skipped because the V1 registry is
184
- // identifier-path-only and explicit:
185
- // - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
186
- // the registry and can't be rendered server-side without
187
- // user-supplied template-fn mappings.
188
- // - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
189
- // too, same reason.
190
- // Adding new entries to `templatePrimitives` should narrow this
191
- // skip set; see `templatePrimitives` declaration in
192
- // `go-template-adapter.ts` for the full V1 surface.
193
- skipTemplatePrimitives: new Set([
194
- TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
195
- TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
196
- ]),
92
+ expectedDiagnostics: conformancePins,
93
+ // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` pass via
94
+ // `GoTemplateAdapter.templatePrimitives` (#1188) the identifier-path
95
+ // registry for well-known JS builtins. `USER_IMPORT_VIA_CONST` and
96
+ // `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` now ALSO pass (#2069): a bespoke
97
+ // user import can never be added to the string-keyed registry (the
98
+ // adapter has no way to know about it ahead of time), but the shared
99
+ // `RelocateEnv.loweringMatchers` acceptance path recognises it via a
100
+ // `LoweringPlugin` the case setup registers around the compile (see
101
+ // `packages/adapter-tests/src/cases/template-primitives.ts`) the same
102
+ // seam a real userland plugin author would use. No skips left, so
103
+ // `skipTemplatePrimitives` is omitted entirely (defaults to "skip
104
+ // nothing").
197
105
  skipMarkerConformance: new Set<string>([
198
106
  // Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
199
107
  // `.map` intentionally elide a slot id from the SSR template that
@@ -922,10 +830,18 @@ export function Dyn() {
922
830
  expect(types).toContain('Items: nil,')
923
831
  })
924
832
 
925
- test('keeps nil for object keys that are not Go-identifier-safe (#1675 review)', () => {
926
- // A quoted key like "data-id" capitalises to `Data-id`, which is not a
927
- // valid Go struct field identifier baking it would emit a keyed struct
928
- // literal that doesn't compile, so the whole array must stay nil.
833
+ test('bakes a non-Go-identifier object key via a sanitized struct field (#1675 review, revised #2087)', () => {
834
+ // A quoted key like "data-id" used to capitalise to `Data-id` (not a
835
+ // valid Go struct field identifier), so the struct field was dropped
836
+ // entirely and the whole array baked to nil rather than emit invalid
837
+ // Go — silently losing the data. #2087 Phase B's `goFieldNameForKey`
838
+ // (packages/adapter-go-template/src/adapter/lib/go-naming.ts) instead
839
+ // PascalCases each segment split on the hyphen (`data-id` → `DataID`,
840
+ // matching the `id` whole-word initialism), giving the field a real
841
+ // Go name — needed so the destructure-rest residual lowering
842
+ // (`rest-destructure-object-spread-in-map`, #2087) has something to
843
+ // read a non-identifier sibling key back from. The array now bakes for
844
+ // real instead of deferring to nil.
929
845
  const adapter = new GoTemplateAdapter()
930
846
  const ir = compileToIR(`
931
847
  "use client"
@@ -938,7 +854,56 @@ export function Rows() {
938
854
  }
939
855
  `)
940
856
  const types = adapter.generate(ir).types!
941
- expect(types).toContain('Rows: nil,')
857
+ expect(types).toContain('DataID string `json:"data-id"`')
858
+ expect(types).toContain('Rows: []Row{Row{DataID: "a"}},')
859
+ })
860
+
861
+ test('bakes a non-Go-identifier key inside a nested INLINE object with the same sanitizer as the accessor side (#2089 review)', () => {
862
+ // A nested inline-object property (`meta: { 'data-x': string }` — no
863
+ // named Go struct, lowered to map[string]interface{}) bakes as a Go map
864
+ // literal. The map KEY must be produced by `goFieldNameForKey` — the
865
+ // same function `buildSegmentAccessor`/`structFieldsFor` use — so the
866
+ // emitted accessor (`.Meta.DataX`, an exact-string MapIndex on maps)
867
+ // actually finds the value. `capitalizeFieldName` alone would bake
868
+ // `"Data-x"`, a key no emitted accessor can reach, silently rendering
869
+ // empty (flagged by Copilot on PR #2089).
870
+ const adapter = new GoTemplateAdapter()
871
+ const ir = compileToIR(`
872
+ "use client"
873
+ import { createSignal } from "@barefootjs/client"
874
+
875
+ type Row = { id: string; meta: { "data-x": string } }
876
+ export function Rows() {
877
+ const [rows] = createSignal<Row[]>([{ id: "r1", meta: { "data-x": "v" } }])
878
+ return <ul>{rows().map(({ id, meta }) => <li key={id}>{meta["data-x"]}</li>)}</ul>
879
+ }
880
+ `)
881
+ const types = adapter.generate(ir).types!
882
+ expect(types).toContain('map[string]interface{}{"DataX": "v"}')
883
+ expect(types).not.toContain('"Data-x"')
884
+ })
885
+
886
+ test('snake_case keys keep their underscore in the generated field name (#2089 review)', () => {
887
+ // Underscores are VALID Go identifier characters, and `Foo_bar` is the
888
+ // field name this adapter has always generated for a snake_case key —
889
+ // `goFieldNameForKey` must NOT split on `_` (that would rename the
890
+ // generated field to `FooBar`, silently diverging from the `.Foo_bar`
891
+ // dot access the member emitter produces AND breaking consumers'
892
+ // hand-written constructors against previously generated types).
893
+ const adapter = new GoTemplateAdapter()
894
+ const ir = compileToIR(`
895
+ "use client"
896
+ import { createSignal } from "@barefootjs/client"
897
+
898
+ type Row = { foo_bar: string }
899
+ export function Rows() {
900
+ const [rows] = createSignal<Row[]>([{ foo_bar: "a" }])
901
+ return <ul>{rows().map((r) => <li key={r.foo_bar}>{r.foo_bar}</li>)}</ul>
902
+ }
903
+ `)
904
+ const out = adapter.generate(ir)
905
+ expect(out.types!).toContain('Foo_bar string `json:"foo_bar"`')
906
+ expect(out.template).toContain('.Foo_bar')
942
907
  })
943
908
 
944
909
  test('collapses whitespace-padded empty array literal to nil (#1675 review)', () => {
@@ -1022,10 +987,10 @@ function Box({ label }: { label: string }) {
1022
987
  `
1023
988
  const { types } = compileAndGenerate(source)
1024
989
  // The condition prop and the value reference resolve to `in.<Field>`,
1025
- // the static key is preserved. (The analyzer surfaces these
1026
- // destructured props as `unknown`/`interface{}`, so the condition
1027
- // routes through `bf.Truthy` for a faithful JS truthiness test.)
1028
- expect(types).toContain('if bf.Truthy(in.Label) {')
990
+ // the static key is preserved. `label` is a required primitive, so the
991
+ // analyzer resolves it to `string` (issue #2150) and the field-typed
992
+ // condition is a faithful `!= ""` truthiness test rather than reflection.
993
+ expect(types).toContain('if in.Label != "" {')
1029
994
  expect(types).toContain('return map[string]any{"data-label": in.Label}')
1030
995
  })
1031
996
 
@@ -3474,7 +3439,6 @@ export { C }
3474
3439
 
3475
3440
  // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
3476
3441
  const unsupportedSort: Array<[string, string]> = [
3477
- ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
3478
3442
  ['localeCompare locale/options arg', `items().toSorted((a, b) => a.name.localeCompare(b.name, "ja", { numeric: true })).map(x => <li key={x.name}>{x.name}</li>)`],
3479
3443
  ]
3480
3444
  for (const [label, chain] of unsupportedSort) {
@@ -3497,6 +3461,17 @@ export function C() {
3497
3461
  })
3498
3462
  }
3499
3463
 
3464
+ // #2090: a function-reference comparator (`.toSorted(myCmp)`, `myCmp` a
3465
+ // same-file const arrow) now resolves through the analyzer's scope
3466
+ // machinery and compiles — no BF021, and the sort lowers exactly like an
3467
+ // inline comparator (`bf_sort` / `bf_sort_eval` group in the template).
3468
+ test('sort follow-up (function-reference comparator): resolves and compiles without BF021', () => {
3469
+ const chain = `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`
3470
+ const result = emitLoop(chain, false)
3471
+ expect(result.errors).toEqual([])
3472
+ expect(result.template).toMatch(/bf_sort/)
3473
+ })
3474
+
3500
3475
  // End-to-end proof via `go run`: the `@client` form renders a
3501
3476
  // `<!--bf-client:sN-->` placeholder. The bare form is now caught at
3502
3477
  // build with BF101 and degrades to an empty, render-safe slot (no
@@ -3589,6 +3564,14 @@ export function C() {
3589
3564
 
3590
3565
  test('@client attribute inside a keyed .map() loop body is also deferred', () => {
3591
3566
  const adapter = new GoTemplateAdapter()
3567
+ // `days` is signal-backed (not a bare function-scope local) so the loop
3568
+ // array itself resolves to a real `.Days []Day` field — a plain
3569
+ // `const days: Day[] = [...]` array-of-objects local has no such backing
3570
+ // (the Go adapter only binds a STRING-derived function-scope const as a
3571
+ // field; #2087's `renderLoop` BF101 check now catches that case loudly
3572
+ // instead of emitting a template that references a nonexistent field).
3573
+ // This test's actual subject is the `/* @client */` attribute deferral
3574
+ // inside a loop body, which is orthogonal to how the array is sourced.
3592
3575
  const result = compileJSX(`
3593
3576
  "use client"
3594
3577
  import { createSignal } from "@barefootjs/client"
@@ -3596,10 +3579,10 @@ type Day = { n: number }
3596
3579
  export function C() {
3597
3580
  const [sel] = createSignal(0)
3598
3581
  const pred = (d: Day) => sel() === d.n
3599
- const days: Day[] = [{ n: 1 }, { n: 2 }]
3582
+ const [days] = createSignal<Day[]>([{ n: 1 }, { n: 2 }])
3600
3583
  return (
3601
3584
  <div>
3602
- {days.map((day: Day) => (
3585
+ {days().map((day: Day) => (
3603
3586
  <div key={day.n} data-x={/* @client */ pred(day)}>cell</div>
3604
3587
  ))}
3605
3588
  </div>
@@ -3608,9 +3591,269 @@ export function C() {
3608
3591
  `.trimStart(), 'test.tsx', { adapter })
3609
3592
  const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3610
3593
  expect(result.errors ?? []).toEqual([])
3611
- // The loop still renders (the array is a static const) but the deferred
3594
+ // The loop still renders (the array is signal-backed) but the deferred
3612
3595
  // attribute is absent from the emitted `<div>` cell.
3613
3596
  expect(template).toContain('range')
3614
3597
  expect(template).not.toContain('data-x')
3615
3598
  })
3616
3599
  })
3600
+
3601
+ // #2087: the shared `isSupported` gate (expression-parser.ts, `logical` case)
3602
+ // now admits an EMPTY object-literal fallback (`x ?? {}`) as `??`'s right
3603
+ // operand — needed for the chart UI component's `<ChartConfigContext.Provider
3604
+ // value={{ config: props.config ?? {} }}>`. Every other template adapter has
3605
+ // a native `{}` dict/hashref to emit for this shape; Go's `text/template` has
3606
+ // none, so `GoTemplateAdapter.objectLiteral` is the one place left to refuse
3607
+ // it — self-reporting BF101 (the shared gate no longer does, since it now
3608
+ // considers the expression supported) and falling back to the safe `""`
3609
+ // sentinel so the emitted action stays valid Go template syntax rather than
3610
+ // splicing the `[UNSUPPORTED: …]` marker into an `or`/`and` operand.
3611
+ describe('GoTemplateAdapter - #2087 empty object-literal `?? {}` fallback', () => {
3612
+ test('a value-position `?? {}` (outside a Provider) raises BF101 and still emits a syntactically valid template', () => {
3613
+ const adapter = new GoTemplateAdapter()
3614
+ const result = compileJSX(`
3615
+ "use client"
3616
+ type Props = { config?: Record<string, string> }
3617
+ export function C(props: Props) {
3618
+ return <div>{props.config ?? {}}</div>
3619
+ }
3620
+ `.trimStart(), 'test.tsx', { adapter })
3621
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
3622
+ const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3623
+ // The `{}` fallback lowers to the safe `""` Go string sentinel — never the
3624
+ // `[UNSUPPORTED: …]` marker text, which would break `text/template` parsing
3625
+ // once spliced as an `or` operand.
3626
+ expect(template).toContain('{{or .Config ""}}')
3627
+ expect(template).not.toContain('UNSUPPORTED')
3628
+ })
3629
+
3630
+ test('the chart shape — a Provider value member falling back to `?? {}` — compiles clean with no BF101', () => {
3631
+ const adapter = new GoTemplateAdapter()
3632
+ const result = compileJSX(`
3633
+ "use client"
3634
+ import { createContext, useContext } from "@barefootjs/client"
3635
+ type ChartConfig = Record<string, string>
3636
+ const Ctx = createContext<{ config: ChartConfig }>({ config: {} })
3637
+ function Consumer() {
3638
+ const ctx = useContext(Ctx)
3639
+ return <span>{ctx.config.label ?? "none"}</span>
3640
+ }
3641
+ type Props = { config?: ChartConfig }
3642
+ export function C(props: Props) {
3643
+ return (
3644
+ <div>
3645
+ <Ctx.Provider value={{ config: props.config ?? {} }}>
3646
+ <Consumer />
3647
+ </Ctx.Provider>
3648
+ </div>
3649
+ )
3650
+ }
3651
+ `.trimStart(), 'test.tsx', { adapter })
3652
+ // The object-literal provider value never reaches `objectLiteral` (it's
3653
+ // handled structurally by `extendProviderContext` /
3654
+ // `providerObjectValueToGoMap` instead), so no BF101 fires here — matching
3655
+ // the `ui/compat.lock.json` `chart` × `go-template` `ok: true` entry.
3656
+ expect(result.errors ?? []).toEqual([])
3657
+ })
3658
+
3659
+ test('#2087: an object-shaped createContext default lowers the consumer field to a map, and the Provider bakes a matching Go map into the child constructor call', () => {
3660
+ // Mirrors the real cross-component wiring `renderGoTemplateComponent` does
3661
+ // (`registerChildComponentShape` for every sibling before the parent's
3662
+ // types/template are generated) — a bare `compileJSX` multi-component
3663
+ // compile never calls that hook, so it can't show the Provider→consumer
3664
+ // binding this test pins.
3665
+ const source = `
3666
+ "use client"
3667
+ import { createContext, useContext } from "@barefootjs/client"
3668
+ type ChartConfig = Record<string, string>
3669
+ const Ctx = createContext<{ config: ChartConfig }>({ config: {} })
3670
+ function Consumer() {
3671
+ const ctx = useContext(Ctx)
3672
+ return <span>{ctx.config.label ?? "none"}</span>
3673
+ }
3674
+ type Props = { config?: ChartConfig }
3675
+ export function Root(props: Props) {
3676
+ return (
3677
+ <div>
3678
+ <Ctx.Provider value={{ config: props.config ?? {} }}>
3679
+ <Consumer />
3680
+ </Ctx.Provider>
3681
+ </div>
3682
+ )
3683
+ }
3684
+ `.trimStart()
3685
+
3686
+ const consumerCtx = analyzeComponent(source, 'test.tsx', 'Consumer')
3687
+ const consumerIR: ComponentIR = {
3688
+ version: '0.1',
3689
+ metadata: buildMetadata(consumerCtx),
3690
+ root: jsxToIR(consumerCtx)!,
3691
+ errors: [],
3692
+ }
3693
+ const rootCtx = analyzeComponent(source, 'test.tsx', 'Root')
3694
+ const rootIR: ComponentIR = {
3695
+ version: '0.1',
3696
+ metadata: buildMetadata(rootCtx),
3697
+ root: jsxToIR(rootCtx)!,
3698
+ errors: [],
3699
+ }
3700
+
3701
+ const adapter = new GoTemplateAdapter()
3702
+ // Order matters: the child's shape (incl. its context consumers) must be
3703
+ // known before the parent's types/constructor are generated.
3704
+ adapter.registerChildComponentShape(consumerIR)
3705
+ const consumerTypes = adapter.generateTypes(consumerIR)!
3706
+ const rootTypes = adapter.generateTypes(rootIR)!
3707
+ const consumerTemplate = adapter.generate(consumerIR, { skipScriptRegistration: true }).template
3708
+ const rootTemplate = adapter.generate(rootIR, { skipScriptRegistration: true }).template
3709
+
3710
+ // Consumer's `Ctx` field is a real map, not the scalar `string` fallback
3711
+ // every other non-literal default used to produce.
3712
+ expect(consumerTypes).toContain('Ctx map[string]interface{}')
3713
+
3714
+ // The Provider's `{ config: props.config ?? {} }` bakes into a
3715
+ // `map[string]interface{}` Go expression, normalising the parent's
3716
+ // `interface{}`-typed `Config` field through `bf.AsMap` — NOT a bare
3717
+ // `.(map[string]interface{})` type assertion, which would silently drop
3718
+ // a caller-supplied typed map like `map[string]string` (the natural Go
3719
+ // shape for a `Record<string, string>` prop — #2111 review) — and
3720
+ // falling back to a real empty map when the value is nil/absent.
3721
+ expect(rootTypes).toContain('ConsumerSlot0: NewConsumerProps(ConsumerInput{')
3722
+ expect(rootTypes).toContain('Ctx: map[string]interface{}{"config": func() map[string]interface{} { if m := bf.AsMap(in.Config); m != nil { return m }; return map[string]interface{}{} }()},')
3723
+
3724
+ // The consumer's `ctx.config.label ?? 'none'` reads through `bf_get`
3725
+ // (case-tolerant `getFieldValue`), not a plain `.Ctx.Config.Label` dot
3726
+ // chain — the latter would require an exact-cased struct/map field that
3727
+ // never exists and crashes real `go run` execution.
3728
+ expect(consumerTemplate).toContain('{{or (bf_get (bf_get .Ctx "config") "label") "none"}}')
3729
+ expect(consumerTemplate).not.toContain('.Ctx.Config')
3730
+ })
3731
+ })
3732
+
3733
+ describe('GoTemplateAdapter - #2130 loop with element-wrapped child component', () => {
3734
+ // The `.{Name}s` wrapper-slice retarget is ONLY valid when the loop body IS
3735
+ // a single component (`loop.childComponent`) — that's the sole condition
3736
+ // under which `findNestedComponents` generates the slice field. A component
3737
+ // merely nested inside an element item used to retarget the range at a
3738
+ // slice that never exists (`{{range … := .Tags}}` with no `Tags` field →
3739
+ // `can't evaluate field Tags in type *LoopChildProbeProps`, a 500 at
3740
+ // render time).
3741
+ test('element-wrapped child: range iterates the real collection, child renders via the parent slot instance', () => {
3742
+ const adapter = new GoTemplateAdapter()
3743
+ const ir = compileToIR(`
3744
+ 'use client'
3745
+ import { createSignal } from '@barefootjs/client'
3746
+ import { Tag } from '@ui/components/ui/tag'
3747
+
3748
+ interface Row { id: number; label: string }
3749
+
3750
+ export function LoopChildProbe(props: { rows?: Row[] }) {
3751
+ const [rows] = createSignal(props.rows ?? [])
3752
+ return (
3753
+ <ul>
3754
+ {rows().map(row => (
3755
+ <li key={row.id}><Tag>{row.label}</Tag></li>
3756
+ ))}
3757
+ </ul>
3758
+ )
3759
+ }
3760
+ `, adapter)
3761
+ const { template, types } = adapter.generate(ir)
3762
+
3763
+ // The range targets the generated collection field, never a
3764
+ // `.{ChildName}s` slice that no struct declares.
3765
+ expect(template).toContain(':= .Rows}}')
3766
+ expect(template).not.toContain('.Tags}}')
3767
+ expect(types).not.toContain('Tags []')
3768
+
3769
+ // The wrapped child calls through the parent's once-per-slot instance
3770
+ // (root context `$`), with per-item children via the loop-body define.
3771
+ expect(template).toContain('{{template "Tag" (bf_with_children $.TagSlot1')
3772
+ expect(types).toContain('TagSlot1 TagProps')
3773
+ expect(types).toContain('TagSlot1: NewTagProps(TagInput{')
3774
+ })
3775
+
3776
+ // End-to-end on real Go: the emitted template + structs must compile AND
3777
+ // execute (`go run`) — the original failure mode was a request-time 500
3778
+ // (`html/template` resolves struct fields at execute time, not at Go
3779
+ // compile time). Cross-adapter note: this stays a Go-package test rather
3780
+ // than a shared conformance fixture because the EP-family adapters
3781
+ // (Mojo / ERB / Jinja / ...) render loop-nested children with per-item
3782
+ // `ComponentName_<random>` scope ids by design (`comp.slotId &&
3783
+ // !this.inLoop` in their renderComponent), while Hono / Go emit the
3784
+ // parent-slot-derived id — same DOM, different id shape, so a single
3785
+ // `expectedHtml` cannot pin both families.
3786
+ test('element-wrapped child renders per-item content on real Go', async () => {
3787
+ const adapter = new GoTemplateAdapter()
3788
+ let html: string
3789
+ try {
3790
+ html = await renderGoTemplateComponent({
3791
+ source: `
3792
+ 'use client'
3793
+ import { createSignal } from '@barefootjs/client'
3794
+ import { Tag } from '@ui/components/ui/tag'
3795
+
3796
+ interface Row { id: number; label: string }
3797
+
3798
+ export function LoopChildProbe(props: { rows?: Row[] }) {
3799
+ const [rows] = createSignal(props.rows ?? [])
3800
+ return (
3801
+ <ul>
3802
+ {rows().map(row => (
3803
+ <li key={row.id}><Tag>{row.label}</Tag></li>
3804
+ ))}
3805
+ </ul>
3806
+ )
3807
+ }
3808
+ `.trimStart(),
3809
+ adapter,
3810
+ components: {
3811
+ '@ui/components/ui/tag': `
3812
+ export function Tag(props: { children?: any }) {
3813
+ return <span class="tag">{props.children}</span>
3814
+ }
3815
+ `.trimStart(),
3816
+ },
3817
+ props: { rows: [{ id: 1, label: 'one' }, { id: 2, label: 'two' }] },
3818
+ })
3819
+ } catch (err) {
3820
+ if (err instanceof GoNotAvailableError) return
3821
+ throw err
3822
+ }
3823
+ // One <li> per row, each wrapping a rendered Tag with the row's label —
3824
+ // not a template error and not an empty loop.
3825
+ expect(html).toContain('data-key="1"')
3826
+ expect(html).toContain('data-key="2"')
3827
+ expect(html).toContain('>one<')
3828
+ expect(html).toContain('>two<')
3829
+ expect(html).toContain('class="tag"')
3830
+ })
3831
+
3832
+ // Companion guard: the wrapper-slice path is untouched — a loop body that
3833
+ // IS a single component still iterates `.{Name}s` (the todo-app / data-table
3834
+ // machinery this fix must not regress).
3835
+ test('single-component body keeps iterating the wrapper slice', () => {
3836
+ const adapter = new GoTemplateAdapter()
3837
+ const ir = compileToIR(`
3838
+ 'use client'
3839
+ import { createSignal } from '@barefootjs/client'
3840
+ import { TodoItem } from '@ui/components/ui/todo-item'
3841
+
3842
+ interface Todo { id: number; text: string }
3843
+
3844
+ export function TodoList(props: { todos?: Todo[] }) {
3845
+ const [todos] = createSignal(props.todos ?? [])
3846
+ return (
3847
+ <ul>
3848
+ {todos().map(todo => (
3849
+ <TodoItem key={todo.id} text={todo.text} />
3850
+ ))}
3851
+ </ul>
3852
+ )
3853
+ }
3854
+ `, adapter)
3855
+ const { template, types } = adapter.generate(ir)
3856
+ expect(template).toContain(':= .TodoItems}}')
3857
+ expect(types).toContain('TodoItems []')
3858
+ })
3859
+ })