@barefootjs/go-template 0.17.0 → 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.
Files changed (39) hide show
  1. package/dist/adapter/expr/url-builder.d.ts.map +1 -1
  2. package/dist/adapter/go-template-adapter.d.ts +144 -14
  3. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  4. package/dist/adapter/index.js +449 -118
  5. package/dist/adapter/lib/compile-state.d.ts +27 -1
  6. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  7. package/dist/adapter/lib/go-emit.d.ts +9 -0
  8. package/dist/adapter/lib/go-emit.d.ts.map +1 -1
  9. package/dist/adapter/lib/go-naming.d.ts +23 -0
  10. package/dist/adapter/lib/go-naming.d.ts.map +1 -1
  11. package/dist/adapter/memo/memo-compute.d.ts +54 -5
  12. package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
  13. package/dist/adapter/memo/memo-type.d.ts +10 -0
  14. package/dist/adapter/memo/memo-type.d.ts.map +1 -1
  15. package/dist/adapter/type/type-codegen.d.ts +5 -1
  16. package/dist/adapter/type/type-codegen.d.ts.map +1 -1
  17. package/dist/adapter/value/parsed-literal-to-go.d.ts +13 -4
  18. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -1
  19. package/dist/build.js +449 -118
  20. package/dist/conformance-pins.d.ts +12 -0
  21. package/dist/conformance-pins.d.ts.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +468 -118
  25. package/package.json +3 -3
  26. package/src/__tests__/go-template-adapter.test.ts +652 -129
  27. package/src/__tests__/lowering-plugin.test.ts +56 -0
  28. package/src/adapter/expr/url-builder.ts +14 -3
  29. package/src/adapter/go-template-adapter.ts +637 -140
  30. package/src/adapter/lib/compile-state.ts +31 -0
  31. package/src/adapter/lib/go-emit.ts +20 -0
  32. package/src/adapter/lib/go-naming.ts +29 -0
  33. package/src/adapter/memo/memo-compute.ts +228 -18
  34. package/src/adapter/memo/memo-type.ts +19 -0
  35. package/src/adapter/type/type-codegen.ts +22 -3
  36. package/src/adapter/value/parsed-literal-to-go.ts +82 -9
  37. package/src/conformance-pins.ts +135 -0
  38. package/src/index.ts +1 -0
  39. package/src/test-render.ts +46 -8
