@barefootjs/go-template 0.18.5 → 0.18.7

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.
@@ -3239,9 +3239,10 @@ export { C }
3239
3239
  expect(t).toContain('bf_map_eval .Users')
3240
3240
  })
3241
3241
 
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.
3242
+ // The function-reference `.map(format)` case is now covered cross-adapter
3243
+ // by the `array-map-function-reference` shared fixture — `format` resolves
3244
+ // to its declaration (#2206) and the fixture compiles clean rather than
3245
+ // refusing with BF101.
3245
3246
  })
3246
3247
 
3247
3248
  describe('GoTemplateAdapter - #1448 Tier C .flatMap(field projection)', () => {
@@ -3865,3 +3866,706 @@ export function TodoList(props: { todos?: Todo[] }) {
3865
3866
  expect(types).toContain('TodoItems []')
3866
3867
  })
3867
3868
  })
3869
+
3870
+ // #2228: a `.filter(t => …).map(todo => <Child todo={todo} .../>)` loop whose
3871
+ // body is a single child component ranges the WRAPPER slice (`.TodoItems`,
3872
+ // `.{ChildName}s` — see #2130 above), so `{{if}}`'s dot context for the
3873
+ // filter predicate is the wrapper `TodoItemProps` struct, not the raw datum.
3874
+ // `t.done` used to lower straight to `.Done` regardless — a field that only
3875
+ // exists on the raw `Todo`, nested under whichever prop forwards the loop
3876
+ // param verbatim (`todo={todo}` → `.Todo`). `html/template` resolves struct
3877
+ // fields at EXECUTE time, not Go-compile time, so this shipped silently
3878
+ // until a predicate branch that isn't short-circuited away actually ran
3879
+ // (discovered via #2209's `buildDynamicChildLoopSeeding`, which populates
3880
+ // `.TodoItems` in the test harness — previously always empty).
3881
+ describe('GoTemplateAdapter - filter predicate qualifies through wrapper-slice datum field (#2228)', () => {
3882
+ // Same block-body filter shape as TodoAppSSR.tsx (`filter(t => { const f =
3883
+ // filter(); if (f === 'active') return !t.done; if (f === 'completed')
3884
+ // return t.done; return true })`), folded to one expression by #2040's
3885
+ // `predicateTernaryToLogical`. `filter`'s SSR default is `'active'` (not
3886
+ // `'all'`) specifically so `!t.done` is REACHABLE — `'all'`'s short-circuit
3887
+ // is exactly what hid this bug in the shipped todo-app-ssr fixture.
3888
+ const TODO_FILTER_PROBE_SOURCE = `
3889
+ 'use client'
3890
+ import { createSignal } from '@barefootjs/client'
3891
+ import { TodoItem } from './todo-item'
3892
+
3893
+ type Todo = { id: number; text: string; done: boolean }
3894
+ type Filter = 'all' | 'active' | 'completed'
3895
+
3896
+ export function TodoFilterProbe(props: { initialTodos?: Todo[] }) {
3897
+ const [todos] = createSignal<Todo[]>(props.initialTodos ?? [])
3898
+ const [filter] = createSignal<Filter>('active')
3899
+
3900
+ return (
3901
+ <ul>
3902
+ {todos().filter(t => {
3903
+ const f = filter()
3904
+ if (f === 'active') return !t.done
3905
+ if (f === 'completed') return t.done
3906
+ return true
3907
+ }).map(todo => (
3908
+ <TodoItem key={todo.id} todo={todo} />
3909
+ ))}
3910
+ </ul>
3911
+ )
3912
+ }
3913
+ `
3914
+
3915
+ const TODO_ITEM_SOURCE = `
3916
+ type Todo = { id: number; text: string; done: boolean }
3917
+ type Props = { todo: Todo }
3918
+ export function TodoItem(props: Props) {
3919
+ return <li>{props.todo.text}</li>
3920
+ }
3921
+ `
3922
+
3923
+ test('emits .Todo.Done, not bare .Done, in the loop-gating {{if}}', () => {
3924
+ const adapter = new GoTemplateAdapter()
3925
+ const ir = compileToIR(TODO_FILTER_PROBE_SOURCE, adapter)
3926
+ const { template } = adapter.generate(ir)
3927
+
3928
+ // The datum-carrying field is derived from the prop that receives the
3929
+ // loop param verbatim (`todo={todo}` → `Todo`, `capitalizeFieldName('todo')`
3930
+ // — the SAME derivation `generatePropsStruct` uses for every other
3931
+ // prop-to-field mapping), not hardcoded.
3932
+ expect(template).toContain('not .Todo.Done')
3933
+ expect(template).toContain('(.Todo.Done)')
3934
+ // The bare (unqualified) form must not survive the fix — this is the
3935
+ // literal string `html/template` failed to resolve pre-fix
3936
+ // (`can't evaluate field Done in type TodoItemProps`).
3937
+ expect(template).not.toContain('not .Done')
3938
+ expect(template).not.toContain('(.Done)')
3939
+ // Still ranges the wrapper slice (#2130's retarget is untouched).
3940
+ expect(template).toContain(':= .TodoItems}}')
3941
+ })
3942
+
3943
+ test('real `go run`: default filter "active" renders only the not-done item', async () => {
3944
+ let html: string
3945
+ try {
3946
+ html = await renderGoTemplateComponent({
3947
+ source: TODO_FILTER_PROBE_SOURCE.trimStart(),
3948
+ adapter: new GoTemplateAdapter(),
3949
+ components: { './todo-item': TODO_ITEM_SOURCE.trimStart() },
3950
+ props: {
3951
+ initialTodos: [
3952
+ { id: 1, text: 'Eat breakfast', done: true },
3953
+ { id: 2, text: 'Write tests', done: false },
3954
+ ],
3955
+ },
3956
+ })
3957
+ } catch (err) {
3958
+ if (err instanceof GoNotAvailableError) {
3959
+ console.log('Skipping #2228 filter-predicate e2e: go command not found')
3960
+ return
3961
+ }
3962
+ throw err
3963
+ }
3964
+ // Pre-fix this either 500s at `tmpl.ExecuteTemplate` (`can't evaluate
3965
+ // field Done in type TodoItemProps`) or — since `bf_sort_eval`-style
3966
+ // evaluators are untouched by this fix, only the html/template dot-path
3967
+ // is — renders wrong. Post-fix: only the not-done todo (id 2) survives
3968
+ // the 'active' filter.
3969
+ expect(html).not.toContain('Eat breakfast')
3970
+ expect(html).toContain('Write tests')
3971
+ expect(html).toContain('data-key="2"')
3972
+ expect(html).not.toContain('data-key="1"')
3973
+ })
3974
+ })
3975
+
3976
+ // #2208: a static-array loop whose body is a single child component with a
3977
+ // plain-value prop set bakes the per-item props/data-key directly into the
3978
+ // generated constructor — see `analyzeBakeableStaticChildLoop`
3979
+ // (`analysis/static-child-loop-bake.ts`). Two-file fixture shape (sibling
3980
+ // `list-item.tsx`, `siblingTemplatesRegistered: true`) since Go's own BF103
3981
+ // cross-template-registration check (independent of #2208) would otherwise
3982
+ // fire first — mirrors `jsx-runner.ts`'s `compileWithDiagnostics`.
3983
+ describe('GoTemplateAdapter - static array-of-objects loop source baking (#2208)', () => {
3984
+ const STATIC_LIST_SOURCE = `
3985
+ import { ListItem } from './list-item'
3986
+ export function StaticList() {
3987
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
3988
+ return (
3989
+ <ul>
3990
+ {items.map(item => (
3991
+ <ListItem key={item.label} label={item.label} className="text-sm" />
3992
+ ))}
3993
+ </ul>
3994
+ )
3995
+ }
3996
+ `
3997
+
3998
+ function compileStaticList(adapter?: GoTemplateAdapter) {
3999
+ return compileJSX(STATIC_LIST_SOURCE, 'test.tsx', {
4000
+ adapter: adapter ?? new GoTemplateAdapter(),
4001
+ siblingTemplatesRegistered: true,
4002
+ outputIR: false,
4003
+ })
4004
+ }
4005
+
4006
+ test('compiles with no BF101 (no longer refused)', () => {
4007
+ const result = compileStaticList()
4008
+ expect(result.errors ?? []).toEqual([])
4009
+ })
4010
+
4011
+ test('constructor bakes each item\'s props and data-key directly', () => {
4012
+ const result = compileStaticList()
4013
+ const types = result.files.find(f => f.type === 'types')!.content
4014
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Alpha", ClassName: "text-sm"})')
4015
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Beta", ClassName: "text-sm"})')
4016
+ expect(types).toContain('BfDataKey = "Alpha"')
4017
+ expect(types).toContain('BfDataKey = "Beta"')
4018
+ })
4019
+
4020
+ test('the Input struct carries no ListItems field (nothing for a caller to supply)', () => {
4021
+ const result = compileStaticList()
4022
+ const types = result.files.find(f => f.type === 'types')!.content
4023
+ const inputStruct = types.slice(types.indexOf('StaticListInput struct'), types.indexOf('StaticListProps struct'))
4024
+ expect(inputStruct).not.toContain('ListItems')
4025
+ })
4026
+
4027
+ test('the template still ranges over .ListItems (unchanged)', () => {
4028
+ const result = compileStaticList()
4029
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4030
+ expect(template).toContain(':= .ListItems}}')
4031
+ })
4032
+
4033
+ test('a runtime-computed const (#2069) still refuses with BF101', () => {
4034
+ const result = compileJSX(
4035
+ `
4036
+ import { ListItem } from './list-item'
4037
+ export function TagList(props: { tags: string[] }) {
4038
+ const entries = props.tags.filter(t => t !== '')
4039
+ return (
4040
+ <ul>
4041
+ {entries.map(label => (
4042
+ <ListItem key={label} label={label} />
4043
+ ))}
4044
+ </ul>
4045
+ )
4046
+ }
4047
+ `,
4048
+ 'test.tsx',
4049
+ { adapter: new GoTemplateAdapter(), siblingTemplatesRegistered: true },
4050
+ )
4051
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4052
+ })
4053
+
4054
+ // Fable review: `bf build` compiles a whole source dir through ONE reused
4055
+ // adapter instance (loop marker ids restart at `l0` per component) — the
4056
+ // bakeability cache must reset every `generate()` or a stale entry from a
4057
+ // PREVIOUS component either silently suppresses this fix, or (worse)
4058
+ // leaks that other component's baked literal values into this one.
4059
+ const TAG_LIST_SOURCE = `
4060
+ import { ListItem } from './list-item'
4061
+ export function TagList(props: { tags: string[] }) {
4062
+ const entries = props.tags.filter(t => t !== '')
4063
+ return (
4064
+ <ul>
4065
+ {entries.map(label => (
4066
+ <ListItem key={label} label={label} />
4067
+ ))}
4068
+ </ul>
4069
+ )
4070
+ }
4071
+ `
4072
+
4073
+ test('a reused adapter does not suppress baking for a later component sharing a marker id', () => {
4074
+ const adapter = new GoTemplateAdapter()
4075
+ compileJSX(TAG_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4076
+ const second = compileJSX(STATIC_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4077
+ expect(second.errors ?? []).toEqual([])
4078
+ const types = second.files.find(f => f.type === 'types')!.content
4079
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Alpha"')
4080
+ })
4081
+
4082
+ test('a reused adapter does not leak a prior component\'s baked data into a later runtime-computed one', () => {
4083
+ const adapter = new GoTemplateAdapter()
4084
+ compileJSX(STATIC_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4085
+ const second = compileJSX(TAG_LIST_SOURCE, 'test.tsx', { adapter, siblingTemplatesRegistered: true })
4086
+ expect(second.errors?.some(e => e.code === 'BF101')).toBe(true)
4087
+ const types = second.files.find(f => f.type === 'types')!.content
4088
+ expect(types).not.toContain('"Alpha"')
4089
+ })
4090
+
4091
+ // Fable re-review: `generateTypes()` is ALSO a standalone public entry
4092
+ // point (the Go conformance harness in `test-render.ts` calls it
4093
+ // directly on an already-`generate()`d adapter for a sibling/child IR),
4094
+ // not just an internal step of `generate()` — the bake cache must reset
4095
+ // there too, or a marker id collision with a PREVIOUS `generate()` call
4096
+ // leaks that other component's baked data through this door instead.
4097
+ test('a standalone generateTypes() call does not leak a prior generate() pass\'s baked data', () => {
4098
+ const adapter = new GoTemplateAdapter()
4099
+ const tagListIR = compileToIR(TAG_LIST_SOURCE, adapter)
4100
+ adapter.generate(tagListIR, { siblingTemplatesRegistered: true })
4101
+ const staticListIR = compileToIR(STATIC_LIST_SOURCE, adapter)
4102
+ adapter.generate(staticListIR, { siblingTemplatesRegistered: true })
4103
+
4104
+ const types = adapter.generateTypes(tagListIR)
4105
+ expect(types).not.toContain('"Alpha"')
4106
+ expect(types).toContain('ListItems []ListItemInput')
4107
+ })
4108
+
4109
+ // Fable review: a static loop-SOURCE identifier must not resolve through
4110
+ // an outer const when a DIFFERENT, enclosing loop's own callback param
4111
+ // shadows that same name.
4112
+ test('a static const shadowed by an enclosing loop param does not get baked', () => {
4113
+ const result = compileJSX(
4114
+ `
4115
+ import { ListItem } from './list-item'
4116
+ export function Nested({ groups }: { groups: { label: string }[][] }) {
4117
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4118
+ return (
4119
+ <div>
4120
+ {groups.map((items, i) => (
4121
+ <ul key={i}>
4122
+ {items.map(item => (
4123
+ <ListItem key={item.label} label={item.label} />
4124
+ ))}
4125
+ </ul>
4126
+ ))}
4127
+ </div>
4128
+ )
4129
+ }
4130
+ `,
4131
+ 'test.tsx',
4132
+ { adapter: new GoTemplateAdapter(), siblingTemplatesRegistered: true },
4133
+ )
4134
+ const types = result.files.find(f => f.type === 'types')?.content ?? ''
4135
+ expect(types).not.toContain('"Alpha"')
4136
+ })
4137
+ })
4138
+
4139
+ // #2224: the two shapes #2208 deliberately left refused.
4140
+ // Shape 1 — a static-array loop whose body is a PLAIN ELEMENT (no child
4141
+ // component): unrolled once per item at template-generation time (no Go
4142
+ // struct synthesis, no `{{range}}`) — see
4143
+ // `analysis/static-element-loop-bake.ts`'s docstring for the exact gate.
4144
+ // Shape 2 — an INLINE, unnamed array literal directly in `.map()`, with
4145
+ // either body kind — routed into shape 1's unroll (element body) or
4146
+ // #2208's existing bake (component body) the same way a named const is.
4147
+ describe('GoTemplateAdapter - static array-of-objects loop, plain-element body + inline literal (#2224)', () => {
4148
+ test('shape 1: named const + plain-element body compiles with no BF101', () => {
4149
+ const result = compileJSX(
4150
+ `
4151
+ export function List() {
4152
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4153
+ return <ul>{items.map(item => <li key={item.label}>{item.label}</li>)}</ul>
4154
+ }
4155
+ `,
4156
+ 'test.tsx',
4157
+ { adapter: new GoTemplateAdapter() },
4158
+ )
4159
+ expect(result.errors ?? []).toEqual([])
4160
+ })
4161
+
4162
+ test('shape 1: the template is unrolled once per item, no {{range}}', () => {
4163
+ const result = compileJSX(
4164
+ `
4165
+ export function List() {
4166
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4167
+ return <ul>{items.map(item => <li key={item.label}>{item.label}</li>)}</ul>
4168
+ }
4169
+ `,
4170
+ 'test.tsx',
4171
+ { adapter: new GoTemplateAdapter() },
4172
+ )
4173
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4174
+ expect(template).not.toContain('{{range')
4175
+ // The `<!--bf-loop:id--> ... <!--/bf-loop:id-->` marker pair a dynamic
4176
+ // loop emits still wraps the unrolled body, so the CSR-side static
4177
+ // `forEach` wiring (a separate, unaffected compiler pass) finds the
4178
+ // same DOM range.
4179
+ expect(template).toContain('{{bfComment "loop:l0"}}')
4180
+ expect(template).toContain('{{bfComment "/loop:l0"}}')
4181
+ // Per item: the SAME `data-key` / `bfTextStart`/`bfTextEnd` markers a
4182
+ // dynamic `{{range}}` over `.Field` would emit, with the item's value
4183
+ // substituted as a literal Go string instead of a `.Field` reference.
4184
+ expect(template).toContain('<li data-key="{{"Alpha"}}">{{bfTextStart "s0"}}{{"Alpha"}}{{bfTextEnd}}</li>')
4185
+ expect(template).toContain('<li data-key="{{"Beta"}}">{{bfTextStart "s0"}}{{"Beta"}}{{bfTextEnd}}</li>')
4186
+ })
4187
+
4188
+ test('shape 1 rendered through real `go run` produces the expected HTML', async () => {
4189
+ try {
4190
+ const html = await renderGoTemplateComponent({
4191
+ source: `
4192
+ export function List() {
4193
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4194
+ return <ul>{items.map(item => <li key={item.label}>{item.label}</li>)}</ul>
4195
+ }
4196
+ `,
4197
+ adapter: new GoTemplateAdapter(),
4198
+ props: {},
4199
+ })
4200
+ expect(html).toContain('<li data-key="Alpha"><!--bf:s0-->Alpha<!--/--></li>')
4201
+ expect(html).toContain('<li data-key="Beta"><!--bf:s0-->Beta<!--/--></li>')
4202
+ } catch (err) {
4203
+ if (err instanceof GoNotAvailableError) {
4204
+ console.log('Skipping #2224 shape-1 e2e: go command not found')
4205
+ return
4206
+ }
4207
+ throw err
4208
+ }
4209
+ })
4210
+
4211
+ test('shape 2, element body: inline unnamed array literal compiles and unrolls the same as a named const', () => {
4212
+ const result = compileJSX(
4213
+ `
4214
+ export function List() {
4215
+ return <ul>{[{ label: 'Alpha' }, { label: 'Beta' }].map(item => <li key={item.label}>{item.label}</li>)}</ul>
4216
+ }
4217
+ `,
4218
+ 'test.tsx',
4219
+ { adapter: new GoTemplateAdapter() },
4220
+ )
4221
+ expect(result.errors ?? []).toEqual([])
4222
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4223
+ expect(template).not.toContain('{{range')
4224
+ expect(template).toContain('{{"Alpha"}}')
4225
+ expect(template).toContain('{{"Beta"}}')
4226
+ })
4227
+
4228
+ test('shape 2, component body: inline unnamed array literal reaches the #2208 constructor bake (previously BF101 "Expression not supported")', () => {
4229
+ const result = compileJSX(
4230
+ `
4231
+ import { ListItem } from './list-item'
4232
+ export function List() {
4233
+ return (
4234
+ <ul>
4235
+ {[{ label: 'Alpha' }, { label: 'Beta' }].map(item => (
4236
+ <ListItem key={item.label} label={item.label} />
4237
+ ))}
4238
+ </ul>
4239
+ )
4240
+ }
4241
+ `,
4242
+ 'test.tsx',
4243
+ { adapter: new GoTemplateAdapter(), siblingTemplatesRegistered: true },
4244
+ )
4245
+ expect(result.errors ?? []).toEqual([])
4246
+ const types = result.files.find(f => f.type === 'types')!.content
4247
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Alpha"})')
4248
+ expect(types).toContain('NewListItemProps(ListItemInput{Label: "Beta"})')
4249
+ // The template still ranges over `.ListItems` (#2208's shape — only the
4250
+ // constructor's data is baked, not the range itself).
4251
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4252
+ expect(template).toContain(':= .ListItems}}')
4253
+ })
4254
+
4255
+ test('gate: a body expression that cannot fold against the item (a signal-shaped call) keeps the BF101 refusal', () => {
4256
+ const result = compileJSX(
4257
+ `
4258
+ export function List() {
4259
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4260
+ return <ul>{items.map(item => <li key={item.label}>{item.label} - {Date.now()}</li>)}</ul>
4261
+ }
4262
+ `,
4263
+ 'test.tsx',
4264
+ { adapter: new GoTemplateAdapter() },
4265
+ )
4266
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4267
+ const template = result.files.find(f => f.type === 'markedTemplate')?.content ?? ''
4268
+ expect(template).not.toContain('{{"Alpha"}}')
4269
+ })
4270
+
4271
+ test('gate: an index-param reference keeps the BF101 refusal (index is deliberately not folded)', () => {
4272
+ const result = compileJSX(
4273
+ `
4274
+ export function List() {
4275
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4276
+ return <ul>{items.map((item, i) => <li key={item.label}>{i}: {item.label}</li>)}</ul>
4277
+ }
4278
+ `,
4279
+ 'test.tsx',
4280
+ { adapter: new GoTemplateAdapter() },
4281
+ )
4282
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4283
+ })
4284
+
4285
+ test('gate: a nested loop inside the body keeps the BF101 refusal', () => {
4286
+ const result = compileJSX(
4287
+ `
4288
+ export function List() {
4289
+ const items = [{ label: 'Alpha', tags: ['x', 'y'] }]
4290
+ return <ul>{items.map(item => <li key={item.label}>{item.tags.map(t => <span>{t}</span>)}</li>)}</ul>
4291
+ }
4292
+ `,
4293
+ 'test.tsx',
4294
+ { adapter: new GoTemplateAdapter() },
4295
+ )
4296
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4297
+ })
4298
+
4299
+ test('gate: a non-scalar item field (array/object) keeps the BF101 refusal', () => {
4300
+ const result = compileJSX(
4301
+ `
4302
+ export function List() {
4303
+ const items = [{ label: 'Alpha', tags: ['x'] }]
4304
+ return <ul>{items.map(item => <li key={item.label}>{item.tags}</li>)}</ul>
4305
+ }
4306
+ `,
4307
+ 'test.tsx',
4308
+ { adapter: new GoTemplateAdapter() },
4309
+ )
4310
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4311
+ })
4312
+
4313
+ test('gate: .flatMap() keeps the BF101 refusal (out of scope)', () => {
4314
+ const result = compileJSX(
4315
+ `
4316
+ export function List() {
4317
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4318
+ return <ul>{items.flatMap(item => [<li key={item.label}>{item.label}</li>])}</ul>
4319
+ }
4320
+ `,
4321
+ 'test.tsx',
4322
+ { adapter: new GoTemplateAdapter() },
4323
+ )
4324
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
4325
+ })
4326
+
4327
+ test('a static const shadowed by an enclosing loop param does not get unrolled (element body)', () => {
4328
+ const result = compileJSX(
4329
+ `
4330
+ export function Nested({ groups }: { groups: { label: string }[][] }) {
4331
+ const items = [{ label: 'Alpha' }, { label: 'Beta' }]
4332
+ return (
4333
+ <div>
4334
+ {groups.map((items, i) => (
4335
+ <ul key={i}>
4336
+ {items.map(item => (
4337
+ <li key={item.label}>{item.label}</li>
4338
+ ))}
4339
+ </ul>
4340
+ ))}
4341
+ </div>
4342
+ )
4343
+ }
4344
+ `,
4345
+ 'test.tsx',
4346
+ { adapter: new GoTemplateAdapter() },
4347
+ )
4348
+ const template = result.files.find(f => f.type === 'markedTemplate')?.content ?? ''
4349
+ expect(template).not.toContain('{{"Alpha"}}')
4350
+ })
4351
+ })
4352
+
4353
+ // #2236: two independent loop-param-shadowing gaps, both distinct from the
4354
+ // #2224 static-array unroll above (this describe exercises the DYNAMIC
4355
+ // (signal-driven) `{{range}}` path, where a real per-iteration `.` context
4356
+ // exists — #2224's baked/unrolled loops are a different code path entirely).
4357
+ //
4358
+ // Slice A: `convertExpressionToGo`'s bare-identifier fast path (the
4359
+ // "inline a function-scope literal const" shortcut, e.g. `totalPages`) is a
4360
+ // STRING-KEYED check over the raw JS source text reached directly by call
4361
+ // sites like attribute emission (`key={count}` → `data-key`) — it never
4362
+ // goes through `identifier()` (the `ParsedExprEmitter` method), which
4363
+ // already carries the loop-shadow guards (`loopParamStack` /
4364
+ // `isOuterLoopParam`, mirrored from `resolveModuleStringConst` /
4365
+ // `resolveModuleNumericConst`). So a `.map((count) => ...)` callback param
4366
+ // that shadows an outer `const count = 7` got the OUTER literal inlined at
4367
+ // the `data-key` position even though the text position (which DOES go
4368
+ // through `identifier()`) correctly resolved to the per-item value.
4369
+ //
4370
+ // Slice B: Go's `collectStringValueNames` (prop-classes.ts) was ported from
4371
+ // Blade BEFORE #2212 added the local-const inclusion + `collectLoopBoundNames`
4372
+ // exclusion, so an outer string-typed prop/signal whose name is shadowed by a
4373
+ // `.map()` callback param still poisoned the shadowed occurrence's type
4374
+ // resolution — `1 + label` inside `values.map((label) => ...)` (with an outer
4375
+ // `label: string` prop) emitted `bf_concat_str` (string concat) instead of
4376
+ // `bf_add` (numeric addition). The full #2212 shape is ported: same-file
4377
+ // local consts join the set (so an outer `{label + suffix}` with
4378
+ // `suffix = '!'` still classifies as concat via its OTHER operand once
4379
+ // `label` is subtracted — the exact `loop-param-shadows-outer-name` fixture
4380
+ // shape) and loop-bound names are excluded.
4381
+ describe('GoTemplateAdapter - const/type resolution vs loop-param shadowing (#2236)', () => {
4382
+ test('slice A: data-key inside a dynamic (signal-driven) loop uses the loop value, not the outer const', () => {
4383
+ const { template } = compileAndGenerate(`
4384
+ 'use client'
4385
+ import { createSignal } from '@barefootjs/client'
4386
+ export function List() {
4387
+ const count = 7
4388
+ const [nums] = createSignal<number[]>([2, 5])
4389
+ return <ul>{nums().map((count) => <li key={count}>{count * 3}</li>)}</ul>
4390
+ }
4391
+ `)
4392
+ // The range establishes a real per-iteration dot context — both the
4393
+ // `data-key` attribute and the text position must resolve the shadowed
4394
+ // `count` through it.
4395
+ expect(template).toContain('{{range $_, $count := .Nums}}<li data-key="{{.}}">')
4396
+ expect(template).toContain('{{bf_mul . 3}}')
4397
+ // Never the outer `const count = 7` literal.
4398
+ expect(template).not.toContain('{{7}}')
4399
+ })
4400
+
4401
+ test('slice A: a DESTRUCTURED callback binding shadowing an outer const resolves to the binding accessor (#2242 Copilot review)', () => {
4402
+ // Destructured callbacks push '' onto loopParamStack and track their
4403
+ // binding names only in loopBindingStack — the fast-path guard must
4404
+ // scan that stack too, or the outer `const id = 7` inlines at both
4405
+ // the key and text positions.
4406
+ const { template } = compileAndGenerate(`
4407
+ 'use client'
4408
+ import { createSignal } from '@barefootjs/client'
4409
+ export function List() {
4410
+ const id = 7
4411
+ const [items] = createSignal<{ id: number }[]>([{ id: 2 }, { id: 5 }])
4412
+ return <ul>{items().map(({ id }) => <li key={id}>{id}</li>)}</ul>
4413
+ }
4414
+ `)
4415
+ expect(template).toContain('data-key="{{$__bf_item0.ID}}"')
4416
+ expect(template).toContain('{{$__bf_item0.ID}}{{bfTextEnd}}')
4417
+ expect(template).not.toContain('{{7}}')
4418
+ })
4419
+
4420
+ test('record-member fast path: a module object const shadowed by the callback param resolves per-item (loop-param-shadows-record-const fixture)', () => {
4421
+ // resolveStaticRecordLiteralIndex covers IDENT['key'] AND IDENT.key —
4422
+ // the record-member sibling of the bare-identifier gap above. Without
4423
+ // the isLoopShadowedName guard it baked {{"outer-lit"}} into every
4424
+ // iteration.
4425
+ const { template } = compileAndGenerate(`
4426
+ const cfg = { x: 'outer-lit' }
4427
+ export function List({ rows }: { rows: { id: number; x: string }[] }) {
4428
+ return <ul>{rows.map((cfg) => <li key={cfg.id}>{cfg.x}</li>)}</ul>
4429
+ }
4430
+ `)
4431
+ expect(template).toContain('{{range $_, $cfg := .Rows}}')
4432
+ expect(template).toContain('{{.X}}')
4433
+ expect(template).not.toContain('outer-lit')
4434
+ })
4435
+
4436
+ test('slice B: `1 + label` inside the loop that shadows an outer string prop lowers to bf_add, not bf_concat_str', () => {
4437
+ const { template } = compileAndGenerate(`
4438
+ 'use client'
4439
+ export function Labels({ label, values }: { label: string; values: number[] }) {
4440
+ return <ul>{values.map((label) => <li key={label}>{1 + label}</li>)}</ul>
4441
+ }
4442
+ `)
4443
+ expect(template).toContain('{{bf_add 1 .}}')
4444
+ expect(template).not.toContain('bf_concat_str')
4445
+ })
4446
+
4447
+ test('regression pin: a const inlined OUTSIDE any loop still inlines (slice A must not over-guard)', () => {
4448
+ const { template } = compileAndGenerate(`
4449
+ export function Foo() {
4450
+ const total = 5
4451
+ return <div data-key={total}>{total}</div>
4452
+ }
4453
+ `)
4454
+ expect(template).toContain('data-key="{{5}}"')
4455
+ expect(template).toContain('{{5}}</div>')
4456
+ })
4457
+
4458
+ test('regression pin: a genuinely string-typed `+` OUTSIDE the loop still lowers to bf_concat_str (slice B must not over-guard)', () => {
4459
+ const { template } = compileAndGenerate(`
4460
+ export function Foo({ label }: { label: string }) {
4461
+ const suffix = '!'
4462
+ return <p>{label + suffix}</p>
4463
+ }
4464
+ `)
4465
+ expect(template).toContain('bf_concat_str .Label .Suffix')
4466
+ })
4467
+
4468
+ test('local-const operand carries the concat classification when the prop operand is coarsely excluded (fixture shape)', () => {
4469
+ // The `loop-param-shadows-outer-name` fixture's combined shape: `label`
4470
+ // is loop-bound below, so the coarse exclusion strips it from the string
4471
+ // set — the OUTER `{label + suffix}` must then classify as concat via
4472
+ // its `suffix = '!'` local-const operand (the #2212 local-const
4473
+ // inclusion), or it would fall back to `bf_add` and render `0`.
4474
+ const { template } = compileAndGenerate(`
4475
+ 'use client'
4476
+ import { createSignal } from '@barefootjs/client'
4477
+ export function Both({ label, values }: { label: string; values: number[] }) {
4478
+ const suffix = '!'
4479
+ const [n, setN] = createSignal(0)
4480
+ return (
4481
+ <div data-n={n()} onClick={() => setN(n() + 1)}>
4482
+ <p>{label + suffix}</p>
4483
+ <ul>{values.map((label) => <li key={label}>{1 + label}</li>)}</ul>
4484
+ </div>
4485
+ )
4486
+ }
4487
+ `)
4488
+ // Outside the loop: still string concat, carried by the const operand.
4489
+ expect(template).toContain('bf_concat_str .Label .Suffix')
4490
+ // Inside the loop: the shadowed occurrence stays numeric.
4491
+ expect(template).toContain('{{bf_add 1 .}}')
4492
+ })
4493
+
4494
+ // Go's slice-A guard is scope-PRECISE: it consults the live
4495
+ // `loopParamStack`, not a flat component-wide name set, so a const whose
4496
+ // name is loop-bound ELSEWHERE in the component still inlines at an
4497
+ // occurrence that is genuinely outside any loop. This is a real point of
4498
+ // divergence from the Twig-family adapters' coarse `collectLoopBoundNames`
4499
+ // trade-off — pinned here so a future "fix" doesn't accidentally coarsen
4500
+ // Go's precise guard to match them.
4501
+ test('slice A guard is scope-precise: a name that is loop-bound elsewhere still inlines outside the loop', () => {
4502
+ const { template } = compileAndGenerate(`
4503
+ export function Foo({ items }: { items: number[] }) {
4504
+ const count = 9
4505
+ return <div>
4506
+ <p data-key={count}>{count}</p>
4507
+ <ul>{items.map(count => <li key={count}>{count}</li>)}</ul>
4508
+ </div>
4509
+ }
4510
+ `)
4511
+ // Outside the loop, `count` still resolves to the outer literal `9`.
4512
+ expect(template).toContain('data-key="{{9}}"')
4513
+ // Inside the loop, the shadowed occurrence still resolves to the loop
4514
+ // value, not the outer literal.
4515
+ expect(template).toContain('{{range $_, $count := .Items}}<li data-key="{{.}}">')
4516
+ })
4517
+
4518
+ // Slice B's guard, by contrast, is the coarse #2212 trade-off (a flat,
4519
+ // scope-blind `Set<string>` with the loop-bound name subtracted
4520
+ // component-wide): a genuinely non-shadowed occurrence OUTSIDE the loop,
4521
+ // whose name happens to be loop-bound elsewhere, also loses its
4522
+ // string-typed classification and falls back to numeric `bf_add`. This is
4523
+ // the accepted, already-documented residual (same as Blade's #2212
4524
+ // comment) — the suppressed case is safe (numeric fallback, never
4525
+ // silently-wrong string output), just imprecise.
4526
+ test('slice B guard is coarse (accepted #2212 trade-off): a string prop shadowed elsewhere loses bf_concat_str even outside the loop', () => {
4527
+ const { template } = compileAndGenerate(`
4528
+ export function Foo({ count, arr }: { count: string; arr: number[] }) {
4529
+ return <div>
4530
+ <p>{1 + count}</p>
4531
+ <ul>{arr.map((count) => <li key={count}>{count}</li>)}</ul>
4532
+ </div>
4533
+ }
4534
+ `)
4535
+ // Coarse trade-off: falls back to numeric bf_add outside the loop too,
4536
+ // even though this occurrence of `count` is the genuinely string-typed
4537
+ // outer prop, not shadowed.
4538
+ expect(template).toContain('bf_add 1 .Count')
4539
+ expect(template).not.toContain('bf_concat_str')
4540
+ })
4541
+
4542
+ test('end-to-end via real `go run`: shadowed const/type resolution renders correct HTML', async () => {
4543
+ try {
4544
+ const html = await renderGoTemplateComponent({
4545
+ source: `
4546
+ 'use client'
4547
+ import { createSignal } from '@barefootjs/client'
4548
+ export function List() {
4549
+ const count = 7
4550
+ const [nums] = createSignal<number[]>([2, 5])
4551
+ return <ul>{nums().map((count) => <li key={count}>{count * 3}</li>)}</ul>
4552
+ }
4553
+ `,
4554
+ adapter: new GoTemplateAdapter(),
4555
+ props: {},
4556
+ })
4557
+ // The outer `const count = 7` must never leak into the rendered
4558
+ // per-item output — each `<li>` carries its OWN loop value (2, 5),
4559
+ // not the constant 7.
4560
+ expect(html).toContain('<li data-key="2"><!--bf:s0-->6<!--/--></li>')
4561
+ expect(html).toContain('<li data-key="5"><!--bf:s0-->15<!--/--></li>')
4562
+ expect(html).not.toContain('data-key="7"')
4563
+ } catch (err) {
4564
+ if (err instanceof GoNotAvailableError) {
4565
+ console.log('Skipping #2236 e2e: go command not found')
4566
+ return
4567
+ }
4568
+ throw err
4569
+ }
4570
+ })
4571
+ })