@barefootjs/go-template 0.18.5 → 0.19.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 (36) hide show
  1. package/dist/adapter/analysis/static-child-loop-bake.d.ts +61 -0
  2. package/dist/adapter/analysis/static-child-loop-bake.d.ts.map +1 -0
  3. package/dist/adapter/analysis/static-element-loop-bake.d.ts +83 -0
  4. package/dist/adapter/analysis/static-element-loop-bake.d.ts.map +1 -0
  5. package/dist/adapter/emit-context.d.ts +11 -5
  6. package/dist/adapter/emit-context.d.ts.map +1 -1
  7. package/dist/adapter/go-template-adapter.d.ts +153 -6
  8. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  9. package/dist/adapter/index.js +514 -53
  10. package/dist/adapter/lib/compile-state.d.ts +24 -0
  11. package/dist/adapter/lib/compile-state.d.ts.map +1 -1
  12. package/dist/adapter/lib/types.d.ts +9 -0
  13. package/dist/adapter/lib/types.d.ts.map +1 -1
  14. package/dist/adapter/props/prop-classes.d.ts +28 -9
  15. package/dist/adapter/props/prop-classes.d.ts.map +1 -1
  16. package/dist/adapter/props/prop-types.d.ts +45 -0
  17. package/dist/adapter/props/prop-types.d.ts.map +1 -1
  18. package/dist/build.js +514 -53
  19. package/dist/conformance-pins.d.ts.map +1 -1
  20. package/dist/index.js +516 -62
  21. package/dist/render-divergences.d.ts.map +1 -1
  22. package/dist/test-render.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/src/__tests__/go-template-adapter.test.ts +876 -21
  25. package/src/adapter/analysis/static-child-loop-bake.ts +119 -0
  26. package/src/adapter/analysis/static-element-loop-bake.ts +211 -0
  27. package/src/adapter/emit-context.ts +14 -5
  28. package/src/adapter/go-template-adapter.ts +620 -45
  29. package/src/adapter/lib/compile-state.ts +27 -0
  30. package/src/adapter/lib/types.ts +9 -0
  31. package/src/adapter/props/prop-classes.ts +34 -9
  32. package/src/adapter/props/prop-types.ts +178 -1
  33. package/src/adapter/value/value-lowering.ts +1 -1
  34. package/src/conformance-pins.ts +30 -31
  35. package/src/render-divergences.ts +12 -0
  36. package/src/test-render.ts +127 -10
@@ -119,6 +119,37 @@ runAdapterConformanceTests({
119
119
  // (#1897) data-table no longer skipped — loop body children + wrapper
120
120
  // struct + block-body memo baking render correctly on Go.
121
121
  ]),