@@ -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',
@@ -58,119 +63,45 @@ runAdapterConformanceTests({
58
63
  // a `SearchParams bf.SearchParams` binding (zero value → empty query → the
59
64
  // author's default). See #1922; Mojo / Xslate stay skipped pending their own
60
65
  // env-signal lowering + per-request Perl reader.
61
- skipJsx: [],
66
+ //
67
+ // `search-params-derived-filter` (#2075) is no longer skipped: a LIST-
68
+ // valued memo derived from the searchParams() env signal (the blog
69
+ // `visible` filter shape) now SSR-computes via `bf.FilterEval` — the
70
+ // memo's field type is `[]any` and its constructor init serializes the
71
+ // `.filter` predicate to the runtime evaluator. See the #2075 constructor
72
+ // pins below.
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.
62
87
  // Per-fixture build-time contracts for shapes the Go template
63
- // adapter intentionally refuses to lower. Lives here (not on the
64
- // shared fixtures) so adding a new adapter doesn't require touching
65
- // 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
66
91
  // refusal set against the canonical fixture corpus.
67
- expectedDiagnostics: {
68
- // `style-object-dynamic` / `style-3-signals` no longer pinned — a
69
- // `style={{ … }}` object literal now lowers to a CSS string with dynamic
70
- // values interpolated (`background-color:{{.Color}};padding:8px`) via
71
- // `tryLowerStyleObject` (#1322).
72
- // Sibling-imported child component inside a loop body: the adapter
73
- // emits `{{template "X" .}}` which only resolves if the user has
74
- // compiled the sibling file and registered the template on the
75
- // same instance. BF103 makes that requirement loud. (The barefoot
76
- // CLI passes `siblingTemplatesRegistered: true` so CLI builds
77
- // suppress the diagnostic see compileJSX `siblingTemplatesRegistered`.)
78
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
79
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
80
- // call it inside a keyed `.map`. Same BF103 surface as
81
- // `static-array-children` above — pinned at adapter level so the
82
- // shared-component corpus stays adapter-neutral.
83
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
84
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
85
- // Array-destructure loop param (`([k, v]) => ...`): Go's `{{range
86
- // $a, $b := ...}}` only supports single-name bindings, so the
87
- // adapter would otherwise emit invalid template syntax.
88
- 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
89
- // Same destructure shape with a child component body — fires both
90
- // BF103 (imported child in loop) and BF104 (destructure param).
91
- 'static-array-from-props-with-component': [
92
- { code: 'BF103', severity: 'error' },
93
- { code: 'BF104', severity: 'error' },
94
- ],
95
- // (`style-3-signals` graduated alongside `style-object-dynamic` — see note
96
- // above; the `style={{ … }}` object now lowers to a CSS string.)
97
- // #1244 stress catalog: tagged-template-literal callees
98
- // (`cn\`base \${tone()}\``) likewise can't lower into Go template
99
- // syntax — same BF101 refusal.
100
- 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
101
- // #1310: rest destructure in .map() callback. The object-rest shape read
102
- // via member access (`rest-destructure-object-in-map`) now lowers — each
103
- // binding resolves to a field on a synthetic `$__bf_item0` range var (the
104
- // reserved `__bf_item` name, depth-suffixed) and `rest.flag` →
105
- // `$__bf_item0.Flag` (`destructureBindingsSupportable`). The
106
- // other three stay refused: rest SPREAD (`{...rest}`) needs a residual
107
- // object, and array-index / nested paths (`[a, ...t]`, `{ cells: [h] }`)
108
- // need index/slice machinery Go's `{{range}}` can't express inline.
109
- 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
110
- 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
111
- 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
112
- // #1443: `[a, b].filter(Boolean).join(' ')` (registry Slot) now
113
- // lowers to `bf_join (bf_filter_truthy (bf_arr ...)) " "`. No
114
- // BF101 expected — pinned positively by the
115
- // `branch-local-filter-join-go` template-output test below.
116
- //
117
- // #1448 Tier A — JS Array / String methods that the Go template
118
- // adapter hasn't lowered yet. Each row drops once the
119
- // corresponding method PR lands. Hono / CSR pass these out of
120
- // the box (they evaluate JS at runtime) so the pin only applies
121
- // here.
122
- //
123
- // `array-includes` / `string-includes` no longer pinned — both
124
- // shapes lower via the shared `array-method` IR + the polymorphic
125
- // `bf_includes` runtime helper that dispatches on
126
- // `reflect.Kind()` (slice/array → element search, string →
127
- // substring search). The condition-position lowering picks up
128
- // the same emit through the `array-method` arm of
129
- // `renderConditionExpr` (#1448 Tier A first PR).
130
- //
131
- // Remaining fixtures land at expression position and surface BF101
132
- // via `convertExpressionToGo`. Distinct codes for the two paths is
133
- // pre-existing adapter behaviour, not something this catalog
134
- // should paper over — pinned literally here.
135
- // `array-indexOf` / `array-lastIndexOf` no longer pinned —
136
- // value-equality `bf_index_of` / `bf_last_index_of` Go runtime
137
- // helpers handle the shape (#1448 Tier A second PR).
138
- // `array-at` no longer pinned — the pre-existing `bf_at` runtime
139
- // helper now lowers `.at(i)` (#1448 Tier A third PR).
140
- // `array-concat` no longer pinned — the new `bf_concat` runtime
141
- // helper merges two arrays into a single `[]any` (#1448 Tier A
142
- // fourth PR).
143
- // `array-slice` no longer pinned — the new `bf_slice` runtime
144
- // helper carves out a sub-range with JS-compat clamping
145
- // (#1448 Tier A fifth PR).
146
- // `array-reverse` / `array-toReversed` no longer pinned —
147
- // both share the `bf_reverse` helper since SSR templates
148
- // render a snapshot and the JS mutate-vs-new distinction has
149
- // no template-level meaning (#1448 Tier A sixth PR).
150
- // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
151
- // pre-existing `bf_lower` / `bf_upper` runtime helpers wire to
152
- // the JS method names at the adapter layer (#1448 Tier A
153
- // seventh + eighth PRs).
154
- // `string-trim` no longer pinned — pre-existing `bf_trim`
155
- // (wraps `strings.TrimSpace`) handles the strip (#1448 Tier A
156
- // ninth PR, closing out Tier A).
157
- },
158
- // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
159
- // via `GoTemplateAdapter.templatePrimitives` (#1188). The two
160
- // remaining cases stay skipped because the V1 registry is
161
- // identifier-path-only and explicit:
162
- // - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
163
- // the registry and can't be rendered server-side without
164
- // user-supplied template-fn mappings.
165
- // - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
166
- // too, same reason.
167
- // Adding new entries to `templatePrimitives` should narrow this
168
- // skip set; see `templatePrimitives` declaration in
169
- // `go-template-adapter.ts` for the full V1 surface.
170
- skipTemplatePrimitives: new Set([
171
- TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
172
- TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
173
- ]),
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").
174
105
  skipMarkerConformance: new Set<string>([
175
106
  // Same as Hono / Mojo: `/* @client */` markers on TodoApp's keyed
176
107
  // `.map` intentionally elide a slot id from the SSR template that
@@ -899,10 +830,18 @@ export function Dyn() {
899
830
  expect(types).toContain('Items: nil,')
900
831
  })
901
832
 
902
- test('keeps nil for object keys that are not Go-identifier-safe (#1675 review)', () => {
903
- // A quoted key like "data-id" capitalises to `Data-id`, which is not a
904
- // valid Go struct field identifier baking it would emit a keyed struct
905
- // 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.
906
845
  const adapter = new GoTemplateAdapter()
907
846
  const ir = compileToIR(`
908
847
  "use client"
@@ -915,7 +854,56 @@ export function Rows() {
915
854
  }
916
855
  `)
917
856
  const types = adapter.generate(ir).types!
918
- 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')
919
907
  })
920
908
 
921
909
  test('collapses whitespace-padded empty array literal to nil (#1675 review)', () => {
@@ -999,10 +987,10 @@ function Box({ label }: { label: string }) {
999
987
  `
1000
988
  const { types } = compileAndGenerate(source)
1001
989
  // The condition prop and the value reference resolve to `in.<Field>`,
1002
- // the static key is preserved. (The analyzer surfaces these
1003
- // destructured props as `unknown`/`interface{}`, so the condition
1004
- // routes through `bf.Truthy` for a faithful JS truthiness test.)
1005
- 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 != "" {')
1006
994
  expect(types).toContain('return map[string]any{"data-label": in.Label}')
1007
995
  })
1008
996
 
@@ -1830,6 +1818,31 @@ export function TodoList() {
1830
1818
  const bf101 = adapter.errors.filter(e => e.code === 'BF101')
1831
1819
  expect(bf101).toEqual([])
1832
1820
  })
1821
+
1822
+ test('nested-callback refusal keeps the emitted template syntactically valid (#2038)', () => {
1823
+ // The BF101 contract itself is pinned at the shared conformance layer
1824
+ // (`filter-nested-callback-predicate` / `filter-nested-find-predicate`
1825
+ // expectedDiagnostics; the `/* @client */` suppression twin renders
1826
+ // clean). This test pins the GO-SPECIFIC half: the refusal must emit
1827
+ // the `false` sentinel — not a half-rendered predicate like `.Some` —
1828
+ // so `text/template` parsing doesn't cascade into secondary errors.
1829
+ const adapter = new GoTemplateAdapter()
1830
+ const result = compileAndGenerate(`
1831
+ "use client"
1832
+ import { createSignal } from "@barefootjs/client"
1833
+
1834
+ type Item = { id: number }
1835
+
1836
+ export function Picker() {
1837
+ const [items, setItems] = createSignal<Item[]>([])
1838
+ const [picked, setPicked] = createSignal<Item[]>([])
1839
+ return <ul>{items().filter(t => !picked().some(p => p.id === t.id)).map(t => <li key={t.id}>{t.id}</li>)}</ul>
1840
+ }
1841
+ `, adapter)
1842
+ expect(adapter.errors.some(e => e.code === 'BF101')).toBe(true)
1843
+ expect(result.template).not.toContain('.Some')
1844
+ expect(result.template).toContain('{{if false}}')
1845
+ })
1833
1846
  })
1834
1847
 
1835
1848
  describe('find/findIndex - adapter specific', () => {
@@ -2991,6 +3004,238 @@ export { C }
2991
3004
  })
2992
3005
  })
2993
3006
 
3007
+ describe('GoTemplateAdapter - #2075 searchParams()-derived memo constructor', () => {
3008
+ // A memo derived from the createSearchParams() env signal SSR-computes in
3009
+ // the generated constructor from the canonical `in.SearchParams` reader —
3010
+ // including under a local alias. `Get` returns "" for an absent key, so
3011
+ // the `??` default fires on "" — the same documented `?? → or` divergence
3012
+ // as the template-position lowering.
3013
+ test('computes an aliased `?? default` derived memo from in.SearchParams', () => {
3014
+ const adapter = new GoTemplateAdapter()
3015
+ const ir = compileToIR(`
3016
+ 'use client'
3017
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3018
+ export function SortStatus() {
3019
+ const [sp] = createSearchParams()
3020
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
3021
+ return <p>sort: {sort()}</p>
3022
+ }
3023
+ `, adapter)
3024
+ const { types } = adapter.generate(ir)
3025
+ expect(types).toContain(
3026
+ 'Sort: func() string { if v := in.SearchParams.Get("sort"); v != "" { return v }; return "date" }(),',
3027
+ )
3028
+ })
3029
+
3030
+ test('computes a bare env read memo from in.SearchParams', () => {
3031
+ const adapter = new GoTemplateAdapter()
3032
+ const ir = compileToIR(`
3033
+ 'use client'
3034
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3035
+ export function QueryEcho() {
3036
+ const [searchParams] = createSearchParams()
3037
+ const q = createMemo(() => searchParams().get('q'))
3038
+ return <p>{q()}</p>
3039
+ }
3040
+ `, adapter)
3041
+ const { types } = adapter.generate(ir)
3042
+ expect(types).toContain('Q: in.SearchParams.Get("q"),')
3043
+ })
3044
+
3045
+ // #2075 residual: a LIST-valued memo (`.filter(arrow)` over a props array)
3046
+ // chained off the env-derived `tag` memo — the `search-params-derived-filter`
3047
+ // shared fixture's shape. The field is `[]any` (not the misclassified `bool`
3048
+ // the `/=>\s*!/` heuristic used to produce from the predicate's `!tag()`),
3049
+ // and the constructor seeds it via `bf.FilterEval`, whose serialized
3050
+ // predicate JSON is embedded (escaped) in the Go string literal.
3051
+ test('computes a list-filter memo chained off a searchParams()-derived memo via bf.FilterEval', () => {
3052
+ const adapter = new GoTemplateAdapter()
3053
+ const ir = compileToIR(`
3054
+ 'use client'
3055
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3056
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
3057
+ const [searchParams] = createSearchParams()
3058
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
3059
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
3060
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3061
+ }
3062
+ `, adapter)
3063
+ const { types } = adapter.generate(ir)
3064
+ expect(types).toContain('Visible []any')
3065
+ expect(types).toContain('Visible: bf.FilterEval(in.Items,')
3066
+ // The predicate JSON is escaped inside a Go double-quoted string literal,
3067
+ // so assert on the substring that survives escaping (backslash-escaped
3068
+ // quotes around the JSON keys/values).
3069
+ expect(types).toContain('\\"method\\":\\"includes\\"')
3070
+ })
3071
+
3072
+ // #2077 review finding 3: `tag` (an earlier sibling the `visible` filter's
3073
+ // predicate closes over) used to be recomputed verbatim a second time
3074
+ // inline in `visible`'s `bf.FilterEval` env map — the same
3075
+ // `in.SearchParams.Get("tag")` expression emitted twice in the constructor.
3076
+ // It's now hoisted to a shared local before the `return`, referenced by
3077
+ // BOTH the `Tag:` field and the env-map entry.
3078
+ test('hoists the earlier sibling memo instead of duplicating its expression', () => {
3079
+ const adapter = new GoTemplateAdapter()
3080
+ const ir = compileToIR(`
3081
+ 'use client'
3082
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3083
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
3084
+ const [searchParams] = createSearchParams()
3085
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
3086
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
3087
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3088
+ }
3089
+ `, adapter)
3090
+ const { types } = adapter.generate(ir)
3091
+ expect(types).toContain('var memoTag string =')
3092
+ expect(types).toContain('Tag: memoTag,')
3093
+ expect(types).toContain('"tag": memoTag')
3094
+ // The multi-token `Get("tag")` expression appears exactly once in the
3095
+ // whole constructor — the hoisted local, not a second inline copy.
3096
+ expect(types.split('in.SearchParams.Get("tag")').length).toBe(2)
3097
+ })
3098
+ })
3099
+
3100
+ describe('GoTemplateAdapter - env-derived memo sourced from the SSR seed plan (Package I)', () => {
3101
+ // `CompileState.ssrSeedPlan` (and `envSignalReadersByLocal` derived from it)
3102
+ // is now the single authority for env-reader recognition. `compileToIR`
3103
+ // already JSON-round-trips the IR (mirrors the adapter conformance
3104
+ // harness), so each `env-reader` step's `reader.methods` — a ReadonlySet —
3105
+ // arrives serialized to `{}`; `primeCompileState` re-resolves a live reader
3106
+ // via `envSignalReaderFor(step.reader.key)` before trusting `.methods`.
3107
+ // Pins that the env-derived memo constructor still SSR-computes through
3108
+ // that re-resolution instead of silently falling back to a zero value.
3109
+ test('computes an env-derived memo constructor through the JSON-round-tripped ssrSeedPlan', () => {
3110
+ const adapter = new GoTemplateAdapter()
3111
+ const ir = compileToIR(`
3112
+ 'use client'
3113
+ import { createMemo, createSearchParams } from '@barefootjs/client'
3114
+ export function QueryEcho() {
3115
+ const [searchParams] = createSearchParams()
3116
+ const q = createMemo(() => searchParams().get('q'))
3117
+ return <p>{q()}</p>
3118
+ }
3119
+ `, adapter)
3120
+ const { types } = adapter.generate(ir)
3121
+ expect(types).toContain('Q: in.SearchParams.Get("q"),')
3122
+ })
3123
+ })
3124
+
3125
+ describe('GoTemplateAdapter - #2077 review sibling-memo recursion guard', () => {
3126
+ // Finding 1: mutually-referencing memos (each resolving the other as a
3127
+ // filter-arm free var, or as a bare-getter ternary condition) used to
3128
+ // recurse without bound and blow the compiler's call stack
3129
+ // (`RangeError: Maximum call stack size exceeded`, verified by probe before
3130
+ // the fix). The cycle/declaration-order guard makes both directions fall
3131
+ // back to the field's Go zero value instead of throwing.
3132
+ test('mutual filter-arm memos compile without throwing and fall back to zero values', () => {
3133
+ const adapter = new GoTemplateAdapter()
3134
+ const ir = compileToIR(`
3135
+ 'use client'
3136
+ import { createMemo } from '@barefootjs/client'
3137
+ export function C(props: { items: { title: string }[] }) {
3138
+ const a = createMemo(() => props.items.filter((x) => Boolean(b())))
3139
+ const b = createMemo(() => props.items.filter((x) => Boolean(a())))
3140
+ return <ul>{a().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3141
+ }
3142
+ `, adapter)
3143
+ let types = ''
3144
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3145
+ expect(types).toContain('A []any')
3146
+ expect(types).toContain('B []any')
3147
+ // Neither memo can resolve the OTHER as a free var (order rules out both
3148
+ // directions at once — `a` can't see `b` because `b` is declared later,
3149
+ // and vice versa), so `a`'s own computation falls back to the `[]any`
3150
+ // zero value (hoisted since `b` references it), and `b`'s free-var
3151
+ // resolution of `a` reuses that hoisted zero-valued local.
3152
+ expect(types).toContain('var memoA []any = nil')
3153
+ expect(types).toContain('A: memoA,')
3154
+ expect(types).toContain('B: bf.FilterEval(in.Items,')
3155
+ expect(types).toContain('map[string]any{"a": memoA}')
3156
+ })
3157
+
3158
+ test('mutual ternary memos (bare-getter condition) compile without throwing and fall back to zero values', () => {
3159
+ const adapter = new GoTemplateAdapter()
3160
+ const ir = compileToIR(`
3161
+ 'use client'
3162
+ import { createMemo } from '@barefootjs/client'
3163
+ export function C() {
3164
+ const a = createMemo(() => (b() ? 'A' : 'Z'))
3165
+ const b = createMemo(() => (a() ? 'B' : 'Y'))
3166
+ return <p>{a()}{b()}</p>
3167
+ }
3168
+ `, adapter)
3169
+ let types = ''
3170
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3171
+ expect(types).toContain('A: "",')
3172
+ expect(types).toContain('B: "",')
3173
+ })
3174
+
3175
+ test('self-referencing filter-arm memo compiles without throwing', () => {
3176
+ const adapter = new GoTemplateAdapter()
3177
+ const ir = compileToIR(`
3178
+ 'use client'
3179
+ import { createMemo } from '@barefootjs/client'
3180
+ export function C(props: { items: { title: string }[] }) {
3181
+ const a = createMemo(() => props.items.filter((x) => Boolean(a())))
3182
+ return <ul>{a().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
3183
+ }
3184
+ `, adapter)
3185
+ let types = ''
3186
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3187
+ expect(types).toContain('A: nil,')
3188
+ })
3189
+
3190
+ test('self-referencing ternary memo compiles without throwing', () => {
3191
+ const adapter = new GoTemplateAdapter()
3192
+ const ir = compileToIR(`
3193
+ 'use client'
3194
+ import { createMemo } from '@barefootjs/client'
3195
+ export function C() {
3196
+ const a = createMemo(() => (a() ? 'A' : 'Z'))
3197
+ return <p>{a()}</p>
3198
+ }
3199
+ `, adapter)
3200
+ let types = ''
3201
+ expect(() => { types = adapter.generate(ir).types }).not.toThrow()
3202
+ expect(types).toContain('A: "",')
3203
+ })
3204
+ })
3205
+
3206
+ describe('GoTemplateAdapter - #2073 value-producing .map(cb)', () => {
3207
+ function emitMap(expr: string): string {
3208
+ const adapter = new GoTemplateAdapter()
3209
+ const ir = compileToIR(`
3210
+ function C({ tags, users }: { tags: string[]; users: { name: string }[] }) {
3211
+ return <div>{${expr}}</div>
3212
+ }
3213
+ export { C }
3214
+ `, adapter)
3215
+ return adapter.generate(ir).template ?? ''
3216
+ }
3217
+
3218
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
3219
+ // projection, not JSX) lowers through the evaluator — `bf_map_eval` projects
3220
+ // each element (no flatten) and composes through `bf_join`. (Like the
3221
+ // flatMap pins below, the projection JSON's quotes are backslash-escaped in
3222
+ // the Go-template string; the JSON itself is verified by the render
3223
+ // conformance fixtures + the runtime TestMapEval.)
3224
+ test('.map(t => `#${t}`).join(" ") emits bf_map_eval composed into bf_join', () => {
3225
+ const t = emitMap("tags.map(t => `#${t}`).join(' ')")
3226
+ expect(t).toContain('bf_join (bf_map_eval .Tags')
3227
+ })
3228
+
3229
+ test('.map(u => u.name) emits bf_map_eval with the receiver', () => {
3230
+ const t = emitMap("users.map(u => u.name).join(', ')")
3231
+ expect(t).toContain('bf_map_eval .Users')
3232
+ })
3233
+
3234
+ // The function-reference `.map(format)` BF101 refusal is now covered
3235
+ // cross-adapter by the `array-map-function-reference` shared fixture's
3236
+ // `expectedDiagnostics` entry above.
3237
+ })
3238
+
2994
3239
  describe('GoTemplateAdapter - #1448 Tier C .flatMap(field projection)', () => {
2995
3240
  function emitFlatMap(expr: string): string {
2996
3241
  const adapter = new GoTemplateAdapter()
@@ -3194,7 +3439,6 @@ export { C }
3194
3439
 
3195
3440
  // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
3196
3441
  const unsupportedSort: Array<[string, string]> = [
3197
- ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
3198
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>)`],
3199
3443
  ]
3200
3444
  for (const [label, chain] of unsupportedSort) {
@@ -3217,6 +3461,17 @@ export function C() {
3217
3461
  })
3218
3462
  }
3219
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
+
3220
3475
  // End-to-end proof via `go run`: the `@client` form renders a
3221
3476
  // `<!--bf-client:sN-->` placeholder. The bare form is now caught at
3222
3477
  // build with BF101 and degrades to an empty, render-safe slot (no
@@ -3309,6 +3564,14 @@ export function C() {
3309
3564
 
3310
3565
  test('@client attribute inside a keyed .map() loop body is also deferred', () => {
3311
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.
3312
3575
  const result = compileJSX(`
3313
3576
  "use client"
3314
3577
  import { createSignal } from "@barefootjs/client"
@@ -3316,10 +3579,10 @@ type Day = { n: number }
3316
3579
  export function C() {
3317
3580
  const [sel] = createSignal(0)
3318
3581
  const pred = (d: Day) => sel() === d.n
3319
- const days: Day[] = [{ n: 1 }, { n: 2 }]
3582
+ const [days] = createSignal<Day[]>([{ n: 1 }, { n: 2 }])
3320
3583
  return (
3321
3584
  <div>
3322
- {days.map((day: Day) => (
3585
+ {days().map((day: Day) => (
3323
3586
  <div key={day.n} data-x={/* @client */ pred(day)}>cell</div>
3324
3587
  ))}
3325
3588
  </div>
@@ -3328,9 +3591,269 @@ export function C() {
3328
3591
  `.trimStart(), 'test.tsx', { adapter })
3329
3592
  const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3330
3593
  expect(result.errors ?? []).toEqual([])
3331
- // 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
3332
3595
  // attribute is absent from the emitted `<div>` cell.
3333
3596
  expect(template).toContain('range')
3334
3597
  expect(template).not.toContain('data-x')
3335
3598
  })
3336
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
+ })