@barefootjs/go-template 0.29.0 → 0.30.2

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.
@@ -1461,15 +1461,15 @@ function C({ rows }: { rows?: number }) {
1461
1461
 
1462
1462
  test('leaves a concrete/defaulted attr unconditional (scope did not widen)', () => {
1463
1463
  const source = `
1464
- function C({ value = '' }: { value?: string }) {
1465
- return <textarea value={value} />
1464
+ function C({ placeholder = '' }: { placeholder?: string }) {
1465
+ return <textarea placeholder={placeholder} />
1466
1466
  }
1467
1467
  `
1468
1468
  const { template } = compileAndGenerate(source)
1469
- // `value` has a destructure default → concrete `string` field →
1470
- // never nil → emitted unconditionally, exactly like Hono's value="".
1471
- expect(template).toContain('value="{{.Value}}"')
1472
- expect(template).not.toContain('if ne .Value nil')
1469
+ // `placeholder` has a destructure default → concrete `string` field →
1470
+ // never nil → emitted unconditionally, exactly like Hono's placeholder="".
1471
+ expect(template).toContain('placeholder="{{.Placeholder}}"')
1472
+ expect(template).not.toContain('if ne .Placeholder nil')
1473
1473
  })
1474
1474
  })
1475
1475
 
@@ -4166,6 +4166,626 @@ export function TodoList(props: { todos?: Todo[] }) {
4166
4166
  })
4167
4167
  })
4168
4168
 