122
+ skipDataPoints: new Set<string>([
123
+ // #2255 — Go `len` counts bytes; JS counts UTF-16 code units
124
+ // ('日本語' is 3 in JS, 9 here; '👍' is 2 in JS, 4 here).
125
+ 'string-length-text:multibyte',
126
+ 'string-length-text:astral',
127
+ // #2256 — the nullish gate covers only BARE nillable prop refs; a
128
+ // member-access left operand (`user?.name ?? '…'`) still lowers to
129
+ // the truthiness `or`, so a present-but-empty member falls back.
130
+ 'optional-chaining-prop:empty-name',
131
+ // #2260 — controlled/derived boolean props: the SSR seed evaluates
132
+ // only the static fallback of `props.X ?? internal()` chains, so a
133
+ // caller-supplied true never reaches aria-*/data-state.
134
+ 'toggle:gen:defaultPressed:true',
135
+ 'toggle:gen:pressed:true',
136
+ 'switch:gen:defaultChecked:true',
137
+ 'switch:gen:checked:true',
138
+ 'checkbox:gen:defaultChecked:true',
139
+ 'checkbox:gen:checked:true',
140
+ // #2261 — invalid dynamic CSS value: html/template emits the
141
+ // ZgotmplZ sentinel where the oracle drops the property.
142
+ 'style-object-dynamic:gen:color:markup',
143
+ // #2262 — dynamic `.flat` depth 0/negative renders empty instead of
144
+ // the contract's shallow copy.
145
+ 'array-flat-dynamic-depth:gen:depth:zero',
146
+ 'array-flat-dynamic-depth:gen:depth:negative',
147
+ // #2266 — asChild=true with plain-text children: the Slot define
148
+ // dereferences `.Children.Props.Children`, which hard-errors on a
149
+ // string child (`can't evaluate field Props in type interface {}`).
150
+ 'button:gen:asChild:true',
151
+ 'kbd:gen:asChild:true',
152
+ ]),
122
153
  onRenderError: (err, id) => {
123
154
  if (err instanceof GoNotAvailableError) {
124
155
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -506,9 +537,11 @@ export function Counter(props: { initial?: number }) {
506
537
  expect(result.types).toBeDefined()
507
538
  const types = result.types!
508
539
 
509
- // Hoist: `initial := in.Initial` + zero-check + fallback assign.
510
- expect(types).toContain('initial := in.Initial')
511
- expect(types).toMatch(/if initial == 0 \{\s*initial = 99\s*\}/)
540
+ // The fallback applies only when the prop is ABSENT: an explicit
541
+ // `Initial: 0` is honoured (JS `0 ?? 99` is `0`, #2248).
542
+ expect(types).toContain('Initial interface{}')
543
+ expect(types).toContain('var initial int = 99')
544
+ expect(types).toMatch(/if in\.Initial != nil \{\s*initial = bf\.ToInt\(in\.Initial\)\s*\}/)
512
545
 
513
546
  // Prop, signal, and memo all reference the hoisted variable.
514
547
  expect(types).toContain('Initial: initial,')
@@ -553,8 +586,11 @@ export function Label(props: { label?: string }) {
553
586
  `)
554
587
  const result = adapter.generate(ir)
555
588
  const types = result.types!
556
- expect(types).toContain('label := in.Label')
557
- expect(types).toMatch(/if label == ""\s*\{\s*label = "Default"\s*\}/)
589
+ // An explicit `Label: ""` input stays empty (JS `'' ?? 'Default'` is
590
+ // `''`, #2248).
591
+ expect(types).toContain('Label interface{}')
592
+ expect(types).toContain('var label string = "Default"')
593
+ expect(types).toMatch(/if in\.Label != nil \{\s*label = in\.Label\.\(string\)\s*\}/)
558
594
  expect(types).toContain('Label: label,')
559
595
  // Signal name `text` differs from prop name `label`, so the
560
596
  // signal field gets its own entry that resolves through the
@@ -562,12 +598,11 @@ export function Label(props: { label?: string }) {
562
598
  expect(types).toContain('Text: label,')
563
599
  })
564
600
 
565
- test('hoists `props.X ?? true` against the bool zero (#1423 review)', () => {
566
- // Bool-true falls through the same hoist path as int / string
567
- // the asymmetry is documented (caller can't thread "explicit
568
- // false" through because Go's bool zero IS false), but emitting
569
- // a hoisted local matches the int case's shape so a derived
570
- // memo can inherit it.
601
+ test('hoists `props.X ?? true` against nil (#1423 review, #2248)', () => {
602
+ // Bool-true falls through the same hoist path as int / string. The
603
+ // interface{} field is nil only when the caller omitted the prop, so
604
+ // an explicit `Checked: false` survives (JS `false ?? true` is
605
+ // `false`).
571
606
  const adapter = new GoTemplateAdapter()
572
607
  const ir = compileToIR(`
573
608
  "use client"
@@ -580,12 +615,124 @@ export function Check(props: { checked?: boolean }) {
580
615
  `)
581
616
  const result = adapter.generate(ir)
582
617
  const types = result.types!
583
- expect(types).toContain('checked := in.Checked')
584
- expect(types).toMatch(/if checked == false\s*\{\s*checked = true\s*\}/)
618
+ expect(types).toContain('Checked interface{}')
619
+ expect(types).toContain('var checked bool = true')
620
+ expect(types).toMatch(/if in\.Checked != nil \{\s*checked = in\.Checked\.\(bool\)\s*\}/)
585
621
  expect(types).toContain('Checked: checked,')
586
622
  expect(types).toContain('C: checked,')
587
623
  })
588
624
 
625
+ test('hoists the DESTRUCTURED `x ?? N` seed via signal.parsed (#2259)', () => {
626
+ // Destructured components have no `propsObjectName`, so the seed's
627
+ // prop reference is a bare identifier — matched structurally on the
628
+ // signal's ParsedExpr, then routed through the same nullish flip +
629
+ // hoisted-var machinery as `props.size ?? 1` (#2248/#2252 parity).
630
+ const adapter = new GoTemplateAdapter()
631
+ const ir = compileToIR(`
632
+ "use client"
633
+ import { createSignal } from "@barefootjs/client"
634
+
635
+ export function Counter({ size }: { size?: number }) {
636
+ const [count, setCount] = createSignal(size ?? 1)
637
+ return <div>{count()}</div>
638
+ }
639
+ `)
640
+ const result = adapter.generate(ir)
641
+ const types = result.types!
642
+ expect(types).toContain('Size interface{}')
643
+ expect(types).toContain('var size int = 1')
644
+ expect(types).toMatch(/if in\.Size != nil \{\s*size = bf\.ToInt\(in\.Size\)\s*\}/)
645
+ expect(types).toContain('Size: size,')
646
+ expect(types).toContain('Count: size,')
647
+ })
648
+
649
+ test('destructured `x ?? 0` stays concrete and seeds the prop directly (#2259)', () => {
650
+ // A zero-equivalent fallback earns no nillable flip (nil and the Go
651
+ // zero value land on the same output), so the field keeps its scalar
652
+ // type and the signal seeds straight off the input — NOT the literal
653
+ // `0` the pre-#2259 lowering emitted for the unrecognized identifier
654
+ // form.
655
+ const adapter = new GoTemplateAdapter()
656
+ const ir = compileToIR(`
657
+ "use client"
658
+ import { createSignal } from "@barefootjs/client"
659
+
660
+ export function Counter({ size }: { size?: number }) {
661
+ const [count, setCount] = createSignal(size ?? 0)
662
+ return <div>{count()}</div>
663
+ }
664
+ `)
665
+ const result = adapter.generate(ir)
666
+ const types = result.types!
667
+ expect(types).toContain('Size int')
668
+ expect(types).not.toContain('Size interface{}')
669
+ expect(types).toContain('Size: in.Size,')
670
+ expect(types).toContain('Count: in.Size,')
671
+ })
672
+
673
+ test('negative fallback (`?? -1`) hoists in both prop styles (#2259 review)', () => {
674
+ // `-1` parses as unary minus around a number literal, not a literal —
675
+ // the structural seed match must reconstruct the sign, in the
676
+ // props-object form (where it preempts the regex path) and the
677
+ // destructured form alike.
678
+ for (const [params, ref] of [
679
+ ['props: { size?: number }', 'props.size'],
680
+ ['{ size }: { size?: number }', 'size'],
681
+ ]) {
682
+ const adapter = new GoTemplateAdapter()
683
+ const ir = compileToIR(`
684
+ "use client"
685
+ import { createSignal } from "@barefootjs/client"
686
+
687
+ export function Counter(${params}) {
688
+ const [count, setCount] = createSignal(${ref} ?? -1)
689
+ return <div>{count()}</div>
690
+ }
691
+ `, adapter)
692
+ const types = adapter.generate(ir).types!
693
+ expect(types).toContain('Size interface{}')
694
+ expect(types).toContain('var size int = -1')
695
+ expect(types).toContain('Count: size,')
696
+ }
697
+ })
698
+
699
+ test('string fallback with quotes survives the structural match unmangled (#2259 review)', () => {
700
+ const adapter = new GoTemplateAdapter()
701
+ const ir = compileToIR(`
702
+ "use client"
703
+ import { createSignal } from "@barefootjs/client"
704
+
705
+ export function Label({ label }: { label?: string }) {
706
+ const [text, setText] = createSignal(label ?? 'say "hi"')
707
+ return <div>{text()}</div>
708
+ }
709
+ `, adapter)
710
+ const types = adapter.generate(ir).types!
711
+ expect(types).toContain('var label string = "say \\"hi\\""')
712
+ expect(types).not.toContain('\\\\')
713
+ })
714
+
715
+ test('destructure DEFAULT keeps the concrete-typed applyGoFallback baking (#2259)', () => {
716
+ // `{ size = 5 }` never sees a nullish binding in JS (the default
717
+ // already applied), so it must stay excluded from the nillable flip
718
+ // and keep the zero-check baking on the concrete field.
719
+ const adapter = new GoTemplateAdapter()
720
+ const ir = compileToIR(`
721
+ "use client"
722
+ import { createSignal } from "@barefootjs/client"
723
+
724
+ export function Counter({ size = 5 }: { size?: number }) {
725
+ const [count, setCount] = createSignal(size)
726
+ return <div>{count()}</div>
727
+ }
728
+ `)
729
+ const result = adapter.generate(ir)
730
+ const types = result.types!
731
+ expect(types).toContain('Size int')
732
+ expect(types).not.toContain('Size interface{}')
733
+ expect(types).toContain('Size: func() int { if in.Size == 0 { return 5 }; return in.Size }(),')
734
+ })
735
+
589
736
  test('skips hoist for zero-equivalent string and float fallbacks (#1423 review)', () => {
590
737
  // The skip predicate compares the Go fallback against the
591
738
  // type's zero literal — covers `?? ''` (string) and `?? 0.0`
@@ -979,10 +1126,13 @@ function Box({ describedBy }: { describedBy?: string }) {
979
1126
  // Template emission is unchanged from the proven {...props} path.
980
1127
  expect(template).toContain('{{bf_spread_attrs .Spread_0}}')
981
1128
  // The bag value is a conditional map built in NewBoxProps. The
982
- // prop type is unresolved (interface{}), so the condition routes
983
- // through `bf.Truthy` for a faithful JS `Boolean(x)` test.
1129
+ // optional prop resolves to a concrete `string` (#2259) it is
1130
+ // consumed only inside the spread condition, not as a bare attr, so
1131
+ // no nillable flip — and the field-typed condition is a faithful
1132
+ // `!= ""` truthiness test rather than reflection: JS string
1133
+ // truthiness IS non-emptiness, and an absent prop zeroes to `""`.
984
1134
  expect(types).toContain('Spread_0: func() map[string]any {')
985
- expect(types).toContain('if bf.Truthy(in.DescribedBy) {')
1135
+ expect(types).toContain('if in.DescribedBy != "" {')
986
1136
  expect(types).toContain('return map[string]any{"aria-describedby": in.DescribedBy}')
987
1137
  expect(types).toContain('return map[string]any{}')
988
1138
  })
@@ -3239,9 +3389,10 @@ export { C }
3239
3389
  expect(t).toContain('bf_map_eval .Users')
3240
3390
  })
3241
3391
 
3242
- // The function-reference `.map(format)` BF101 refusal is now covered
3243
- // cross-adapter by the `array-map-function-reference` shared fixture's
3244
- // `expectedDiagnostics` entry above.
3392
+ // The function-reference `.map(format)` case is now covered cross-adapter
3393
+ // by the `array-map-function-reference` shared fixture — `format` resolves
3394
+ // to its declaration (#2206) and the fixture compiles clean rather than
3395
+ // refusing with BF101.
3245
3396
  })
3246
3397
 
3247
3398
  describe('GoTemplateAdapter - #1448 Tier C .flatMap(field projection)', () => {
@@ -3630,8 +3781,9 @@ export function C(props: Props) {
3630
3781
  const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3631
3782
  // The `{}` fallback lowers to the safe `""` Go string sentinel — never the
3632
3783
  // `[UNSUPPORTED: …]` marker text, which would break `text/template` parsing
3633
- // once spliced as an `or` operand.
3634
- expect(template).toContain('{{or .Config ""}}')
3784
+ // once spliced as an operand. `??` on a nillable prop lowers to the
3785
+ // nil-testing `bf_nullish` (#2248), equally valid template syntax.
3786
+ expect(template).toContain('{{bf_nullish .Config ""}}')
3635
3787
  expect(template).not.toContain('UNSUPPORTED')
3636
3788
  })
3637
3789
 
@@ -3865,3 +4017,706 @@ export function TodoList(props: { todos?: Todo[] }) {
3865
4017
  expect(types).toContain('TodoItems []')
3866
4018
  })
3867
4019
  })
4020
+
4021
+ // #2228: a `.filter(t => …).map(todo => <Child todo={todo} .../>)` loop whose
4022
+ // body is a single child component ranges the WRAPPER slice (`.TodoItems`,
4023
+ // `.{ChildName}s` — see #2130 above), so `{{if}}`'s dot context for the
4024
+ // filter predicate is the wrapper `TodoItemProps` struct, not the raw datum.
4025
+ // `t.done` used to lower straight to `.Done` regardless — a field that only
4026
+ // exists on the raw `Todo`, nested under whichever prop forwards the loop
4027
+ // param verbatim (`todo={todo}` → `.Todo`). `html/template` resolves struct
4028
+ // fields at EXECUTE time, not Go-compile time, so this shipped silently
4029
+ // until a predicate branch that isn't short-circuited away actually ran
4030
+ // (discovered via #2209's `buildDynamicChildLoopSeeding`, which populates
4031
+ // `.TodoItems` in the test harness — previously always empty).
4032
+ describe('GoTemplateAdapter - filter predicate qualifies through wrapper-slice datum field (#2228)', () => {
4033
+ // Same block-body filter shape as TodoAppSSR.tsx (`filter(t => { const f =
4034
+ // filter(); if (f === 'active') return !t.done; if (f === 'completed')
4035
+ // return t.done; return true })`), folded to one expression by #2040's
4036
+ // `predicateTernaryToLogical`. `filter`'s SSR default is `'active'` (not
4037
+ // `'all'`) specifically so `!t.done` is REACHABLE — `'all'`'s short-circuit
4038
+ // is exactly what hid this bug in the shipped todo-app-ssr fixture.
4039
+ const TODO_FILTER_PROBE_SOURCE = `
4040
+ 'use client'
4041
+ import { createSignal } from '@barefootjs/client'
4042
+ import { TodoItem } from './todo-item'
4043
+
4044
+ type Todo = { id: number; text: string; done: boolean }
4045
+ type Filter = 'all' | 'active' | 'completed'
4046
+
4047
+ export function TodoFilterProbe(props: { initialTodos?: Todo[] }) {
4048
+ const [todos] = createSignal<Todo[]>(props.initialTodos ?? [])
4049
+ const [filter] = createSignal<Filter>('active')
4050
+
4051
+ return (
4052
+ <ul>
4053
+ {todos().filter(t => {
4054
+ const f = filter()
4055
+ if (f === 'active') return !t.done
4056
+ if (f === 'completed') return t.done
4057
+ return true
4058
+ }).map(todo => (
4059
+ <TodoItem key={todo.id} todo={todo} />
4060
+ ))}
4061
+ </ul>
4062
+ )
4063
+ }
4064
+ `
4065
+
4066
+ const TODO_ITEM_SOURCE = `
4067
+ type Todo = { id: number; text: string; done: boolean }
4068
+ type Props = { todo: Todo }
4069
+ export function TodoItem(props: Props) {
4070
+ return <li>{props.todo.text}</li>
4071
+ }
4072
+ `
4073
+
4074
+ test('emits .Todo.Done, not bare .Done, in the loop-gating {{if}}', () => {
4075
+ const adapter = new GoTemplateAdapter()
4076
+ const ir = compileToIR(TODO_FILTER_PROBE_SOURCE, adapter)
4077
+ const { template } = adapter.generate(ir)
4078
+
4079
+ // The datum-carrying field is derived from the prop that receives the
4080
+ // loop param verbatim (`todo={todo}` → `Todo`, `capitalizeFieldName('todo')`
4081
+ // — the SAME derivation `generatePropsStruct` uses for every other
4082
+ // prop-to-field mapping), not hardcoded.
4083
+ expect(template).toContain('not .Todo.Done')
4084
+ expect(template).toContain('(.Todo.Done)')
4085
+ // The bare (unqualified) form must not survive the fix — this is the
4086
+ // literal string `html/template` failed to resolve pre-fix
4087
+ // (`can't evaluate field Done in type TodoItemProps`).
4088
+ expect(template).not.toContain('not .Done')
4089
+ expect(template).not.toContain('(.Done)')
4090
+ // Still ranges the wrapper slice (#2130's retarget is untouched).
4091
+ expect(template).toContain(':= .TodoItems}}')
4092
+ })
4093
+
4094
+ test('real `go run`: default filter "active" renders only the not-done item', async () => {
4095
+ let html: string
4096
+ try {
4097
+ html = await renderGoTemplateComponent({
4098
+ source: TODO_FILTER_PROBE_SOURCE.trimStart(),
4099
+ adapter: new GoTemplateAdapter(),
4100
+ components: { './todo-item': TODO_ITEM_SOURCE.trimStart() },
4101
+ props: {
4102
+ initialTodos: [
4103
+ { id: 1, text: 'Eat breakfast', done: true },
4104
+ { id: 2, text: 'Write tests', done: false },
4105
+ ],
4106
+ },
4107
+ })
4108
+ } catch (err) {
4109
+ if (err instanceof GoNotAvailableError) {
4110
+ console.log('Skipping #2228 filter-predicate e2e: go command not found')
4111
+ return
4112
+ }
4113
+ throw err
4114
+ }
4115
+ // Pre-fix this either 500s at `tmpl.ExecuteTemplate` (`can't evaluate
4116
+ // field Done in type TodoItemProps`) or — since `bf_sort_eval`-style
4117
+ // evaluators are untouched by this fix, only the html/template dot-path
4118
+ // is — renders wrong. Post-fix: only the not-done todo (id 2) survives
4119
+ // the 'active' filter.
4120
+ expect(html).not.toContain('Eat breakfast')
4121
+ expect(html).toContain('Write tests')
4122
+ expect(html).toContain('data-key="2"')
4123
+ expect(html).not.toContain('data-key="1"')
4124
+ })
4125
+ })
4126
+
4127
+ // #2208: a static-array loop whose body is a single child component with a
4128
+ // plain-value prop set bakes the per-item props/data-key directly into the
4129
+ // generated constructor — see `analyzeBakeableStaticChildLoop`
4130
+ // (`analysis/static-child-loop-bake.ts`). Two-file fixture shape (sibling
4131
+ // `list-item.tsx`, `siblingTemplatesRegistered: true`) since Go's own BF103
4132
+ // cross-template-registration check (independent of #2208) would otherwise
4133
+ // fire first — mirrors `jsx-runner.ts`'s `compileWithDiagnostics`.
4134
+ describe('GoTemplateAdapter - static array-of-objects loop source baking (#2208)', () => {
4135
+ const STATIC_LIST_SOURCE = `
4136
+ import { ListItem } from './list-item'
4137
+ export function StaticList() {
4138
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4139
+ return (
4140
+ <ul>
4141
+ {items.map(item => (
4142
+ <ListItem key={item.label} label={item.label} className="text-sm" />
4143
+ ))}
4144
+ </ul>
4145
+ )
4146
+ }
4147
+ `
4148
+
4149
+ function compileStaticList(adapter?: GoTemplateAdapter) {
4150
+ return compileJSX(STATIC_LIST_SOURCE, 'test.tsx', {
4151
+ adapter: adapter ?? new GoTemplateAdapter(),
4152
+ siblingTemplatesRegistered: true,
4153
+ outputIR: false,
4154
+ })
4155
+ }
4156
+
4157
+ test('compiles with no BF101 (no longer refused)', () => {
4158
+ const result = compileStaticList()
4159
+ expect(result.errors ?? []).toEqual([])
4160
+ })
4161
+
4162
+ test('constructor bakes each item\'s props and data-key directly', () => {
4163
+ const result = compileStaticList()
4164
+ const types = result.files.find(f => f.type === 'types')!.content
4165
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Alpha", ClassName: "text-sm"})')
4166
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Beta", ClassName: "text-sm"})')
4167
+ expect(types).toContain('BfDataKey = "Alpha"')
4168
+ expect(types).toContain('BfDataKey = "Beta"')
4169
+ })
4170
+
4171
+ test('the Input struct carries no ListItems field (nothing for a caller to supply)', () => {
4172
+ const result = compileStaticList()
4173
+ const types = result.files.find(f => f.type === 'types')!.content
4174
+ const inputStruct = types.slice(types.indexOf('StaticListInput struct'), types.indexOf('StaticListProps struct'))
4175
+ expect(inputStruct).not.toContain('ListItems')
4176
+ })
4177
+
4178
+ test('the template still ranges over .ListItems (unchanged)', () => {
4179
+ const result = compileStaticList()
4180
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4181
+ expect(template).toContain(':= .ListItems}}')
4182
+ })
4183
+
4184
+ test('a runtime-computed const (#2069) still refuses with BF101', () => {
4185
+ const result = compileJSX(
4186
+ `
4187
+ import { ListItem } from './list-item'
4188
+ export function TagList(props: { tags: string[] }) {
4189
+ const entries = props.tags.filter(t => t !== '')
4190
+ return (
4191
+ <ul>
4192
+ {entries.map(label => (
4193
+ <ListItem key={label} label={label} />
4194
+ ))}
4195
+ </ul>
4196
+ )
4197
+ }
4198
+ `,
4199
+ 'test.tsx',
4200
+ { adapter: new GoTemplateAdapter(), siblingTemplatesRegistered: true },
4201
+ )
4202
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4203
+ })
4204
+
4205
+ // Fable review: `bf build` compiles a whole source dir through ONE reused
4206
+ // adapter instance (loop marker ids restart at `l0` per component) — the
4207
+ // bakeability cache must reset every `generate()` or a stale entry from a
4208
+ // PREVIOUS component either silently suppresses this fix, or (worse)
4209
+ // leaks that other component's baked literal values into this one.
4210
+ const TAG_LIST_SOURCE = `
4211
+ import { ListItem } from './list-item'
4212
+ export function TagList(props: { tags: string[] }) {
4213
+ const entries = props.tags.filter(t => t !== '')
4214
+ return (
4215
+ <ul>
4216
+ {entries.map(label => (
4217
+ <ListItem key={label} label={label} />
4218
+ ))}
4219
+ </ul>
4220
+ )
4221
+ }
4222
+ `
4223
+
4224
+ test('a reused adapter does not suppress baking for a later component sharing a marker id', () => {
4225
+ const adapter = new GoTemplateAdapter()
4226
+ compileJSX(TAG_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4227
+ const second = compileJSX(STATIC_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4228
+ expect(second.errors ?? []).toEqual([])
4229
+ const types = second.files.find(f => f.type === 'types')!.content
4230
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Alpha"')
4231
+ })
4232
+
4233
+ test('a reused adapter does not leak a prior component\'s baked data into a later runtime-computed one', () => {
4234
+ const adapter = new GoTemplateAdapter()
4235
+ compileJSX(STATIC_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4236
+ const second = compileJSX(TAG_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4237
+ expect(second.errors?.some(e => e.code === 'BF101')).toBe(true)
4238
+ const types = second.files.find(f => f.type === 'types')!.content
4239
+ expect(types).not.toContain('"Alpha"')
4240
+ })
4241
+
4242
+ // Fable re-review: `generateTypes()` is ALSO a standalone public entry
4243
+ // point (the Go conformance harness in `test-render.ts` calls it
4244
+ // directly on an already-`generate()`d adapter for a sibling/child IR),
4245
+ // not just an internal step of `generate()` — the bake cache must reset
4246
+ // there too, or a marker id collision with a PREVIOUS `generate()` call
4247
+ // leaks that other component's baked data through this door instead.
4248
+ test('a standalone generateTypes() call does not leak a prior generate() pass\'s baked data', () => {
4249
+ const adapter = new GoTemplateAdapter()
4250
+ const tagListIR = compileToIR(TAG_LIST_SOURCE, adapter)
4251
+ adapter.generate(tagListIR, { siblingTemplatesRegistered: true })
4252
+ const staticListIR = compileToIR(STATIC_LIST_SOURCE, adapter)
4253
+ adapter.generate(staticListIR, { siblingTemplatesRegistered: true })
4254
+
4255
+ const types = adapter.generateTypes(tagListIR)
4256
+ expect(types).not.toContain('"Alpha"')
4257
+ expect(types).toContain('ListItems []ListItemInput')
4258
+ })
4259
+
4260
+ // Fable review: a static loop-SOURCE identifier must not resolve through
4261
+ // an outer const when a DIFFERENT, enclosing loop's own callback param
4262
+ // shadows that same name.
4263
+ test('a static const shadowed by an enclosing loop param does not get baked', () => {
4264
+ const result = compileJSX(
4265
+ `
4266
+ import { ListItem } from './list-item'
4267
+ export function Nested({ groups }: { groups: { label: string }[][] }) {
4268
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4269
+ return (
4270
+ <div>
4271
+ {groups.map((items, i) => (
4272
+ <ul key={i}>
4273
+ {items.map(item => (
4274
+ <ListItem key={item.label} label={item.label} />
4275
+ ))}
4276
+ </ul>
4277
+ ))}
4278
+ </div>
4279
+ )
4280
+ }
4281
+ `,
4282
+ 'test.tsx',
4283
+ { adapter: new GoTemplateAdapter(), siblingTemplatesRegistered: true },
4284
+ )
4285
+ const types = result.files.find(f => f.type === 'types')?.content ?? ''
4286
+ expect(types).not.toContain('"Alpha"')
4287
+ })
4288
+ })
4289
+
4290
+ // #2224: the two shapes #2208 deliberately left refused.
4291
+ // Shape 1 — a static-array loop whose body is a PLAIN ELEMENT (no child
4292
+ // component): unrolled once per item at template-generation time (no Go
4293
+ // struct synthesis, no `{{range}}`) — see
4294
+ // `analysis/static-element-loop-bake.ts`'s docstring for the exact gate.
4295
+ // Shape 2 — an INLINE, unnamed array literal directly in `.map()`, with
4296
+ // either body kind — routed into shape 1's unroll (element body) or
4297
+ // #2208's existing bake (component body) the same way a named const is.
4298
+ describe('GoTemplateAdapter - static array-of-objects loop, plain-element body + inline literal (#2224)', () => {
4299
+ test('shape 1: named const + plain-element body compiles with no BF101', () => {
4300
+ const result = compileJSX(
4301
+ `
4302
+ export function List() {
4303
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4304
+ return <ul>{items.map(item => <li key={item.label}>{item.label}</li>)}</ul>
4305
+ }
4306
+ `,
4307
+ 'test.tsx',
4308
+ { adapter: new GoTemplateAdapter() },
4309
+ )
4310
+ expect(result.errors ?? []).toEqual([])
4311
+ })
4312
+
4313
+ test('shape 1: the template is unrolled once per item, no {{range}}', () => {
4314
+ const result = compileJSX(
4315
+ `
4316
+ export function List() {
4317
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4318
+ return <ul>{items.map(item => <li key={item.label}>{item.label}</li>)}</ul>
4319
+ }
4320
+ `,
4321
+ 'test.tsx',
4322
+ { adapter: new GoTemplateAdapter() },
4323
+ )
4324
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4325
+ expect(template).not.toContain('{{range')
4326
+ // The `<!--bf-loop:id--> ... <!--/bf-loop:id-->` marker pair a dynamic
4327
+ // loop emits still wraps the unrolled body, so the CSR-side static
4328
+ // `forEach` wiring (a separate, unaffected compiler pass) finds the
4329
+ // same DOM range.
4330
+ expect(template).toContain('{{bfComment "loop:l0"}}')
4331
+ expect(template).toContain('{{bfComment "/loop:l0"}}')
4332
+ // Per item: the SAME `data-key` / `bfTextStart`/`bfTextEnd` markers a
4333
+ // dynamic `{{range}}` over `.Field` would emit, with the item's value
4334
+ // substituted as a literal Go string instead of a `.Field` reference.
4335
+ expect(template).toContain('<li data-key="{{"Alpha"}}">{{bfTextStart "s0"}}{{"Alpha"}}{{bfTextEnd}}</li>')
4336
+ expect(template).toContain('<li data-key="{{"Beta"}}">{{bfTextStart "s0"}}{{"Beta"}}{{bfTextEnd}}</li>')
4337
+ })
4338
+
4339
+ test('shape 1 rendered through real `go run` produces the expected HTML', async () => {
4340
+ try {
4341
+ const html = await renderGoTemplateComponent({
4342
+ source: `
4343
+ export function List() {
4344
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4345
+ return <ul>{items.map(item => <li key={item.label}>{item.label}</li>)}</ul>
4346
+ }
4347
+ `,
4348
+ adapter: new GoTemplateAdapter(),
4349
+ props: {},
4350
+ })
4351
+ expect(html).toContain('<li data-key="Alpha"><!--bf:s0-->Alpha<!--/--></li>')
4352
+ expect(html).toContain('<li data-key="Beta"><!--bf:s0-->Beta<!--/--></li>')
4353
+ } catch (err) {
4354
+ if (err instanceof GoNotAvailableError) {
4355
+ console.log('Skipping #2224 shape-1 e2e: go command not found')
4356
+ return
4357
+ }
4358
+ throw err
4359
+ }
4360
+ })
4361
+
4362
+ test('shape 2, element body: inline unnamed array literal compiles and unrolls the same as a named const', () => {
4363
+ const result = compileJSX(
4364
+ `
4365
+ export function List() {
4366
+ return <ul>{[{ label: 'Alpha' }, { label: 'Beta' }].map(item => <li key={item.label}>{item.label}</li>)}</ul>
4367
+ }
4368
+ `,
4369
+ 'test.tsx',
4370
+ { adapter: new GoTemplateAdapter() },
4371
+ )
4372
+ expect(result.errors ?? []).toEqual([])
4373
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4374
+ expect(template).not.toContain('{{range')
4375
+ expect(template).toContain('{{"Alpha"}}')
4376
+ expect(template).toContain('{{"Beta"}}')
4377
+ })
4378
+
4379
+ test('shape 2, component body: inline unnamed array literal reaches the #2208 constructor bake (previously BF101 "Expression not supported")', () => {
4380
+ const result = compileJSX(
4381
+ `
4382
+ import { ListItem } from './list-item'
4383
+ export function List() {
4384
+ return (
4385
+ <ul>
4386
+ {[{ label: 'Alpha' }, { label: 'Beta' }].map(item => (
4387
+ <ListItem key={item.label} label={item.label} />
4388
+ ))}
4389
+ </ul>
4390
+ )
4391
+ }
4392
+ `,
4393
+ 'test.tsx',
4394
+ { adapter: new GoTemplateAdapter(), siblingTemplatesRegistered: true },
4395
+ )
4396
+ expect(result.errors ?? []).toEqual([])
4397
+ const types = result.files.find(f => f.type === 'types')!.content
4398
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Alpha"})')
4399
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Beta"})')
4400
+ // The template still ranges over `.ListItems` (#2208's shape — only the
4401
+ // constructor's data is baked, not the range itself).
4402
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4403
+ expect(template).toContain(':= .ListItems}}')
4404
+ })
4405
+
4406
+ test('gate: a body expression that cannot fold against the item (a signal-shaped call) keeps the BF101 refusal', () => {
4407
+ const result = compileJSX(
4408
+ `
4409
+ export function List() {
4410
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4411
+ return <ul>{items.map(item => <li key={item.label}>{item.label} - {Date.now()}</li>)}</ul>
4412
+ }
4413
+ `,
4414
+ 'test.tsx',
4415
+ { adapter: new GoTemplateAdapter() },
4416
+ )
4417
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4418
+ const template = result.files.find(f => f.type === 'markedTemplate')?.content ?? ''
4419
+ expect(template).not.toContain('{{"Alpha"}}')
4420
+ })
4421
+
4422
+ test('gate: an index-param reference keeps the BF101 refusal (index is deliberately not folded)', () => {
4423
+ const result = compileJSX(
4424
+ `
4425
+ export function List() {
4426
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4427
+ return <ul>{items.map((item, i) => <li key={item.label}>{i}: {item.label}</li>)}</ul>
4428
+ }
4429
+ `,
4430
+ 'test.tsx',
4431
+ { adapter: new GoTemplateAdapter() },
4432
+ )
4433
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4434
+ })
4435
+
4436
+ test('gate: a nested loop inside the body keeps the BF101 refusal', () => {
4437
+ const result = compileJSX(
4438
+ `
4439
+ export function List() {
4440
+ const items = [{ label: 'Alpha', tags: ['x', 'y'] }]
4441
+ return <ul>{items.map(item => <li key={item.label}>{item.tags.map(t => <span>{t}</span>)}</li>)}</ul>
4442
+ }
4443
+ `,
4444
+ 'test.tsx',
4445
+ { adapter: new GoTemplateAdapter() },
4446
+ )
4447
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4448
+ })
4449
+
4450
+ test('gate: a non-scalar item field (array/object) keeps the BF101 refusal', () => {
4451
+ const result = compileJSX(
4452
+ `
4453
+ export function List() {
4454
+ const items = [{ label: 'Alpha', tags: ['x'] }]
4455
+ return <ul>{items.map(item => <li key={item.label}>{item.tags}</li>)}</ul>
4456
+ }
4457
+ `,
4458
+ 'test.tsx',
4459
+ { adapter: new GoTemplateAdapter() },
4460
+ )
4461
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4462
+ })
4463
+
4464
+ test('gate: .flatMap() keeps the BF101 refusal (out of scope)', () => {
4465
+ const result = compileJSX(
4466
+ `
4467
+ export function List() {
4468
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4469
+ return <ul>{items.flatMap(item => [<li key={item.label}>{item.label}</li>])}</ul>
4470
+ }
4471
+ `,
4472
+ 'test.tsx',
4473
+ { adapter: new GoTemplateAdapter() },
4474
+ )
4475
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4476
+ })
4477
+
4478
+ test('a static const shadowed by an enclosing loop param does not get unrolled (element body)', () => {
4479
+ const result = compileJSX(
4480
+ `
4481
+ export function Nested({ groups }: { groups: { label: string }[][] }) {
4482
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4483
+ return (
4484
+ <div>
4485
+ {groups.map((items, i) => (
4486
+ <ul key={i}>
4487
+ {items.map(item => (
4488
+ <li key={item.label}>{item.label}</li>
4489
+ ))}
4490
+ </ul>
4491
+ ))}
4492
+ </div>
4493
+ )
4494
+ }
4495
+ `,
4496
+ 'test.tsx',
4497
+ { adapter: new GoTemplateAdapter() },
4498
+ )
4499
+ const template = result.files.find(f => f.type === 'markedTemplate')?.content ?? ''
4500
+ expect(template).not.toContain('{{"Alpha"}}')
4501
+ })
4502
+ })
4503
+
4504
+ // #2236: two independent loop-param-shadowing gaps, both distinct from the
4505
+ // #2224 static-array unroll above (this describe exercises the DYNAMIC
4506
+ // (signal-driven) `{{range}}` path, where a real per-iteration `.` context
4507
+ // exists — #2224's baked/unrolled loops are a different code path entirely).
4508
+ //
4509
+ // Slice A: `convertExpressionToGo`'s bare-identifier fast path (the
4510
+ // "inline a function-scope literal const" shortcut, e.g. `totalPages`) is a
4511
+ // STRING-KEYED check over the raw JS source text reached directly by call
4512
+ // sites like attribute emission (`key={count}` → `data-key`) — it never
4513
+ // goes through `identifier()` (the `ParsedExprEmitter` method), which
4514
+ // already carries the loop-shadow guards (`loopParamStack` /
4515
+ // `isOuterLoopParam`, mirrored from `resolveModuleStringConst` /
4516
+ // `resolveModuleNumericConst`). So a `.map((count) => ...)` callback param
4517
+ // that shadows an outer `const count = 7` got the OUTER literal inlined at
4518
+ // the `data-key` position even though the text position (which DOES go
4519
+ // through `identifier()`) correctly resolved to the per-item value.
4520
+ //
4521
+ // Slice B: Go's `collectStringValueNames` (prop-classes.ts) was ported from
4522
+ // Blade BEFORE #2212 added the local-const inclusion + `collectLoopBoundNames`
4523
+ // exclusion, so an outer string-typed prop/signal whose name is shadowed by a
4524
+ // `.map()` callback param still poisoned the shadowed occurrence's type
4525
+ // resolution — `1 + label` inside `values.map((label) => ...)` (with an outer
4526
+ // `label: string` prop) emitted `bf_concat_str` (string concat) instead of
4527
+ // `bf_add` (numeric addition). The full #2212 shape is ported: same-file
4528
+ // local consts join the set (so an outer `{label + suffix}` with
4529
+ // `suffix = '!'` still classifies as concat via its OTHER operand once
4530
+ // `label` is subtracted — the exact `loop-param-shadows-outer-name` fixture
4531
+ // shape) and loop-bound names are excluded.
4532
+ describe('GoTemplateAdapter - const/type resolution vs loop-param shadowing (#2236)', () => {
4533
+ test('slice A: data-key inside a dynamic (signal-driven) loop uses the loop value, not the outer const', () => {
4534
+ const { template } = compileAndGenerate(`
4535
+ 'use client'
4536
+ import { createSignal } from '@barefootjs/client'
4537
+ export function List() {
4538
+ const count = 7
4539
+ const [nums] = createSignal<number[]>([2, 5])
4540
+ return <ul>{nums().map((count) => <li key={count}>{count * 3}</li>)}</ul>
4541
+ }
4542
+ `)
4543
+ // The range establishes a real per-iteration dot context — both the
4544
+ // `data-key` attribute and the text position must resolve the shadowed
4545
+ // `count` through it.
4546
+ expect(template).toContain('{{range $_, $count := .Nums}}<li data-key="{{.}}">')
4547
+ expect(template).toContain('{{bf_mul . 3}}')
4548
+ // Never the outer `const count = 7` literal.
4549
+ expect(template).not.toContain('{{7}}')
4550
+ })
4551
+
4552
+ test('slice A: a DESTRUCTURED callback binding shadowing an outer const resolves to the binding accessor (#2242 Copilot review)', () => {
4553
+ // Destructured callbacks push '' onto loopParamStack and track their
4554
+ // binding names only in loopBindingStack — the fast-path guard must
4555
+ // scan that stack too, or the outer `const id = 7` inlines at both
4556
+ // the key and text positions.
4557
+ const { template } = compileAndGenerate(`
4558
+ 'use client'
4559
+ import { createSignal } from '@barefootjs/client'
4560
+ export function List() {
4561
+ const id = 7
4562
+ const [items] = createSignal<{ id: number }[]>([{ id: 2 }, { id: 5 }])
4563
+ return <ul>{items().map(({ id }) => <li key={id}>{id}</li>)}</ul>
4564
+ }
4565
+ `)
4566
+ expect(template).toContain('data-key="{{$__bf_item0.ID}}"')
4567
+ expect(template).toContain('{{$__bf_item0.ID}}{{bfTextEnd}}')
4568
+ expect(template).not.toContain('{{7}}')
4569
+ })
4570
+
4571
+ test('record-member fast path: a module object const shadowed by the callback param resolves per-item (loop-param-shadows-record-const fixture)', () => {
4572
+ // resolveStaticRecordLiteralIndex covers IDENT['key'] AND IDENT.key —
4573
+ // the record-member sibling of the bare-identifier gap above. Without
4574
+ // the isLoopShadowedName guard it baked {{"outer-lit"}} into every
4575
+ // iteration.
4576
+ const { template } = compileAndGenerate(`
4577
+ const cfg = { x: 'outer-lit' }
4578
+ export function List({ rows }: { rows: { id: number; x: string }[] }) {
4579
+ return <ul>{rows.map((cfg) => <li key={cfg.id}>{cfg.x}</li>)}</ul>
4580
+ }
4581
+ `)
4582
+ expect(template).toContain('{{range $_, $cfg := .Rows}}')
4583
+ expect(template).toContain('{{.X}}')
4584
+ expect(template).not.toContain('outer-lit')
4585
+ })
4586
+
4587
+ test('slice B: `1 + label` inside the loop that shadows an outer string prop lowers to bf_add, not bf_concat_str', () => {
4588
+ const { template } = compileAndGenerate(`
4589
+ 'use client'
4590
+ export function Labels({ label, values }: { label: string; values: number[] }) {
4591
+ return <ul>{values.map((label) => <li key={label}>{1 + label}</li>)}</ul>
4592
+ }
4593
+ `)
4594
+ expect(template).toContain('{{bf_add 1 .}}')
4595
+ expect(template).not.toContain('bf_concat_str')
4596
+ })
4597
+
4598
+ test('regression pin: a const inlined OUTSIDE any loop still inlines (slice A must not over-guard)', () => {
4599
+ const { template } = compileAndGenerate(`
4600
+ export function Foo() {
4601
+ const total = 5
4602
+ return <div data-key={total}>{total}</div>
4603
+ }
4604
+ `)
4605
+ expect(template).toContain('data-key="{{5}}"')
4606
+ expect(template).toContain('{{5}}</div>')
4607
+ })
4608
+
4609
+ test('regression pin: a genuinely string-typed `+` OUTSIDE the loop still lowers to bf_concat_str (slice B must not over-guard)', () => {
4610
+ const { template } = compileAndGenerate(`
4611
+ export function Foo({ label }: { label: string }) {
4612
+ const suffix = '!'
4613
+ return <p>{label + suffix}</p>
4614
+ }
4615
+ `)
4616
+ expect(template).toContain('bf_concat_str .Label .Suffix')
4617
+ })
4618
+
4619
+ test('local-const operand carries the concat classification when the prop operand is coarsely excluded (fixture shape)', () => {
4620
+ // The `loop-param-shadows-outer-name` fixture's combined shape: `label`
4621
+ // is loop-bound below, so the coarse exclusion strips it from the string
4622
+ // set — the OUTER `{label + suffix}` must then classify as concat via
4623
+ // its `suffix = '!'` local-const operand (the #2212 local-const
4624
+ // inclusion), or it would fall back to `bf_add` and render `0`.
4625
+ const { template } = compileAndGenerate(`
4626
+ 'use client'
4627
+ import { createSignal } from '@barefootjs/client'
4628
+ export function Both({ label, values }: { label: string; values: number[] }) {
4629
+ const suffix = '!'
4630
+ const [n, setN] = createSignal(0)
4631
+ return (
4632
+ <div data-n={n()} onClick={() => setN(n() + 1)}>
4633
+ <p>{label + suffix}</p>
4634
+ <ul>{values.map((label) => <li key={label}>{1 + label}</li>)}</ul>
4635
+ </div>
4636
+ )
4637
+ }
4638
+ `)
4639
+ // Outside the loop: still string concat, carried by the const operand.
4640
+ expect(template).toContain('bf_concat_str .Label .Suffix')
4641
+ // Inside the loop: the shadowed occurrence stays numeric.
4642
+ expect(template).toContain('{{bf_add 1 .}}')
4643
+ })
4644
+
4645
+ // Go's slice-A guard is scope-PRECISE: it consults the live
4646
+ // `loopParamStack`, not a flat component-wide name set, so a const whose
4647
+ // name is loop-bound ELSEWHERE in the component still inlines at an
4648
+ // occurrence that is genuinely outside any loop. This is a real point of
4649
+ // divergence from the Twig-family adapters' coarse `collectLoopBoundNames`
4650
+ // trade-off — pinned here so a future "fix" doesn't accidentally coarsen
4651
+ // Go's precise guard to match them.
4652
+ test('slice A guard is scope-precise: a name that is loop-bound elsewhere still inlines outside the loop', () => {
4653
+ const { template } = compileAndGenerate(`
4654
+ export function Foo({ items }: { items: number[] }) {
4655
+ const count = 9
4656
+ return <div>
4657
+ <p data-key={count}>{count}</p>
4658
+ <ul>{items.map(count => <li key={count}>{count}</li>)}</ul>
4659
+ </div>
4660
+ }
4661
+ `)
4662
+ // Outside the loop, `count` still resolves to the outer literal `9`.
4663
+ expect(template).toContain('data-key="{{9}}"')
4664
+ // Inside the loop, the shadowed occurrence still resolves to the loop
4665
+ // value, not the outer literal.
4666
+ expect(template).toContain('{{range $_, $count := .Items}}<li data-key="{{.}}">')
4667
+ })
4668
+
4669
+ // Slice B's guard, by contrast, is the coarse #2212 trade-off (a flat,
4670
+ // scope-blind `Set<string>` with the loop-bound name subtracted
4671
+ // component-wide): a genuinely non-shadowed occurrence OUTSIDE the loop,
4672
+ // whose name happens to be loop-bound elsewhere, also loses its
4673
+ // string-typed classification and falls back to numeric `bf_add`. This is
4674
+ // the accepted, already-documented residual (same as Blade's #2212
4675
+ // comment) — the suppressed case is safe (numeric fallback, never
4676
+ // silently-wrong string output), just imprecise.
4677
+ test('slice B guard is coarse (accepted #2212 trade-off): a string prop shadowed elsewhere loses bf_concat_str even outside the loop', () => {
4678
+ const { template } = compileAndGenerate(`
4679
+ export function Foo({ count, arr }: { count: string; arr: number[] }) {
4680
+ return <div>
4681
+ <p>{1 + count}</p>
4682
+ <ul>{arr.map((count) => <li key={count}>{count}</li>)}</ul>
4683
+ </div>
4684
+ }
4685
+ `)
4686
+ // Coarse trade-off: falls back to numeric bf_add outside the loop too,
4687
+ // even though this occurrence of `count` is the genuinely string-typed
4688
+ // outer prop, not shadowed.
4689
+ expect(template).toContain('bf_add 1 .Count')
4690
+ expect(template).not.toContain('bf_concat_str')
4691
+ })
4692
+
4693
+ test('end-to-end via real `go run`: shadowed const/type resolution renders correct HTML', async () => {
4694
+ try {
4695
+ const html = await renderGoTemplateComponent({
4696
+ source: `
4697
+ 'use client'
4698
+ import { createSignal } from '@barefootjs/client'
4699
+ export function List() {
4700
+ const count = 7
4701
+ const [nums] = createSignal<number[]>([2, 5])
4702
+ return <ul>{nums().map((count) => <li key={count}>{count * 3}</li>)}</ul>
4703
+ }
4704
+ `,
4705
+ adapter: new GoTemplateAdapter(),
4706
+ props: {},
4707
+ })
4708
+ // The outer `const count = 7` must never leak into the rendered
4709
+ // per-item output — each `<li>` carries its OWN loop value (2, 5),
4710
+ // not the constant 7.
4711
+ expect(html).toContain('<li data-key="2"><!--bf:s0-->6<!--/--></li>')
4712
+ expect(html).toContain('<li data-key="5"><!--bf:s0-->15<!--/--></li>')
4713
+ expect(html).not.toContain('data-key="7"')
4714
+ } catch (err) {
4715
+ if (err instanceof GoNotAvailableError) {
4716
+ console.log('Skipping #2236 e2e: go command not found')
4717
+ return
4718
+ }
4719
+ throw err
4720
+ }
4721
+ })
4722
+ })