@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.
- package/dist/adapter/go-template-adapter.d.ts +224 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +294 -8
- package/dist/adapter/memo/memo-compute.d.ts +36 -0
- package/dist/adapter/memo/memo-compute.d.ts.map +1 -1
- package/dist/adapter/type/type-codegen.d.ts +21 -1
- package/dist/adapter/type/type-codegen.d.ts.map +1 -1
- package/dist/adapter/value/value-lowering.d.ts +1 -1
- package/dist/adapter/value/value-lowering.d.ts.map +1 -1
- package/dist/build.js +294 -8
- package/dist/conformance-pins.d.ts.map +1 -1
- package/dist/index.js +300 -10
- package/dist/render-divergences.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/go-template-adapter.test.ts +626 -6
- package/src/adapter/go-template-adapter.ts +597 -13
- package/src/adapter/memo/memo-compute.ts +125 -0
- package/src/adapter/type/type-codegen.ts +44 -1
- package/src/adapter/value/value-lowering.ts +6 -1
- package/src/conformance-pins.ts +0 -5
- package/src/render-divergences.ts +24 -9
|
@@ -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({
|
|
1465
|
-
return <textarea
|
|
1464
|
+
function C({ placeholder = '' }: { placeholder?: string }) {
|
|
1465
|
+
return <textarea placeholder={placeholder} />
|
|
1466
1466
|
}
|
|
1467
1467
|
`
|
|
1468
1468
|
const { template } = compileAndGenerate(source)
|
|
1469
|
-
// `
|
|
1470
|
-
// never nil → emitted unconditionally, exactly like Hono's
|
|
1471
|
-
expect(template).toContain('
|
|
1472
|
-
expect(template).not.toContain('if ne .
|
|
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
|