4169
+ // #2445: a child component nested inside a composite loop row (row root is a
4170
+ // plain element, #2130's shape) gets ONE hoisted `.{Name}SlotN` props value
4171
+ // built outside `{{range}}` (see the describe block above) — correct for a
4172
+ // prop that doesn't depend on the row, but stale for one that does
4173
+ // (`text={row.label}` read the same value on every row, always the zero
4174
+ // value, since the constructor has no per-row data to give it). Fixed by
4175
+ // reapplying the row-dependent prop per row, at template-execution time,
4176
+ // via `bf_with_props` — the props-argument sibling of `bf_with_children`.
4177
+ describe('GoTemplateAdapter - #2445 composite loop row: per-row child prop', () => {
4178
+ // These compile through `compileJSX` directly (not the `compileToIR` +
4179
+ // `adapter.generate(ir)` two-step other tests in this file use) because
4180
+ // the fixture's child (`Badge`) is a SAME-FILE sibling of the parent
4181
+ // (a sibling-MODULE child inside a loop is refused by BF103) — the debug
4182
+ // `ir.json` `compileToIR` reads back only carries one component's IR, and
4183
+ // `IRProp.freeIdentifiers` (a `Set`) doesn't survive that JSON round trip
4184
+ // anyway. `compileJSX`'s own `types`/`markedTemplate` outputs already
4185
+ // combine both components' generated code in one pass, matching how the
4186
+ // real pipeline (and the `composite-row-child-component` fixture) compiles
4187
+ // this shape.
4188
+ test('a prop reading the row is reapplied per row via bf_with_props', () => {
4189
+ const result = compileJSX(`
4190
+ 'use client'
4191
+ import { createSignal } from '@barefootjs/client'
4192
+ type Item = { id: number; label: string }
4193
+ function Badge(props: { text: string }) {
4194
+ return <span class="badge">{props.text}</span>
4195
+ }
4196
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4197
+ const [rows] = createSignal<Item[]>(props.items)
4198
+ return (
4199
+ <ul>
4200
+ {rows().map(row => (
4201
+ <li key={row.id}>
4202
+ <Badge text={row.label} />
4203
+ </li>
4204
+ ))}
4205
+ </ul>
4206
+ )
4207
+ }
4208
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4209
+ const types = result.files.find(f => f.type === 'types')!.content
4210
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4211
+
4212
+ // Range still iterates the real collection — this is the #2130 shape,
4213
+ // not the wrapper-slice one — and the shared base instance is still
4214
+ // built once, carrying the (correct) constructor-derived scope id.
4215
+ expect(template).toContain(':= .Rows}}')
4216
+ expect(types).toContain('BadgeSlot0 BadgeProps')
4217
+ expect(types).toContain('BadgeSlot0: NewBadgeProps(BadgeInput{')
4218
+
4219
+ // The row-dependent prop is reapplied per row, inside `{{range}}`.
4220
+ expect(template).toContain('{{template "Badge" (bf_with_props $.BadgeSlot0 "Text" .Label)}}')
4221
+ })
4222
+
4223
+ // Negative pin — the whole point of the loop-dependence gate: a prop that
4224
+ // does NOT read the row is left on the constructor-only path, so this
4225
+ // fix doesn't touch (and can't regress) the corpus of composite-loop
4226
+ // fixtures whose nested-child props are static/prop-derived.
4227
+ test('a prop NOT reading the row stays on the constructor-only path', () => {
4228
+ const result = compileJSX(`
4229
+ 'use client'
4230
+ import { createSignal } from '@barefootjs/client'
4231
+ type Item = { id: number; label: string }
4232
+ function Badge(props: { text: string }) {
4233
+ return <span class="badge">{props.text}</span>
4234
+ }
4235
+ export function CompositeRowChildComponent(props: { items: Item[]; title: string }) {
4236
+ const [rows] = createSignal<Item[]>(props.items)
4237
+ return (
4238
+ <ul>
4239
+ {rows().map(row => (
4240
+ <li key={row.id}>
4241
+ <Badge text={props.title} />
4242
+ </li>
4243
+ ))}
4244
+ </ul>
4245
+ )
4246
+ }
4247
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4248
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4249
+ expect(template).toContain('{{template "Badge" $.BadgeSlot0}}')
4250
+ expect(template).not.toContain('bf_with_props')
4251
+ })
4252
+
4253
+ // Composition pin: a nested child with both a per-row prop AND JSX
4254
+ // children nests `bf_with_props` inside `bf_with_children` — the shared
4255
+ // base instance is overridden with the row's prop value first, then that
4256
+ // result has its per-row children injected.
4257
+ test('a per-row prop composes with per-row JSX children', () => {
4258
+ const result = compileJSX(`
4259
+ 'use client'
4260
+ import { createSignal } from '@barefootjs/client'
4261
+ type Item = { id: number; label: string; hint: string }
4262
+ function Badge(props: { title: string; children?: any }) {
4263
+ return <span class="badge" title={props.title}>{props.children}</span>
4264
+ }
4265
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4266
+ const [rows] = createSignal<Item[]>(props.items)
4267
+ return (
4268
+ <ul>
4269
+ {rows().map(row => (
4270
+ <li key={row.id}>
4271
+ <Badge title={row.hint}>{row.label}</Badge>
4272
+ </li>
4273
+ ))}
4274
+ </ul>
4275
+ )
4276
+ }
4277
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4278
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4279
+ expect(template).toContain(
4280
+ '(bf_with_children (bf_with_props $.BadgeSlot1 "Title" .Hint) (bf_tmpl "',
4281
+ )
4282
+ })
4283
+
4284
+ // End-to-end on real Go: each row's Badge must render that row's OWN
4285
+ // label, not the same (previously always-empty) value for every row.
4286
+ test('each row renders its own label on real Go', async () => {
4287
+ let html: string
4288
+ try {
4289
+ html = await renderGoTemplateComponent({
4290
+ source: `
4291
+ 'use client'
4292
+ import { createSignal } from '@barefootjs/client'
4293
+ type Item = { id: number; label: string }
4294
+ function Badge(props: { text: string }) {
4295
+ return <span class="badge">{props.text}</span>
4296
+ }
4297
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4298
+ const [rows] = createSignal<Item[]>(props.items)
4299
+ return (
4300
+ <ul>
4301
+ {rows().map(row => (
4302
+ <li key={row.id}>
4303
+ <Badge text={row.label} />
4304
+ </li>
4305
+ ))}
4306
+ </ul>
4307
+ )
4308
+ }
4309
+ `.trimStart(),
4310
+ adapter: new GoTemplateAdapter(),
4311
+ props: { items: [{ id: 1, label: 'one' }, { id: 2, label: 'two' }] },
4312
+ })
4313
+ } catch (err) {
4314
+ if (err instanceof GoNotAvailableError) return
4315
+ throw err
4316
+ }
4317
+ expect(html).toContain('>one<')
4318
+ expect(html).toContain('>two<')
4319
+ expect(html).toContain('class="badge"')
4320
+ })
4321
+
4322
+ // A per-row prop shaped as a ternary (`row.on ? "yes" : "no"`) parses to a
4323
+ // ParsedExpr `template-literal` whose sole part is the ternary — the
4324
+ // shared `templateLiteral()` emitter wraps that single dynamic part's
4325
+ // already-bare `(bf_ternary ...)` pipeline value in a `{{...}}` shell for
4326
+ // TEXT-position embedding (#2335). `loopRowChildPropOverrides` needs the
4327
+ // BARE value (a function-argument position, not text), so it must unwrap
4328
+ // this specific single-part shape rather than refuse it as a fragment.
4329
+ test('a ternary per-row prop unwraps to a bare bf_ternary pipeline argument', () => {
4330
+ const result = compileJSX(`
4331
+ 'use client'
4332
+ import { createSignal } from '@barefootjs/client'
4333
+ type Item = { id: number; on: boolean }
4334
+ function Badge(props: { text: string }) {
4335
+ return <span class="badge">{props.text}</span>
4336
+ }
4337
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4338
+ const [rows] = createSignal<Item[]>(props.items)
4339
+ return (
4340
+ <ul>
4341
+ {rows().map(row => (
4342
+ <li key={row.id}>
4343
+ <Badge text={row.on ? "yes" : "no"} />
4344
+ </li>
4345
+ ))}
4346
+ </ul>
4347
+ )
4348
+ }
4349
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4350
+ expect(result.errors ?? []).toEqual([])
4351
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4352
+ expect(template).toContain(
4353
+ '{{template "Badge" (bf_with_props $.BadgeSlot0 "Text" (bf_ternary (bf_truthy .On) "yes" "no"))}}',
4354
+ )
4355
+ })
4356
+
4357
+ // A multi-part template literal (mixed literal text and interpolation,
4358
+ // `` `#${row.id} ${row.label}` ``) has no single-pipeline-value reduction
4359
+ // the way a single-part ternary does — refuse it loudly (BF101) instead
4360
+ // of silently emitting the stale constructor-only value (the #2445 bug
4361
+ // this whole fix exists to close).
4362
+ test('a multi-part template-literal per-row prop is refused with BF101, not silently stale', () => {
4363
+ const result = compileJSX(`
4364
+ 'use client'
4365
+ import { createSignal } from '@barefootjs/client'
4366
+ type Item = { id: number; label: string }
4367
+ function Badge(props: { text: string }) {
4368
+ return <span class="badge">{props.text}</span>
4369
+ }
4370
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4371
+ const [rows] = createSignal<Item[]>(props.items)
4372
+ return (
4373
+ <ul>
4374
+ {rows().map(row => (
4375
+ <li key={row.id}>
4376
+ <Badge text={\`#\${row.id} \${row.label}\`} />
4377
+ </li>
4378
+ ))}
4379
+ </ul>
4380
+ )
4381
+ }
4382
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4383
+ expect((result.errors ?? []).some(e => e.code === 'BF101')).toBe(true)
4384
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4385
+ expect(template).not.toContain('bf_with_props')
4386
+ })
4387
+
4388
+ // A prop whose expression `convertExpressionToGo` itself refuses (an
4389
+ // inline object literal isn't a supported value shape) must not ALSO
4390
+ // clobber the child with a bad `bf_with_props` pipeline argument built
4391
+ // from the `""` error sentinel — that would either silently blank a
4392
+ // string field or fail at template EXECUTE time for a non-string one.
4393
+ // The existing BF101 from the unsupported expression is enough; the prop
4394
+ // simply stays on the (already-existing, unchanged) constructor path.
4395
+ test('an unsupported per-row prop expression does not also emit a bad pipeline argument', () => {
4396
+ const result = compileJSX(`
4397
+ 'use client'
4398
+ import { createSignal } from '@barefootjs/client'
4399
+ type Item = { id: number; label: string }
4400
+ function Badge(props: { opts: any }) {
4401
+ return <span class="badge">{JSON.stringify(props.opts)}</span>
4402
+ }
4403
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4404
+ const [rows] = createSignal<Item[]>(props.items)
4405
+ return (
4406
+ <ul>
4407
+ {rows().map(row => (
4408
+ <li key={row.id}>
4409
+ <Badge opts={{ align: row.label }} />
4410
+ </li>
4411
+ ))}
4412
+ </ul>
4413
+ )
4414
+ }
4415
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4416
+ const bf101s = (result.errors ?? []).filter(e => e.code === 'BF101')
4417
+ expect(bf101s).toHaveLength(1)
4418
+ // Copilot review (PR #2451): the error must point at the prop's own
4419
+ // source location, not `convertExpressionToGo`'s internal `makeLoc()`
4420
+ // placeholder (always line 1, column 0) — the loop-dependence gate
4421
+ // repoints it before continuing.
4422
+ expect(bf101s[0]!.loc.start.line).toBeGreaterThan(1)
4423
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4424
+ expect(template).toContain('{{template "Badge" $.BadgeSlot0}}')
4425
+ expect(template).not.toContain('bf_with_props')
4426
+ })
4427
+
4428
+ // A prop that routes into the child's rest bag (not a declared param) has
4429
+ // no named Go field for `bf_with_props` to override — it must stay on the
4430
+ // constructor path rather than emit a pipeline argument that can only
4431
+ // ever no-op at the runtime helper's unknown-field passthrough. Requires
4432
+ // `registerChildComponentShape` (normally the CLI's cross-file pre-pass,
4433
+ // #2131) so the adapter actually knows `Badge`'s rest-bag shape.
4434
+ test('a rest-bag-routed per-row prop stays on the constructor path', () => {
4435
+ // Mirrors the `registerChildComponentShape` pattern the `#2087` test
4436
+ // above uses: a bare `compileJSX` never calls that hook (only the CLI's
4437
+ // cross-file pre-pass, #2131, does), so this builds each component's IR
4438
+ // directly and registers `Badge`'s rest-bag shape before generating the
4439
+ // parent — the real order `renderGoTemplateComponent` / `bf build` use.
4440
+ const source = `
4441
+ 'use client'
4442
+ import { createSignal } from '@barefootjs/client'
4443
+ type Item = { id: number; label: string }
4444
+ function Badge({ text, ...rest }: { text: string; [key: string]: unknown }) {
4445
+ return <span class="badge" {...rest}>{text}</span>
4446
+ }
4447
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4448
+ const [rows] = createSignal<Item[]>(props.items)
4449
+ return (
4450
+ <ul>
4451
+ {rows().map(row => (
4452
+ <li key={row.id}>
4453
+ <Badge text="static" tone={row.label} />
4454
+ </li>
4455
+ ))}
4456
+ </ul>
4457
+ )
4458
+ }
4459
+ `.trimStart()
4460
+
4461
+ const badgeCtx = analyzeComponent(source, 'test.tsx', 'Badge')
4462
+ const badgeIR: ComponentIR = {
4463
+ version: '0.1',
4464
+ metadata: buildMetadata(badgeCtx),
4465
+ root: jsxToIR(badgeCtx)!,
4466
+ errors: [],
4467
+ }
4468
+ const rootCtx = analyzeComponent(source, 'test.tsx', 'CompositeRowChildComponent')
4469
+ const rootIR: ComponentIR = {
4470
+ version: '0.1',
4471
+ metadata: buildMetadata(rootCtx),
4472
+ root: jsxToIR(rootCtx)!,
4473
+ errors: [],
4474
+ }
4475
+
4476
+ const adapter = new GoTemplateAdapter()
4477
+ adapter.registerChildComponentShape(badgeIR)
4478
+ adapter.generateTypes(badgeIR)
4479
+ adapter.generateTypes(rootIR)
4480
+ const template = adapter.generate(rootIR, { skipScriptRegistration: true }).template
4481
+
4482
+ // `tone` isn't a declared `Badge` param, so it routes into the rest bag
4483
+ // (`emitChildField`'s rule) — no named Go field for `bf_with_props` to
4484
+ // override, so the call stays the bare shared-instance reference.
4485
+ expect(template).toContain('{{template "Badge" $.BadgeSlot0}}')
4486
+ expect(template).not.toContain('bf_with_props')
4487
+ })
4488
+ })
4489
+
4490
+ // #2448: `bf_with_props` (#2445, above) overrides fields on the ALREADY-
4491
+ // CONSTRUCTED shared instance — it does not re-run `New<Child>Props`. When
4492
+ // the overridden prop feeds a child field the constructor DERIVES at
4493
+ // construction time (a `createMemo` body or a `createSignal` initial value —
4494
+ // `New<Child>Props` bakes both), patching fields would leave that field
4495
+ // holding the shared instance's one-shot value on every row.
4496
+ //
4497
+ // The child therefore gets a generated props REBUILDER, registered from the
4498
+ // generated package's `init()`, and the call site emits `bf_reprops` — which
4499
+ // re-runs the real constructor per row so every derived field recomputes.
4500
+ // BF101 remains only for shapes the rebuilder declines.
4501
+ const DERIVED_PROP_SOURCE = `
4502
+ 'use client'
4503
+ import { createSignal, createMemo } from '@barefootjs/client'
4504
+ type Row = { id: number; label: string; n: number }
4505
+ function Badge(props: { text: string; n: number }) {
4506
+ const dbl = createMemo(() => props.n * 2)
4507
+ return <span class="badge">{props.text}:{dbl()}</span>
4508
+ }
4509
+ export function CompositeRowChildDerivedProp(props: { rows: Row[] }) {
4510
+ const [rows] = createSignal<Row[]>(props.rows)
4511
+ return (
4512
+ <ul>
4513
+ {rows().map(row => (
4514
+ <li key={row.id}>
4515
+ <Badge text={row.label} n={row.n} />
4516
+ </li>
4517
+ ))}
4518
+ </ul>
4519
+ )
4520
+ }
4521
+ `.trimStart()
4522
+
4523
+ describe('GoTemplateAdapter - #2448 per-row override of a prop feeding a derived child field', () => {
4524
+ // Two-phase build (mirrors the rest-bag test's pattern above): the adapter
4525
+ // instance here never compiles `Badge` through `generate()`, so the explicit
4526
+ // `generateTypes(badgeIR)` is what emits its rebuilder and marks it ready.
4527
+ // (Under `compileJSX` — the tests below — the same-file child is generated
4528
+ // first and self-registers, so both doors are exercised.)
4529
+ test('a per-row-overridden prop feeding a memo field rebuilds the child per row', () => {
4530
+ const badgeCtx = analyzeComponent(DERIVED_PROP_SOURCE, 'test.tsx', 'Badge')
4531
+ const badgeIR: ComponentIR = {
4532
+ version: '0.1',
4533
+ metadata: buildMetadata(badgeCtx),
4534
+ root: jsxToIR(badgeCtx)!,
4535
+ errors: [],
4536
+ }
4537
+ const rootCtx = analyzeComponent(DERIVED_PROP_SOURCE, 'test.tsx', 'CompositeRowChildDerivedProp')
4538
+ const rootIR: ComponentIR = {
4539
+ version: '0.1',
4540
+ metadata: buildMetadata(rootCtx),
4541
+ root: jsxToIR(rootCtx)!,
4542
+ errors: [],
4543
+ }
4544
+
4545
+ const adapter = new GoTemplateAdapter()
4546
+ adapter.registerChildComponentShape(badgeIR)
4547
+ // Generating `Badge`'s types RECORDS that a rebuilder is possible; it does
4548
+ // not emit one. Only a parent knows whether the child is actually
4549
+ // overridden per row, so the registration rides the PARENT's type block.
4550
+ const badgeTypes = adapter.generateTypes(badgeIR)
4551
+ expect(badgeTypes).not.toContain('RegisterReprops')
4552
+
4553
+ const { template, types } = adapter.generate(rootIR, { skipScriptRegistration: true })
4554
+
4555
+ expect(rootIR.errors.filter(e => e.code === 'BF101')).toHaveLength(0)
4556
+ // BOTH per-row props ride the rebuild — `n` is no longer dropped.
4557
+ expect(template).toContain('{{template "Badge" (bf_reprops "Badge" $.BadgeSlot0 "Text" .Label "N" .N)}}')
4558
+
4559
+ // The rebuilder reconstructs the Input from the base Props, applies the
4560
+ // row's overrides, and re-runs the real constructor. `Badge`'s own types
4561
+ // are in the same Go package (`combineGoTypes`), so they're in scope here.
4562
+ expect(types).toContain('bf.RegisterReprops("Badge"')
4563
+ expect(types).toContain('p := NewBadgeProps(in)')
4564
+ expect(types).toContain('err = bf.RepropsAssign("Badge", "N", &in.N, kv[i+1])')
4565
+ // Identity is carried over, never re-derived: NewBadgeProps mints a random
4566
+ // ScopeID when handed an empty one, so re-running it without this would
4567
+ // give every row its own scope and break hydration.
4568
+ expect(types).toContain('ScopeID: b.ScopeID,')
4569
+ expect(types).toContain('p.Scripts = b.Scripts')
4570
+ })
4571
+
4572
+ // A `createSignal` INITIAL VALUE is baked into `New<Child>Props` exactly
4573
+ // like a memo body is (`const [dbl] = createSignal(props.n)` emits
4574
+ // `Dbl: in.N`), so it takes the same rebuild path. Compiled through
4575
+ // `compileJSX` so this also pins `generate()`'s self-registration door.
4576
+ test("a per-row-overridden prop feeding a signal's initial value rebuilds too", () => {
4577
+ const result = compileJSX(`
4578
+ 'use client'
4579
+ import { createSignal } from '@barefootjs/client'
4580
+ type Row = { id: number; label: string; n: number }
4581
+ function Badge(props: { text: string; n: number }) {
4582
+ const [dbl] = createSignal(props.n)
4583
+ return <span class="badge">{props.text}:{dbl()}</span>
4584
+ }
4585
+ export function SignalRowChildDerivedProp(props: { rows: Row[] }) {
4586
+ const [rows] = createSignal<Row[]>(props.rows)
4587
+ return (
4588
+ <ul>
4589
+ {rows().map(row => (
4590
+ <li key={row.id}>
4591
+ <Badge text={row.label} n={row.n} />
4592
+ </li>
4593
+ ))}
4594
+ </ul>
4595
+ )
4596
+ }
4597
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4598
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4599
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4600
+ expect(template).toContain('(bf_reprops "Badge" $.BadgeSlot0 "Text" .Label "N" .N)')
4601
+ })
4602
+
4603
+ // An ALIASED destructure (`{ n: count }`) is where the JSX attribute name
4604
+ // (`n` → `"N"`) and the child's own Go field (`Count`) diverge. #2457: the
4605
+ // reconciliation now happens ONCE, at the PARENT's emission site
4606
+ // (`loopRowChildPropOverrides`, via `childPropFieldNames`) — the parent
4607
+ // emits the child's own field name directly, so the rebuilder's switch
4608
+ // (`recordRepropsSpec` / `emitRepropsRegistration`) needs no separate
4609
+ // "wire" name and is keyed uniformly by that one field on both sides.
4610
+ test('an aliased destructured prop maps the parent name onto the child field', () => {
4611
+ const result = compileJSX(`
4612
+ 'use client'
4613
+ import { createSignal, createMemo } from '@barefootjs/client'
4614
+ type Row = { id: number; label: string; n: number }
4615
+ function Badge({ text, n: count }: { text: string; n: number }) {
4616
+ const dbl = createMemo(() => count * 2)
4617
+ return <span class="badge">{text}:{dbl()}</span>
4618
+ }
4619
+ export function AliasedRowChildDerivedProp(props: { rows: Row[] }) {
4620
+ const [rows] = createSignal<Row[]>(props.rows)
4621
+ return (
4622
+ <ul>
4623
+ {rows().map(row => (
4624
+ <li key={row.id}>
4625
+ <Badge text={row.label} n={row.n} />
4626
+ </li>
4627
+ ))}
4628
+ </ul>
4629
+ )
4630
+ }
4631
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4632
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4633
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4634
+ // The parent now writes the child's OWN field name ("Count"), not the
4635
+ // JSX attribute ("N").
4636
+ expect(template).toContain('(bf_reprops "Badge" $.BadgeSlot0 "Text" .Label "Count" .N)')
4637
+ const types = result.files.find(f => f.type === 'types')!.content
4638
+ expect(types).toContain('case "Count":')
4639
+ expect(types).toContain('&in.Count,')
4640
+ // "N" — the JSX attribute name — is never a case label: the switch is
4641
+ // keyed by the child's field on both sides, and the parent already
4642
+ // resolved the name before emitting the call.
4643
+ expect(types).not.toContain('case "N":')
4644
+ })
4645
+
4646
+ // #2457: the SAME aliased destructure, but with NO derived field — the
4647
+ // shape `bf_with_props` mishandled even before #2448's rebuilder existed.
4648
+ // `bf.WithProps` documents an unknown-field pair as a silent passthrough,
4649
+ // so emitting the JSX attribute name ("N") against a child whose field is
4650
+ // "Count" used to drop the override with no diagnostic on every row. No
4651
+ // rebuilder is needed here — resolving the field name at the parent's
4652
+ // emission site is enough on its own.
4653
+ test('an aliased destructured prop with no derived field still lands on the child field', () => {
4654
+ const result = compileJSX(`
4655
+ 'use client'
4656
+ import { createSignal } from '@barefootjs/client'
4657
+ type Row = { id: number; label: string; n: number }
4658
+ function Badge({ text, n: count }: { text: string; n: number }) {
4659
+ return <span class="badge">{text}:{count}</span>
4660
+ }
4661
+ export function AliasedRowChildNoDerivedProp(props: { rows: Row[] }) {
4662
+ const [rows] = createSignal<Row[]>(props.rows)
4663
+ return (
4664
+ <ul>
4665
+ {rows().map(row => (
4666
+ <li key={row.id}>
4667
+ <Badge text={row.label} n={row.n} />
4668
+ </li>
4669
+ ))}
4670
+ </ul>
4671
+ )
4672
+ }
4673
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4674
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4675
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4676
+ // Still bf_with_props (no derived field, so no rebuilder needed) — but
4677
+ // the pair now names a field the child actually has.
4678
+ expect(template).toContain('(bf_with_props $.BadgeSlot0 "Text" .Label "Count" .N)')
4679
+ expect(template).not.toContain('bf_reprops')
4680
+ const types = result.files.find(f => f.type === 'types')!.content
4681
+ expect(types).not.toContain('RegisterReprops')
4682
+ })
4683
+
4684
+ // Copilot review on #2462: `ChildComponentShape.paramNames` is looked up by
4685
+ // the PARENT against the name it wrote at the JSX call site, so it has to be
4686
+ // keyed the same way `childPropFieldNames` is. Keyed by the local binding,
4687
+ // an aliased prop on a child that ALSO has a rest bag looked undeclared —
4688
+ // `loopRowChildPropOverrides`' rest-bag guard skipped it outright, so the
4689
+ // override never reached the template at all. Same wrong-name-at-a-
4690
+ // parent-side-lookup bug as #2457, one guard earlier.
4691
+ test('an aliased prop on a rest-bag child is not mistaken for a rest-bag prop', () => {
4692
+ const result = compileJSX(`
4693
+ 'use client'
4694
+ import { createSignal } from '@barefootjs/client'
4695
+ type Row = { id: number; label: string; n: number }
4696
+ function Badge({ text, n: count, ...rest }: { text: string; n: number; [k: string]: unknown }) {
4697
+ return <span class="badge" {...rest}>{text}:{count}</span>
4698
+ }
4699
+ export function AliasedRestBagRowChild(props: { rows: Row[] }) {
4700
+ const [rows] = createSignal<Row[]>(props.rows)
4701
+ return (
4702
+ <ul>
4703
+ {rows().map(row => (
4704
+ <li key={row.id}>
4705
+ <Badge text={row.label} n={row.n} />
4706
+ </li>
4707
+ ))}
4708
+ </ul>
4709
+ )
4710
+ }
4711
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4712
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4713
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4714
+ // `n` is a DECLARED prop, so it rides the override under the child's own
4715
+ // field name — not silently routed into the rest bag and not dropped.
4716
+ expect(template).toContain('(bf_with_props $.BadgeSlot0 "Text" .Label "Count" .N)')
4717
+ })
4718
+
4719
+ // Sound-or-loud fallback: a shape whose Input can NOT be reconstructed from
4720
+ // Props gets no rebuilder, and the refusal has to stay. A `...rest` bag adds
4721
+ // an Input field with no Props counterpart, so it is declined — and because
4722
+ // `Badge` still derives `dbl` from `n`, emitting `bf_with_props` here would
4723
+ // be the silently-stale output all of this exists to prevent.
4724
+ test('a child whose Input cannot be rebuilt still refuses with BF101', () => {
4725
+ const result = compileJSX(`
4726
+ 'use client'
4727
+ import { createSignal, createMemo } from '@barefootjs/client'
4728
+ type Row = { id: number; label: string; n: number }
4729
+ function Badge({ text, n, ...rest }: { text: string; n: number; [k: string]: unknown }) {
4730
+ const dbl = createMemo(() => n * 2)
4731
+ return <span class="badge" {...rest}>{text}:{dbl()}</span>
4732
+ }
4733
+ export function RestBagRowChildDerivedProp(props: { rows: Row[] }) {
4734
+ const [rows] = createSignal<Row[]>(props.rows)
4735
+ return (
4736
+ <ul>
4737
+ {rows().map(row => (
4738
+ <li key={row.id}>
4739
+ <Badge text={row.label} n={row.n} />
4740
+ </li>
4741
+ ))}
4742
+ </ul>
4743
+ )
4744
+ }
4745
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4746
+ const bf101s = (result.errors ?? []).filter(e => e.code === 'BF101')
4747
+ expect(bf101s).toHaveLength(1)
4748
+ expect(bf101s[0]!.message).toContain("'n'")
4749
+ expect(bf101s[0]!.message).toContain('dbl')
4750
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4751
+ expect(template).not.toContain('bf_reprops')
4752
+ expect(template).not.toContain('"N"')
4753
+ // No rebuilder was emitted, so nothing registers a "Badge" entry that
4754
+ // `bf_reprops` could have resolved at runtime.
4755
+ expect(result.files.find(f => f.type === 'types')!.content).not.toContain('RegisterReprops')
4756
+ })
4757
+
4758
+ // Regression pin for #2445: a nested child with NO derived field (no
4759
+ // `createMemo`/`createSignal` reading the overridden prop) must still
4760
+ // compile clean and still emit `bf_with_props` — this refusal must not fire
4761
+ // on the shape #2445 already fixed.
4762
+ test('a per-row-overridden prop with no derived field still emits bf_with_props', () => {
4763
+ const result = compileJSX(`
4764
+ 'use client'
4765
+ import { createSignal } from '@barefootjs/client'
4766
+ type Item = { id: number; label: string }
4767
+ function Badge(props: { text: string }) {
4768
+ return <span class="badge">{props.text}</span>
4769
+ }
4770
+ export function CompositeRowChildComponent(props: { items: Item[] }) {
4771
+ const [rows] = createSignal<Item[]>(props.items)
4772
+ return (
4773
+ <ul>
4774
+ {rows().map(row => (
4775
+ <li key={row.id}>
4776
+ <Badge text={row.label} />
4777
+ </li>
4778
+ ))}
4779
+ </ul>
4780
+ )
4781
+ }
4782
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
4783
+ expect((result.errors ?? []).filter(e => e.code === 'BF101')).toHaveLength(0)
4784
+ const template = result.files.find(f => f.type === 'markedTemplate')!.content
4785
+ expect(template).toContain('{{template "Badge" (bf_with_props $.BadgeSlot0 "Text" .Label)}}')
4786
+ })
4787
+ })
4788
+
4169
4789
  // #2228: a `.filter(t => …).map(todo => <Child todo={todo} .../>)` loop whose
4170
4790
  // body is a single child component ranges the WRAPPER slice (`.TodoItems`,
4171
4791
  // `.{ChildName}s` — see #2130 above), so `{{if}}`'s dot context for the