@barefootjs/go-template 0.33.4 → 0.33.6

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.
@@ -34,6 +34,7 @@ import type {
34
34
  TemplatePrimitiveRegistry,
35
35
  LoopBindingPathSegment,
36
36
  LoopBindingSource,
37
+ ConstantInfo,
37
38
  } from '@barefootjs/jsx'
38
39
  import {
39
40
  BaseAdapter,
@@ -78,6 +79,7 @@ import {
78
79
  collectLoopBoundNames,
79
80
  evaluateStaticLiteral,
80
81
  BindingScope,
82
+ buildImportAliasMap,
81
83
  } from '@barefootjs/jsx'
82
84
  import { findInterpolationEnd } from '@barefootjs/jsx/scanner'
83
85
  import { BF_REGION, escapeHtml, resolveJsxChildrenProp } from '@barefootjs/shared'
@@ -257,6 +259,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
257
259
  extractPropFallback: (initialValue, preParsed) => this.extractPropFallback(initialValue, preParsed),
258
260
  extractCollisionDerivation: (parsed) => this.extractCollisionDerivation(parsed),
259
261
  resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
262
+ resolveModuleNumericConst: (name) => this.resolveModuleNumericConst(name),
263
+ resolveModuleBooleanConst: (name) => this.resolveModuleBooleanConst(name),
260
264
  }
261
265
 
262
266
  /** Diagnostics from the current compile (backed by `CompileState`); `generate()` also merges these into `ir.errors`. */
@@ -386,6 +390,44 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
386
390
  /** Child component name → the contexts it consumes (cross-component, for provider wiring). */
387
391
  private childContextConsumers: Map<string, ContextConsumer[]> = new Map()
388
392
 
393
+ /**
394
+ * Local alias -> declared/exported name for imported components (#2822,
395
+ * the SSR-side counterpart of #2777's client-JS registry-key fix). A
396
+ * child referenced under an import alias (`import { Foo as Bar }`,
397
+ * `<Bar/>`) has an `IRComponent.name` of `Bar` (the caller-local JSX tag
398
+ * name), but every cross-file lookup this adapter does against the
399
+ * child's OWN registered identity — `childComponentShapes` /
400
+ * `childContextConsumers` / `childDerivedFieldDeps` / `childPropFieldNames`
401
+ * / `childRepropsReady` (all populated from the CHILD's own
402
+ * `ir.metadata.componentName` via `registerChildComponentShape` or the
403
+ * child's own `generate()`/`generateTypes()` pass), the `New<Name>Props`
404
+ * constructor + `<Name>Input`/`<Name>Props` TYPE names, and the
405
+ * `{{template "<Name>" ...}}` cross-template call — must resolve through
406
+ * to `Foo`, the declared name, or they silently miss (a Go compile error
407
+ * for the constructor/type case, a `no such template` render error for
408
+ * the `{{template}}` case). Built once per compile from
409
+ * `ir.metadata.imports` via the shared `buildImportAliasMap`
410
+ * (`@barefootjs/jsx`) and read through `resolveChildName`.
411
+ *
412
+ * Deliberately NOT applied to a component's own PARENT-PRIVATE struct
413
+ * field name (`${comp.name}${suffix}`, `.${comp.name}` field access) —
414
+ * that field is declared and read using the SAME `comp.name` expression
415
+ * within this one parent's own generated code, so it stays internally
416
+ * consistent under the caller-local alias with no cross-file identity to
417
+ * match.
418
+ */
419
+ private importAliases: Map<string, string> = new Map()
420
+
421
+ /**
422
+ * Resolve a component reference's `IRComponent.name` (the caller-local
423
+ * JSX tag / import alias) to the name the referenced child's OWN module
424
+ * registers its Go template/type/constructor under. Identity for an
425
+ * un-aliased reference. See `importAliases`.
426
+ */
427
+ private resolveChildName(name: string): string {
428
+ return this.importAliases.get(name) ?? name
429
+ }
430
+
389
431
 
390
432
  constructor(options: GoTemplateAdapterOptions = {}) {
391
433
  super()
@@ -405,6 +447,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
405
447
  private primeCompileState(ir: ComponentIR): void {
406
448
  this.state.propsObjectName = ir.metadata.propsObjectName
407
449
  this.state.restPropsName = ir.metadata.restPropsName ?? null
450
+ // #2822: this component's OWN import-alias map (local alias -> declared
451
+ // name), read by `resolveChildName` at every cross-file lookup/codegen
452
+ // site below. Re-primed on every `generate()`/`generateTypes()` call —
453
+ // harmless per this method's own docstring, since what matters is that
454
+ // the LAST prime before a given `ir`'s template body actually renders is
455
+ // that same `ir`'s own imports (true here: `generate(ir)` primes then
456
+ // renders `ir`'s body synchronously before any other IR is primed).
457
+ this.importAliases = buildImportAliasMap(ir.metadata.imports ?? [])
408
458
  // Inline-object-typed props (`cfg: { id: number }`) bake as
409
459
  // `map[string]interface{}`; `member()` routes a nested access on them
410
460
  // through `bf_get` rather than an exact-case dot path (#2299).
@@ -487,6 +537,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
487
537
  this.state.componentName = ir.metadata.componentName
488
538
  this.state.errors = []
489
539
  this.state.referencedDerivedConsts = new Set()
540
+ this.state.templateReadRootFields = new Set()
490
541
  this.state.templateVarCounter = 0
491
542
  this.state.pendingChildrenDefines = []
492
543
  this.scope = BindingScope.EMPTY
@@ -532,7 +583,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
532
583
  for (const d of this.state.pendingChildrenDefines) {
533
584
  template += `{{define "${d.name}"}}${d.content}{{end}}\n`
534
585
  }
535
- const types = this.generateTypes(ir)
586
+ const types = this.generateTypes(ir, true)
536
587
 
537
588
  if (this.state.errors.length > 0) {
538
589
  ir.errors.push(...this.state.errors)
@@ -1070,9 +1121,25 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1070
1121
  return desired
1071
1122
  }
1072
1123
 
1073
- generateTypes(ir: ComponentIR): string | null {
1124
+ /**
1125
+ * `preserveTemplateReadRootFields` is set ONLY by `generate()`'s own
1126
+ * internal call below — `templateReadRootFields` is an observation log
1127
+ * `renderNode` populated moments ago while rendering THIS SAME
1128
+ * component's template body, and this call needs to read that log back,
1129
+ * not a freshly emptied one. Every other caller (the standalone public
1130
+ * entry point `test-render.ts` calls directly on an already-`generate()`d
1131
+ * adapter for a sibling/child IR, and this file's own unit tests) omits
1132
+ * it and gets the set reset fresh — otherwise a stale log left over from
1133
+ * whichever OTHER component `generate()` rendered last would silently
1134
+ * narrow #2700's BF101 refusal to a false negative (pullfrog review, PR
1135
+ * #2818).
1136
+ */
1137
+ generateTypes(ir: ComponentIR, preserveTemplateReadRootFields = false): string | null {
1074
1138
  this.state.usesHtmlTemplate = false
1075
1139
  this.state.usesFmt = false
1140
+ if (!preserveTemplateReadRootFields) {
1141
+ this.state.templateReadRootFields = new Set()
1142
+ }
1076
1143
  // Prime identically to `generate()` so the standalone `generateTypes` entry
1077
1144
  // can't drift the structs (e.g. a `{...props}` bag field in one entry only).
1078
1145
  this.primeCompileState(ir)
@@ -1331,17 +1398,29 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1331
1398
  }
1332
1399
 
1333
1400
  /**
1334
- * Synthesise a Go struct from an untyped object-array signal's inline initial
1335
- * value, or `null` (caller keeps `[]interface{}`/`nil`). Requires: untyped
1336
- * array type; a non-empty array literal of object literals; every element
1337
- * sharing the same Go-identifier key set; every value a scalar literal with a
1338
- * per-key-consistent Go type (mixed int/float64 widens to float64). Any
1339
- * deviation, or a name collision with an existing type, returns `null`.
1401
+ * Synthesise a Go struct (plus, per #2800, one nested struct for each
1402
+ * array-of-objects field, recursively) from an untyped object-array
1403
+ * signal's inline initial value, or `null` (caller keeps
1404
+ * `[]interface{}`/`nil`). Requires: untyped array type; a non-empty array
1405
+ * literal of object literals; every element sharing the same
1406
+ * Go-identifier key set; every value EITHER a scalar literal with a
1407
+ * per-key-consistent Go type (mixed int/float64 widens to float64) OR an
1408
+ * array of object literals (recursed the same way). Any deviation, or a
1409
+ * name collision with an existing type, returns `null` for the WHOLE
1410
+ * signal — `parsedLiteralToGo` (`value-lowering.ts`) already defers the
1411
+ * whole array the moment one element fails to bake, so a partial struct
1412
+ * (e.g. only the scalar fields) would synthesize a type nothing could
1413
+ * ever fully populate; not worth a second, more permissive code path.
1414
+ *
1415
+ * Returns the synthesized structs in DEPENDENCY ORDER — nested structs
1416
+ * before the struct(s) that reference them by name — so a caller
1417
+ * registering them in list order never references an undeclared Go type.
1418
+ * The signal's own top-level struct is always the LAST entry.
1340
1419
  */
1341
1420
  private synthesizeStructFromSignal(
1342
1421
  signal: { getter: string; type: TypeInfo; initialValue: string; parsed?: ParsedExpr },
1343
1422
  componentName: string,
1344
- ): { name: string; fields: Array<{ tsName: string; goName: string; goType: string }> } | null {
1423
+ ): Array<{ name: string; fields: Array<{ tsName: string; goName: string; goType: string }>; properties: PropertyInfo[] }> | null {
1345
1424
  // Only untyped arrays: typed (`Item[]`) / scalar (`string[]`) elements bake
1346
1425
  // through the normal path.
1347
1426
  if (signal.type.kind !== 'array') return null
@@ -1351,12 +1430,40 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1351
1430
  const node = signal.parsed
1352
1431
  if (!node || node.kind !== 'array-literal' || node.elements.length === 0) return null
1353
1432
 
1354
- // Field order + per-key Go types from the first element; every other element
1355
- // must match exactly.
1433
+ const name = `${componentName}${capitalizeFieldName(signal.getter)}Item`
1434
+ return this.synthesizeStructsFromElements(node.elements, name)
1435
+ }
1436
+
1437
+ /**
1438
+ * The recursive core of `synthesizeStructFromSignal`: given a list of
1439
+ * object-literal elements known to share ONE shape, and the Go struct
1440
+ * name to assign that shape, returns the synthesized struct(s) — this
1441
+ * shape's own struct last, any nested array-of-objects field's struct(s)
1442
+ * before it — or `null` on any shape this fast path doesn't bake.
1443
+ *
1444
+ * A nested array-of-objects field is validated and shaped from the FLAT
1445
+ * concatenation of that key's elements across every row (not just the
1446
+ * first row) — a later row's own object shape for that key must still
1447
+ * agree, but the nested struct's field set is inferred from every row's
1448
+ * contribution so no row's data is silently dropped from the type.
1449
+ */
1450
+ private synthesizeStructsFromElements(
1451
+ elements: ParsedExpr[],
1452
+ name: string,
1453
+ ): Array<{ name: string; fields: Array<{ tsName: string; goName: string; goType: string }>; properties: PropertyInfo[] }> | null {
1454
+ // Don't shadow an existing (user-defined or already-synthesised) type.
1455
+ if (this.state.localTypeNames.has(name)) return null
1456
+
1457
+ type PropShape = { kind: 'scalar'; goType: string } | { kind: 'nested-array' }
1458
+
1459
+ // Field order + per-key shape from the first element; every other
1460
+ // element must match exactly (same keys, same shape KIND per key).
1356
1461
  const order: string[] = []
1357
- const goTypes = new Map<string, string>()
1358
- for (let i = 0; i < node.elements.length; i++) {
1359
- const el = node.elements[i]
1462
+ const shapes = new Map<string, PropShape>()
1463
+ const nestedElements = new Map<string, ParsedExpr[]>()
1464
+
1465
+ for (let i = 0; i < elements.length; i++) {
1466
+ const el = elements[i]
1360
1467
  if (el.kind !== 'object-literal') return null
1361
1468
  const seen = new Set<string>()
1362
1469
  for (const prop of el.properties) {
@@ -1367,36 +1474,124 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1367
1474
  if (prop.shorthand) return null
1368
1475
  const key = prop.key
1369
1476
  if (!GO_IDENTIFIER.test(key)) return null
1477
+ seen.add(key)
1478
+
1479
+ const isNestedArray =
1480
+ prop.value.kind === 'array-literal' &&
1481
+ prop.value.elements.every(e => e.kind === 'object-literal')
1482
+
1483
+ if (isNestedArray) {
1484
+ const prevShape = shapes.get(key)
1485
+ if (prevShape === undefined) {
1486
+ if (i !== 0) return null // key absent from the first element → shape differs
1487
+ order.push(key)
1488
+ shapes.set(key, { kind: 'nested-array' })
1489
+ nestedElements.set(key, [])
1490
+ } else if (prevShape.kind !== 'nested-array') {
1491
+ return null // this key is a scalar in some rows, a nested array in others
1492
+ }
1493
+ nestedElements.get(key)!.push(...(prop.value as { elements: ParsedExpr[] }).elements)
1494
+ continue
1495
+ }
1496
+
1370
1497
  const goType = this.scalarParsedGoType(prop.value)
1371
1498
  if (!goType) return null
1372
- seen.add(key)
1373
- const prev = goTypes.get(key)
1374
- if (prev === undefined) {
1375
- if (i !== 0) return null // key absent from the first element → shape differs
1499
+ const prevShape = shapes.get(key)
1500
+ if (prevShape === undefined) {
1501
+ if (i !== 0) return null
1376
1502
  order.push(key)
1377
- goTypes.set(key, goType)
1503
+ shapes.set(key, { kind: 'scalar', goType })
1504
+ } else if (prevShape.kind !== 'scalar') {
1505
+ return null
1378
1506
  } else {
1379
- const merged = this.mergeScalarGoType(prev, goType)
1507
+ const merged = this.mergeScalarGoType(prevShape.goType, goType)
1380
1508
  if (!merged) return null
1381
- goTypes.set(key, merged)
1509
+ shapes.set(key, { kind: 'scalar', goType: merged })
1382
1510
  }
1383
1511
  }
1384
1512
  // A first-element key missing here → shape differs.
1385
1513
  if (seen.size !== order.length) return null
1386
1514
  }
1387
1515
 
1388
- const name = `${componentName}${capitalizeFieldName(signal.getter)}Item`
1389
- // Don't shadow an existing (user-defined or already-synthesised) type.
1390
- if (this.state.localTypeNames.has(name)) return null
1516
+ const nestedStructs: Array<{ name: string; fields: Array<{ tsName: string; goName: string; goType: string }>; properties: PropertyInfo[] }> = []
1517
+ const fields: Array<{ tsName: string; goName: string; goType: string }> = []
1518
+ const properties: PropertyInfo[] = []
1391
1519
 
1392
- return {
1393
- name,
1394
- fields: order.map(key => ({
1395
- tsName: key,
1396
- goName: capitalizeFieldName(key),
1397
- goType: goTypes.get(key)!,
1398
- })),
1520
+ for (const key of order) {
1521
+ const shape = shapes.get(key)!
1522
+ if (shape.kind === 'scalar') {
1523
+ fields.push({ tsName: key, goName: capitalizeFieldName(key), goType: shape.goType })
1524
+ properties.push({ name: key, type: this.scalarGoTypeToTypeInfo(shape.goType), optional: false, readonly: false })
1525
+ continue
1526
+ }
1527
+ const nestedList = nestedElements.get(key)!
1528
+ // Every row's array for this key was empty — no row's data to infer
1529
+ // a shape from (distinct from a MISSING key, already ruled out
1530
+ // above); matches the top-level empty-array rule this function
1531
+ // already applies to the signal's own outer array.
1532
+ if (nestedList.length === 0) return null
1533
+ const nestedName = `${name}${capitalizeFieldName(key)}Item`
1534
+ const nested = this.synthesizeStructsFromElements(nestedList, nestedName)
1535
+ if (!nested) return null
1536
+ nestedStructs.push(...nested)
1537
+ fields.push({ tsName: key, goName: capitalizeFieldName(key), goType: `[]${nestedName}` })
1538
+ properties.push({ name: key, type: this.synthSliceTypeInfo(nestedName), optional: false, readonly: false })
1399
1539
  }
1540
+
1541
+ return [...nestedStructs, { name, fields, properties }]
1542
+ }
1543
+
1544
+ /** `PropertyInfo.type` for a scalar Go field type — consumed only as a
1545
+ * defensive/consistent fill; `parsedLiteralToGo`'s object branch never
1546
+ * looks up a SCALAR property's declared type (only array/object ones),
1547
+ * so this never actually gates a bake, unlike `synthSliceTypeInfo`. */
1548
+ private scalarGoTypeToTypeInfo(goType: string): TypeInfo {
1549
+ if (goType === 'string') return { kind: 'primitive', raw: 'string', primitive: 'string' }
1550
+ if (goType === 'bool') return { kind: 'primitive', raw: 'boolean', primitive: 'boolean' }
1551
+ return { kind: 'primitive', raw: 'number', primitive: 'number' }
1552
+ }
1553
+
1554
+ /**
1555
+ * `TypeInfo` for "an array of the named synthesized struct" — the exact
1556
+ * shape both `emitSynthStructs`'s `synthStructTypes` entry (the signal's
1557
+ * OWN field type) and a nested array-of-objects field's `PropertyInfo`
1558
+ * need, so `parsed-literal-to-go.ts`'s `structPropertyType` resolves a
1559
+ * nested array property the identical way it resolves the signal's own
1560
+ * top-level type.
1561
+ */
1562
+ private synthSliceTypeInfo(name: string): TypeInfo {
1563
+ return { kind: 'array', raw: `${name}[]`, elementType: { kind: 'interface', raw: name } }
1564
+ }
1565
+
1566
+ /**
1567
+ * Register a synthesized struct (fields as Go source lines, `properties`
1568
+ * for `structPropertyType`'s nested-type lookups) and emit its
1569
+ * declaration — the one register+emit sequence shared by
1570
+ * `emitSynthPropStructs.visitObject` (anonymous TS object types, #2674)
1571
+ * and `emitSynthStructs` (untyped object-array signals, #2800), so
1572
+ * localTypeNames/localStructFields/currentTypeDefinitions registration
1573
+ * can't drift between the two synthesis call sites.
1574
+ */
1575
+ private registerSynthStruct(
1576
+ lines: string[],
1577
+ name: string,
1578
+ fields: Array<{ tsName: string; goName: string; goType: string }>,
1579
+ properties: PropertyInfo[],
1580
+ comment: string,
1581
+ ): void {
1582
+ this.state.localTypeNames.add(name)
1583
+ this.state.localStructFields.set(name, new Map(fields.map(f => [f.tsName, f.goName])))
1584
+ this.state.currentTypeDefinitions.push({
1585
+ kind: 'type',
1586
+ name,
1587
+ definition: '',
1588
+ properties,
1589
+ loc: SYNTH_TYPE_LOC,
1590
+ })
1591
+ const goFields = fields.map(f => `\t${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``)
1592
+ lines.push(comment)
1593
+ lines.push(`type ${name} struct {\n${goFields.join('\n')}\n}`)
1594
+ lines.push('')
1400
1595
  }
1401
1596
 
1402
1597
  /**
@@ -1524,7 +1719,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1524
1719
  nested.loopParam,
1525
1720
  nested.loopKey,
1526
1721
  )) continue
1527
- lines.push(`\t${nested.name}s []${nested.name}Input`)
1722
+ // #2822 follow-up: field NAME stays alias-keyed (parent-private, this
1723
+ // Input struct's own field — read as `in.${nested.name}s` throughout
1724
+ // this file), but the element TYPE is the child's own cross-file
1725
+ // `<Name>Input` struct — see `importAliases`.
1726
+ lines.push(`\t${nested.name}s []${this.resolveChildName(nested.name)}Input`)
1528
1727
  }
1529
1728
 
1530
1729
  // `useContext` consumer fields — settable by an enclosing provider; default
@@ -1595,11 +1794,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1595
1794
  const wrapperName = this.loopBodyWrapperName(parentComponentName, nested)
1596
1795
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
1597
1796
  const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!)
1598
-
1599
- lines.push(`// ${wrapperName} wraps ${nested.name}Props with per-row loop datum`)
1797
+ // #2822 follow-up: this is a Go EMBEDDED (anonymous) field — its field
1798
+ // name IS its type name, so unlike `child.fieldName` the alias-keyed
1799
+ // field name can't be kept separate from the type here. The whole
1800
+ // token must be the child's own cross-file DECLARED name everywhere
1801
+ // this embedded field is declared or literal-initialized (below, and
1802
+ // every `${declaredName}Props: New${declaredName}Props(...)` composite
1803
+ // literal site) — see `importAliases`.
1804
+ const declaredName = this.resolveChildName(nested.name)
1805
+
1806
+ lines.push(`// ${wrapperName} wraps ${declaredName}Props with per-row loop datum`)
1600
1807
  lines.push(`// fields and child component slots for the loop body children. (#1897)`)
1601
1808
  lines.push(`type ${wrapperName} struct {`)
1602
- lines.push(`\t${nested.name}Props`)
1809
+ lines.push(`\t${declaredName}Props`)
1603
1810
  for (const f of datumFields) {
1604
1811
  lines.push(`\t${f.goName} ${f.goType} \`json:"-"\``)
1605
1812
  }
@@ -1610,7 +1817,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1610
1817
  lines.push(`\tBfLoopItem ${scalarLoopType} \`json:"-"\``)
1611
1818
  }
1612
1819
  for (const child of bodyChildInstances) {
1613
- lines.push(`\t${child.fieldName} ${child.name}Props \`json:"-"\``)
1820
+ // #2822: field NAME stays alias-keyed (parent-private); the TYPE must
1821
+ // name the child's own declared Go type — see `importAliases`.
1822
+ lines.push(`\t${child.fieldName} ${this.resolveChildName(child.name)}Props \`json:"-"\``)
1614
1823
  }
1615
1824
  lines.push('}')
1616
1825
  lines.push('')
@@ -1759,6 +1968,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1759
1968
  // Static nested WITHOUT body children.
1760
1969
  for (const nested of staticWithoutBody) {
1761
1970
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
1971
+ // #2822 follow-up: the constructor/type names below are cross-file
1972
+ // (the child's own `New<Name>Props`/`<Name>Props`/`<Name>Input`) and
1973
+ // must resolve to the child's declared name; `varName` (this local)
1974
+ // and `in.${nested.name}s` (this parent's own Input field, read
1975
+ // below) stay alias-keyed — see `importAliases`.
1976
+ const declaredName = this.resolveChildName(nested.name)
1762
1977
  // #2208: a static loop whose ARRAY SOURCE is itself fully-static
1763
1978
  // (`const items = [{ label: 'Alpha' }, ...]`) has no caller input to
1764
1979
  // wait for — every item's props/data-key are already known at
@@ -1776,10 +1991,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1776
1991
  )
1777
1992
  : null
1778
1993
  if (baked) {
1779
- lines.push(`\t${varName} := make([]${nested.name}Props, ${baked.items.length})`)
1994
+ lines.push(`\t${varName} := make([]${declaredName}Props, ${baked.items.length})`)
1780
1995
  baked.items.forEach((item, i) => {
1781
1996
  const fields = item.inputFields.map(f => `${f.goField}: ${f.goValue}`).join(', ')
1782
- lines.push(`\t${varName}[${i}] = New${nested.name}Props(${nested.name}Input{${fields}})`)
1997
+ lines.push(`\t${varName}[${i}] = New${declaredName}Props(${declaredName}Input{${fields}})`)
1783
1998
  lines.push(`\t${varName}[${i}].BfParent = scopeID`)
1784
1999
  lines.push(`\t${varName}[${i}].BfMount = "${nested.slotId}"`)
1785
2000
  if (item.dataKey !== null) {
@@ -1789,9 +2004,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1789
2004
  lines.push('')
1790
2005
  continue
1791
2006
  }
1792
- lines.push(`\t${varName} := make([]${nested.name}Props, len(in.${nested.name}s))`)
2007
+ lines.push(`\t${varName} := make([]${declaredName}Props, len(in.${nested.name}s))`)
1793
2008
  lines.push(`\tfor i, item := range in.${nested.name}s {`)
1794
- lines.push(`\t\t${varName}[i] = New${nested.name}Props(item)`)
2009
+ lines.push(`\t\t${varName}[i] = New${declaredName}Props(item)`)
1795
2010
  lines.push(`\t\t${varName}[i].BfParent = scopeID`)
1796
2011
  lines.push(`\t\t${varName}[i].BfMount = "${nested.slotId}"`)
1797
2012
  const keyField = loopKeyToGoFieldPath(nested.loopKey, nested.loopParam)
@@ -1964,8 +2179,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1964
2179
  // Bake against the synthesised struct type if one was inferred for this
1965
2180
  // untyped object-array signal, else the signal's own type.
1966
2181
  const bakeType = this.state.synthStructTypes.get(signal.getter) ?? signal.type
2182
+ const resolvedParsed = this.resolvedSignalParsed(signal)
1967
2183
  const initialValue = convertInitialValue(this.emitCtx, signal.initialValue, bakeType, ir.metadata.propsParams, signal.parsed)
1968
2184
  lines.push(`\t\t${fieldName}: ${initialValue},`)
2185
+ if (resolvedParsed?.kind === 'object-literal' && jsLiteralToGo(this.emitCtx, bakeType, resolvedParsed) === null) {
2186
+ const step = this.state.ssrSeedPlan.steps.find(s => s.kind === 'derived' && s.origin === 'signal' && s.name === signal.getter)
2187
+ if (step?.kind === 'derived') this.refuseUnbakeableDerivedObjectLiteral(signal.getter, signal.loc, step.frees)
2188
+ }
1969
2189
  }
1970
2190
  }
1971
2191
 
@@ -1997,6 +2217,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1997
2217
  const goType = this.inferMemoType(memo, ir.metadata.signals, memoPropsParamMap)
1998
2218
  const memoValue = computeMemoInitialValue(this.emitCtx, memo, ir.metadata.signals, ir.metadata.propsParams, propFallbackVars, goType)
1999
2219
  lines.push(`\t\t${fieldName}: ${memoValue},`)
2220
+ // No #2700 refusal check here (unlike the signal loop above): the
2221
+ // analyzer deliberately never sets `MemoInfo.parsed` to an
2222
+ // `object-literal` for an object-returning memo body (`() => ({…})`,
2223
+ // `analyzer.ts`'s own docstring — "isn't lowered from the parsed tree
2224
+ // yet") — a pre-existing, unrelated exclusion this fix doesn't touch.
2225
+ // Detecting the shape without `parsed` would mean re-parsing
2226
+ // `memo.computation` as text, which the repo's own convention (see
2227
+ // CLAUDE.md, "Never parse imports... with regex or string matching")
2228
+ // rules out. #2700's own reproduction and fixture are signal-only;
2229
+ // a memo-side refusal is left for whoever lands the "Roadmap A" memo
2230
+ // object-literal `parsed` support this comment references.
2000
2231
  }
2001
2232
 
2002
2233
  // Computed derived-const fields (`Root: func() string { … }()`), matching
@@ -2182,7 +2413,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2182
2413
  private emitStaticChildInstances(lines: string[], ir: ComponentIR): void {
2183
2414
  const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
2184
2415
  for (const child of staticChildren) {
2185
- lines.push(`\t\t${child.fieldName}: New${child.name}Props(${child.name}Input{`)
2416
+ // #2822: the constructor/type names and every cross-file shape lookup
2417
+ // below must resolve to the child's own DECLARED name — `New<Name>Props`
2418
+ // / `<Name>Input` are types/functions the child's OWN generated Go file
2419
+ // defines under that name, not the caller-local alias. `child.fieldName`
2420
+ // (this PARENT's own struct field) stays keyed by the alias — declared
2421
+ // and read consistently within this one parent's generated code.
2422
+ const declaredName = this.resolveChildName(child.name)
2423
+ lines.push(`\t\t${child.fieldName}: New${declaredName}Props(${declaredName}Input{`)
2186
2424
  lines.push(`\t\t\tScopeID: scopeID + "_${child.slotId}",`)
2187
2425
  lines.push(`\t\t\tBfParent: scopeID,`)
2188
2426
  lines.push(`\t\t\tBfMount: "${child.slotId}",`)
@@ -2190,7 +2428,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2190
2428
  // consumes gets the provider value set on its consumer field (else its own
2191
2429
  // NewProps applies the `createContext` default).
2192
2430
  if (child.contextBindings) {
2193
- for (const consumer of this.childContextConsumers.get(child.name) ?? []) {
2431
+ for (const consumer of this.childContextConsumers.get(declaredName) ?? []) {
2194
2432
  const goVal = child.contextBindings.get(consumer.contextName)
2195
2433
  if (goVal !== undefined) {
2196
2434
  lines.push(`\t\t\t${this.contextFieldName(consumer)}: ${goVal},`)
@@ -2200,7 +2438,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2200
2438
  // Non-param attrs route into the child's rest bag (see
2201
2439
  // `childComponentShapes`); `restBagEntries` collects `"jsx-attr-name":
2202
2440
  // goValue` pairs for that map.
2203
- const childShape = this.childComponentShapes.get(child.name)
2441
+ const childShape = this.childComponentShapes.get(declaredName)
2204
2442
  const restBagEntries: string[] = []
2205
2443
  const emitChildField = (jsxName: string, goValue: string): void => {
2206
2444
  if (
@@ -2398,6 +2636,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2398
2636
  lines.push(`// New${componentName}Props creates ${propsTypeName} from ${inputTypeName}.`)
2399
2637
  for (const nested of signalDynamicNested) {
2400
2638
  const arrayField = `${nested.name}s`
2639
+ // #2822 follow-up: `arrayField` names THIS parent's own field
2640
+ // (alias-keyed, stays as-is); the constructor/type names in the
2641
+ // example below are the child's own cross-file symbols — resolve
2642
+ // for doc accuracy (cosmetic; not compiled) — see `importAliases`.
2643
+ const declaredName = this.resolveChildName(nested.name)
2401
2644
  lines.push(`//`)
2402
2645
  lines.push(`// NOTE: \`${arrayField}\` is populated by the route handler, not by`)
2403
2646
  lines.push(`// New${componentName}Props — the SSR template iterates over it`)
@@ -2405,9 +2648,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2405
2648
  lines.push(`// assign it before passing the props to your renderer. Example:`)
2406
2649
  lines.push(`//`)
2407
2650
  lines.push(`// props := New${componentName}Props(${inputTypeName}{ /* ... */ })`)
2408
- lines.push(`// props.${arrayField} = make([]${nested.name}Props, len(items))`)
2651
+ lines.push(`// props.${arrayField} = make([]${declaredName}Props, len(items))`)
2409
2652
  lines.push(`// for i, item := range items {`)
2410
- lines.push(`// props.${arrayField}[i] = New${nested.name}Props(${nested.name}Input{ /* fields */ })`)
2653
+ lines.push(`// props.${arrayField}[i] = New${declaredName}Props(${declaredName}Input{ /* fields */ })`)
2411
2654
  lines.push(`// props.${arrayField}[i].BfParent = props.ScopeID`)
2412
2655
  lines.push(`// props.${arrayField}[i].BfMount = "${nested.slotId}"`)
2413
2656
  lines.push(`// }`)
@@ -2470,10 +2713,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2470
2713
  const wrapperType = this.loopBodyWrapperName(componentName, nested)
2471
2714
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
2472
2715
  const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!, ir.metadata.propsParams)
2716
+ // #2822 follow-up: matches `generateLoopBodyWrapperStruct`'s embedded
2717
+ // field — the composite-literal key here must be the SAME declared
2718
+ // name the wrapper struct's embedded field was declared under.
2719
+ const declaredName = this.resolveChildName(nested.name)
2473
2720
 
2474
2721
  for (const child of bodyChildInstances) {
2475
2722
  const childVar = `child_${child.fieldName}`
2476
- lines.push(`\t${childVar} := New${child.name}Props(${child.name}Input{`)
2723
+ const childDeclaredName = this.resolveChildName(child.name)
2724
+ lines.push(`\t${childVar} := New${childDeclaredName}Props(${childDeclaredName}Input{`)
2477
2725
  lines.push(`\t\tScopeID: scopeID + "_${child.slotId}",`)
2478
2726
  lines.push(`\t\tBfParent: scopeID,`)
2479
2727
  lines.push(`\t\tBfMount: "${child.slotId}",`)
@@ -2493,7 +2741,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2493
2741
  lines.push(`\t${varName} := make([]${wrapperType}, len(${dataVar}))`)
2494
2742
  lines.push(`\tfor i, item := range ${dataVar} {`)
2495
2743
  lines.push(`\t\t${varName}[i] = ${wrapperType}{`)
2496
- lines.push(`\t\t\t${nested.name}Props: New${nested.name}Props(${nested.name}Input{`)
2744
+ lines.push(`\t\t\t${declaredName}Props: New${declaredName}Props(${declaredName}Input{`)
2497
2745
  lines.push(`\t\t\t\tBfParent: scopeID,`)
2498
2746
  lines.push(`\t\t\t\tBfMount: "${nested.slotId}",`)
2499
2747
  // Loop-body component's own static props. `key` → BfDataKey below; children
@@ -2564,11 +2812,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2564
2812
  const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
2565
2813
  const datumFields = this.resolveLoopDatumFields(nested.loopItemType)
2566
2814
  const bodyChildInstances = this.collectBodyChildInstances(nested.bodyChildren!, ir.metadata.propsParams)
2815
+ // #2822 follow-up: matches `generateLoopBodyWrapperStruct`'s embedded
2816
+ // field — see the identical comment in `emitStaticBodyWrappers`.
2817
+ const declaredName = this.resolveChildName(nested.name)
2567
2818
 
2568
2819
  // Child sub-component instances created once (identical scope IDs per row).
2569
2820
  for (const child of bodyChildInstances) {
2570
2821
  const childVar = `child_${child.fieldName}`
2571
- lines.push(`\t${childVar} := New${child.name}Props(${child.name}Input{`)
2822
+ const childDeclaredName = this.resolveChildName(child.name)
2823
+ lines.push(`\t${childVar} := New${childDeclaredName}Props(${childDeclaredName}Input{`)
2572
2824
  lines.push(`\t\tScopeID: scopeID + "_${child.slotId}",`)
2573
2825
  lines.push(`\t\tBfParent: scopeID,`)
2574
2826
  lines.push(`\t\tBfMount: "${child.slotId}",`)
@@ -2587,7 +2839,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2587
2839
  lines.push(`\t${varName} := make([]${wrapperType}, len(bakedData))`)
2588
2840
  lines.push(`\tfor i, item := range bakedData {`)
2589
2841
  lines.push(`\t\t${varName}[i] = ${wrapperType}{`)
2590
- lines.push(`\t\t\t${nested.name}Props: New${nested.name}Props(${nested.name}Input{`)
2842
+ lines.push(`\t\t\t${declaredName}Props: New${declaredName}Props(${declaredName}Input{`)
2591
2843
  lines.push(`\t\t\t\tBfParent: scopeID,`)
2592
2844
  lines.push(`\t\t\t\tBfMount: "${nested.slotId}",`)
2593
2845
  lines.push(`\t\t\t}),`)
@@ -2715,20 +2967,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2715
2967
  visit(prop.type, desiredName, prop.name)
2716
2968
  }
2717
2969
  const fields = this.structFieldsFor(typeInfo)
2718
- this.state.localStructFields.set(desiredName, new Map(fields.map(f => [f.tsName, f.goName])))
2719
- this.state.currentTypeDefinitions.push({
2720
- kind: 'type',
2721
- name: desiredName,
2722
- definition: '',
2723
- properties: typeInfo.properties ?? [],
2724
- loc: SYNTH_TYPE_LOC,
2725
- })
2726
- const goFields = fields.map(
2727
- f => `\t${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``,
2970
+ this.registerSynthStruct(
2971
+ lines,
2972
+ desiredName,
2973
+ fields,
2974
+ typeInfo.properties ?? [],
2975
+ `// ${desiredName} is a synthesised type for an anonymous object type (#2674).`,
2728
2976
  )
2729
- lines.push(`// ${desiredName} is a synthesised type for an anonymous object type (#2674).`)
2730
- lines.push(`type ${desiredName} struct {\n${goFields.join('\n')}\n}`)
2731
- lines.push('')
2732
2977
  }
2733
2978
 
2734
2979
  const visitArrayElem = (elemType: TypeInfo | undefined, parentName: string, propName: string): void => {
@@ -2781,28 +3026,33 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2781
3026
  }
2782
3027
 
2783
3028
  private emitSynthStructs(lines: string[], ir: ComponentIR, componentName: string): void {
2784
- // Synthesise a struct for each untyped object-array signal and emit it, so
2785
- // the signal field can be typed `[]Synth` and its inline items baked (the
2786
- // loop body reaches each item via struct field access). Registered in
2787
- // localTypeNames/localStructFields so the baker resolves the element type.
3029
+ // Synthesise a struct for each untyped object-array signal (plus, per
3030
+ // #2800, one nested struct per array-of-objects field, recursively) and
3031
+ // emit them, so the signal field can be typed `[]Synth` and its inline
3032
+ // items baked (the loop body reaches each item via struct field
3033
+ // access). Registered via `registerSynthStruct` so the baker resolves
3034
+ // every level's element type the same way it resolves a #2674
3035
+ // anonymous-type struct.
2788
3036
  this.state.synthStructTypes = new Map<string, TypeInfo>()
2789
3037
  for (const signal of ir.metadata.signals) {
2790
3038
  if (signal.envReader) continue // env signal has no bakeable initial shape (#2057)
2791
3039
  const synth = this.synthesizeStructFromSignal(signal, componentName)
2792
3040
  if (!synth) continue
2793
- this.state.localTypeNames.add(synth.name)
2794
- this.state.localStructFields.set(synth.name, new Map(synth.fields.map(f => [f.tsName, f.goName])))
2795
- this.state.synthStructTypes.set(signal.getter, {
2796
- kind: 'array',
2797
- raw: `${synth.name}[]`,
2798
- elementType: { kind: 'interface', raw: synth.name },
2799
- })
2800
- const goFields = synth.fields.map(
2801
- f => `\t${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``,
2802
- )
2803
- lines.push(`// ${synth.name} is a synthesised element type for the ${signal.getter} signal.`)
2804
- lines.push(`type ${synth.name} struct {\n${goFields.join('\n')}\n}`)
2805
- lines.push('')
3041
+ // Nested-first order (`synthesizeStructFromSignal`'s contract): a
3042
+ // struct referencing an earlier entry by name is always registered
3043
+ // after it, so no declaration ever forward-references an
3044
+ // undeclared Go type.
3045
+ for (const s of synth) {
3046
+ this.registerSynthStruct(
3047
+ lines,
3048
+ s.name,
3049
+ s.fields,
3050
+ s.properties,
3051
+ `// ${s.name} is a synthesised element type for the ${signal.getter} signal.`,
3052
+ )
3053
+ }
3054
+ const top = synth[synth.length - 1]
3055
+ this.state.synthStructTypes.set(signal.getter, this.synthSliceTypeInfo(top.name))
2806
3056
  }
2807
3057
  }
2808
3058
 
@@ -3082,10 +3332,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3082
3332
  // real — a second same-named field here is a Go compile error
3083
3333
  // ("redeclared"), not just dead code.
3084
3334
  if (this.isOrphanedClientOnlyNested(nested)) continue
3085
- // Loop body with JSX children → use the wrapper struct type.
3335
+ // Loop body with JSX children → use the wrapper struct type (a
3336
+ // parent-private name — the child's own Props type only appears
3337
+ // INSIDE it as the embedded field, already resolved in
3338
+ // `generateLoopBodyWrapperStruct`). Loop body IS just the bare child
3339
+ // component → this element type IS the child's own cross-file Props
3340
+ // type directly — #2822 follow-up: resolve to the declared name.
3086
3341
  const elemType = nested.bodyChildren?.length
3087
3342
  ? this.loopBodyWrapperName(componentName, nested)
3088
- : `${nested.name}Props`
3343
+ : `${this.resolveChildName(nested.name)}Props`
3089
3344
  if (nested.isDynamic && !nested.isPropDerived) {
3090
3345
  // Dynamic signal-array loops are template-only.
3091
3346
  lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
@@ -3129,7 +3384,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3129
3384
 
3130
3385
  const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
3131
3386
  for (const child of staticChildren) {
3132
- lines.push(`\t${child.fieldName} ${child.name}Props \`json:"-"\``)
3387
+ // #2822: the field's NAME stays keyed by the caller-local alias
3388
+ // (`child.fieldName`, declared and read consistently within this
3389
+ // parent's own generated code — see `importAliases`'s docstring), but
3390
+ // its TYPE is a real Go type only the child's own file defines, under
3391
+ // the child's DECLARED name.
3392
+ lines.push(`\t${child.fieldName} ${this.resolveChildName(child.name)}Props \`json:"-"\``)
3133
3393
  }
3134
3394
 
3135
3395
  // Top-level intrinsic-element spreads: each gets a `Spread_<slotId>
@@ -3879,6 +4139,55 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3879
4139
  return resolveSignalParsedThroughSeedPlan(this.state, signal)
3880
4140
  }
3881
4141
 
4142
+ /**
4143
+ * #2700: a `derived`-classified signal (`SsrSeedPlan`'s classification —
4144
+ * an object literal whose free identifiers ALL resolve in scope, e.g.
4145
+ * `createSignal({ ...base, done: true })`) whose value the
4146
+ * constructor-time baker (`convertInitialValue`) can't reproduce silently
4147
+ * keeps the field's Go zero value — that baker is static-only
4148
+ * (identifier/member/call operands defer, `parsed-literal-to-go.ts`'s own
4149
+ * docstring). Signal-only: the analyzer deliberately never sets
4150
+ * `MemoInfo.parsed` to an `object-literal` for an object-returning memo
4151
+ * body (`analyzer.ts`'s own docstring — "isn't lowered from the parsed
4152
+ * tree yet"), so there is no structural way to reach this check for a
4153
+ * memo without re-parsing `computation` as text, which the repo's own
4154
+ * convention rules out.
4155
+ *
4156
+ * Deferring silently is harmless UNLESS the SSR template actually reads
4157
+ * the field: a signal that only feeds a spread-attrs bag never reaches
4158
+ * here as a false positive because spread bags bake through their own
4159
+ * `.Spread_<slot>` route (`emitSpreadBagInits`), never `rootFieldRef` —
4160
+ * so `templateReadRootFields` (populated while `generate()` rendered the
4161
+ * template, which always precedes `generateTypes`'s call into this
4162
+ * method) is the exact structural proxy for "the zero value would
4163
+ * actually surface," not merely "the bake failed."
4164
+ *
4165
+ * Scoped to a NON-EMPTY free set on purpose, not every deferred bake: a
4166
+ * fully-static object literal (`createSignal({ id: 'row-1' })`) hits the
4167
+ * same `nil` fallback today but is a separate, untracked silent-divergence
4168
+ * shape with no fixture of its own — loud-ifying it here would silently
4169
+ * widen this fix beyond #2700's actual reproduction (a literal that
4170
+ * references a live prop/signal), so it's left for its own issue instead.
4171
+ */
4172
+ private refuseUnbakeableDerivedObjectLiteral(
4173
+ name: string,
4174
+ loc: SourceLocation,
4175
+ frees: readonly string[],
4176
+ ): void {
4177
+ if (frees.length === 0) return
4178
+ if (!this.state.templateReadRootFields.has(name)) return
4179
+ this.state.errors.push({
4180
+ code: 'BF101',
4181
+ severity: 'error',
4182
+ message: `Signal '${name}' is seeded from an object literal that references live value(s) (${frees.join(', ')}) — the Go template adapter bakes object-typed signal values into Go source at New${this.state.componentName}Props time, and that baker is static-only (identifier/member/call operands defer), so the SSR template's read of it would see the Go zero value instead of the derived object.`,
4183
+ loc,
4184
+ suggestion: {
4185
+ message: `Wrap each SSR read of '${name}()' in /* @client */ so it renders on the client instead, or pass the already-derived object in as a prop.`,
4186
+ escape: [{ kind: 'client-directive' }],
4187
+ },
4188
+ })
4189
+ }
4190
+
3882
4191
  /**
3883
4192
  * Parse a signal-time initial value of the form `props.X ?? <literal>` —
3884
4193
  * or, for destructured components, `x ?? <literal>` — into the source prop
@@ -4537,6 +4846,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4537
4846
  * rebinds. Outside any loop the root *is* the dot, so we emit `.Field`.
4538
4847
  */
4539
4848
  private rootFieldRef(name: string): string {
4849
+ this.state.templateReadRootFields.add(name)
4540
4850
  const prefix = this.inLoop ? '$.' : '.'
4541
4851
  return `${prefix}${capitalizeFieldName(name)}`
4542
4852
  }
@@ -4587,6 +4897,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4587
4897
  return `"${escapeGoString(value)}"`
4588
4898
  }
4589
4899
 
4900
+ /**
4901
+ * The single module-const lookup shared by `resolveModuleNumericConst`
4902
+ * and `resolveModuleBooleanConst` — they differ only in which literal
4903
+ * SHAPE they accept from the same "plain module-level const" search, not
4904
+ * in how that search is performed. A second inline lookup per resolver
4905
+ * would grow `binding-scope-ratchet.test.ts`'s shrink-only floor for this
4906
+ * file (already at 5) for a shape variance the callers can express
4907
+ * themselves instead.
4908
+ */
4909
+ private findModuleConst(name: string): ConstantInfo | undefined {
4910
+ return this.state.localConstants.find(
4911
+ (k) => k.name === name && k.isModule && !k.containsArrow,
4912
+ )
4913
+ }
4914
+
4590
4915
  /**
4591
4916
  * Inline a module-level numeric const (`const TRACK = 8`) as its literal
4592
4917
  * value. Only a plain numeric initializer qualifies — anything computed or
@@ -4598,9 +4923,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4598
4923
  if (this.isCurrentLoopItem(name)) return null
4599
4924
  if (this.loopVarRefCount.has(name)) return null
4600
4925
  if (this.isOuterLoopParam(name)) return null
4601
- const c = this.state.localConstants.find(
4602
- (k) => k.name === name && k.isModule && !k.containsArrow,
4603
- )
4926
+ const c = this.findModuleConst(name)
4604
4927
  if (!c || c.value === undefined) return null
4605
4928
  // `value` is reconstructed from source text, so a valid TS literal may carry
4606
4929
  // numeric separators (`100_000`). Strip them between digits, then accept a
@@ -4609,6 +4932,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4609
4932
  return /^-?\d+(\.\d+)?$/.test(v) ? v : null
4610
4933
  }
4611
4934
 
4935
+ /**
4936
+ * Inline a module-level boolean const (`const OPEN = true`) as its Go
4937
+ * literal (`true`/`false`). Only a plain `true`/`false` initializer
4938
+ * qualifies (#2815) — mirrors `resolveModuleNumericConst`'s shape, sharing
4939
+ * its lookup rather than adding a second `.find(` (see
4940
+ * `findModuleConst`'s docstring).
4941
+ */
4942
+ private resolveModuleBooleanConst(name: string): string | null {
4943
+ if (this.isCurrentLoopItem(name)) return null
4944
+ if (this.loopVarRefCount.has(name)) return null
4945
+ if (this.isOuterLoopParam(name)) return null
4946
+ const c = this.findModuleConst(name)
4947
+ if (!c || c.value === undefined) return null
4948
+ const v = c.value.trim()
4949
+ return v === 'true' || v === 'false' ? v : null
4950
+ }
4951
+
4612
4952
  literal(value: string | number | boolean | null, literalType: LiteralType): string {
4613
4953
  if (literalType === 'string') return `"${value}"`
4614
4954
  if (literalType === 'null') return 'nil'
@@ -5794,8 +6134,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5794
6134
  // A local variable mapped to a signal.
5795
6135
  const signal = localVarMap.get(expr.name)
5796
6136
  if (signal) {
6137
+ // Root-scope read (#2700's BF101 gate needs it in
6138
+ // `templateReadRootFields`) — `$.` is hardcoded rather than
6139
+ // routed through `rootFieldRef`'s `this.inLoop`-conditional
6140
+ // prefix because a filter predicate must always escape back to
6141
+ // root regardless of loop nesting; call it only for its
6142
+ // registration side effect and keep the prefix here (pullfrog
6143
+ // review, PR #2818).
6144
+ this.rootFieldRef(signal)
5797
6145
  return `$.${capitalizeFieldName(signal)}`
5798
6146
  }
6147
+ this.rootFieldRef(expr.name)
5799
6148
  return `.${capitalizeFieldName(expr.name)}`
5800
6149
  }
5801
6150
 
@@ -5840,8 +6189,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5840
6189
  if (expr.callee.kind === 'member' && expr.callee.object.kind === 'identifier' && expr.callee.object.name === param) {
5841
6190
  return `${paramPrefix}.${capitalizeFieldName(expr.callee.property)}`
5842
6191
  }
5843
- // Signal calls: `filter()` -> `$.Filter`
6192
+ // Signal calls: `filter()` -> `$.Filter`. Same registration-only
6193
+ // `rootFieldRef` call as the `identifier` case above, for the same
6194
+ // reason (#2700's BF101 gate; pullfrog review, PR #2818).
5844
6195
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
6196
+ this.rootFieldRef(expr.callee.name)
5845
6197
  return `$.${capitalizeFieldName(expr.callee.name)}`
5846
6198
  }
5847
6199
  // A nested callback method call (`other.some(r => …)`) reaching this
@@ -6204,7 +6556,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6204
6556
  private loopRowChildPropOverrides(
6205
6557
  comp: IRComponent,
6206
6558
  ): { args: string; helper: 'bf_with_props' | 'bf_reprops' } | null {
6207
- const childShape = this.childComponentShapes.get(comp.name)
6559
+ // #2822: every cross-file map below is keyed by the child's own
6560
+ // declared name, not the caller-local alias — see `importAliases`.
6561
+ // Diagnostics still name `comp.name` (what the user actually wrote in
6562
+ // the JSX) since that's the more useful reference for the reader.
6563
+ const declaredName = this.resolveChildName(comp.name)
6564
+ const childShape = this.childComponentShapes.get(declaredName)
6208
6565
  const args: string[] = []
6209
6566
  // Set by the derived-field check below when at least one overridden prop
6210
6567
  // feeds a constructor-derived field AND the child can rebuild itself.
@@ -6235,12 +6592,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6235
6592
  // one-shot value on every row. Re-run the constructor per row when the
6236
6593
  // child has a rebuilder; refuse when it doesn't.
6237
6594
  {
6238
- const derived = this.childDerivedFieldDeps.get(comp.name)
6595
+ const derived = this.childDerivedFieldDeps.get(declaredName)
6239
6596
  const overriddenField = capitalizeFieldName(prop.name)
6240
6597
  const staleField = derived
6241
6598
  ? [...derived].find(([, deps]) => deps.has(overriddenField))?.[0]
6242
6599
  : undefined
6243
- if (staleField && !this.childRepropsReady.has(comp.name)) {
6600
+ if (staleField && !this.childRepropsReady.has(declaredName)) {
6244
6601
  this.state.errors.push({
6245
6602
  code: 'BF101',
6246
6603
  severity: 'error',
@@ -6257,9 +6614,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6257
6614
  // The rebuilder is emitted into THIS component's type block, not the
6258
6615
  // child's — only here do we know it is actually needed. First parent
6259
6616
  // to claim a child owns the registration, so two parents overriding
6260
- // the same child don't both emit an `init()` for it.
6261
- if (!this.repropsOwner.has(comp.name)) {
6262
- this.repropsOwner.set(comp.name, this.state.componentName)
6617
+ // the same child don't both emit an `init()` for it. Keyed by the
6618
+ // child's DECLARED name (#2822) — `emitRepropsRegistration` below
6619
+ // builds Go type references (`<Name>Props`/`<Name>Input`) from this
6620
+ // same key, and only the declared name has real types to match.
6621
+ if (!this.repropsOwner.has(declaredName)) {
6622
+ this.repropsOwner.set(declaredName, this.state.componentName)
6263
6623
  }
6264
6624
  }
6265
6625
  }
@@ -6324,7 +6684,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6324
6684
  // (#2457). Stays LOCAL even though the child's Input field is
6325
6685
  // caller-facing. Falls back to the capitalized attribute for a
6326
6686
  // cross-file child this run's pre-pass never registered.
6327
- const fieldName = this.childPropFieldNames.get(comp.name)?.get(prop.name) ?? capitalizeFieldName(prop.name)
6687
+ const fieldName = this.childPropFieldNames.get(declaredName)?.get(prop.name) ?? capitalizeFieldName(prop.name)
6328
6688
  args.push(`${JSON.stringify(fieldName)} ${wrapIfMultiToken(go)}`)
6329
6689
  }
6330
6690
  if (args.length === 0) return null
@@ -7378,7 +7738,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7378
7738
  */
7379
7739
  private queueDynamicPropDefine(comp: IRComponent): string | null {
7380
7740
  const args: string[] = []
7381
- const childShape = this.childComponentShapes.get(comp.name)
7741
+ // #2822: cross-file shapes/field-name maps are keyed by the child's own
7742
+ // declared name, not the caller-local alias — see `importAliases`.
7743
+ const declaredName = this.resolveChildName(comp.name)
7744
+ const childShape = this.childComponentShapes.get(declaredName)
7382
7745
  for (const prop of comp.props) {
7383
7746
  if (prop.value.kind !== 'jsx-children' || prop.name === 'children') continue
7384
7747
  const children = prop.value.children
@@ -7419,7 +7782,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7419
7782
  // render. `childPropFieldNames` resolves this exact hazard for the
7420
7783
  // sibling `bf_with_props` call site (`loopRowChildPropOverrides`,
7421
7784
  // below) — mirrored here.
7422
- const fieldName = this.childPropFieldNames.get(comp.name)?.get(prop.name) ?? capitalizeFieldName(prop.name)
7785
+ const fieldName = this.childPropFieldNames.get(declaredName)?.get(prop.name) ?? capitalizeFieldName(prop.name)
7423
7786
  args.push(`${JSON.stringify(fieldName)} (bf_tmpl ${JSON.stringify(name)} .)`)
7424
7787
  }
7425
7788
  return args.length > 0 ? args.join(' ') : null
@@ -7470,6 +7833,15 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7470
7833
  }
7471
7834
 
7472
7835
  // In Go templates, components are rendered via {{template "name" data}}.
7836
+ // #2822: the STRING passed to `{{template "..."}}` (and the `bf_reprops`
7837
+ // registry-key argument below) must be the child's own DECLARED name —
7838
+ // that's what the child's own compiled Go file registers its
7839
+ // `{{define "..."}}` block under. A field-access expression like
7840
+ // `.${comp.name}${suffix}` stays keyed by `comp.name` (the caller-local
7841
+ // alias) — it names THIS parent's own struct field, declared and read
7842
+ // consistently within this one parent's generated code, with no
7843
+ // cross-file identity to match.
7844
+ const declaredName = this.resolveChildName(comp.name)
7473
7845
  let templateCall: string
7474
7846
  if (this.inLoop && (this.loopWrapperStack[this.loopWrapperStack.length - 1] ?? false)) {
7475
7847
  // Wrapper-slice loop (body IS this component): `.` is the wrapper struct
@@ -7488,9 +7860,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7488
7860
  const bodyData = this.loopScalarItemStack[this.loopScalarItemStack.length - 1]
7489
7861
  ? '.BfLoopItem'
7490
7862
  : '.'
7491
- templateCall = `{{template "${comp.name}" (bf_with_children . (bf_tmpl "${loopBodyDefine}" ${bodyData}))}}`
7863
+ templateCall = `{{template "${declaredName}" (bf_with_children . (bf_tmpl "${loopBodyDefine}" ${bodyData}))}}`
7492
7864
  } else {
7493
- templateCall = `{{template "${comp.name}" .}}`
7865
+ templateCall = `{{template "${declaredName}" .}}`
7494
7866
  }
7495
7867
  } else if (this.inLoop && comp.slotId) {
7496
7868
  // Non-wrapper loop (component nested inside an element item, #2130):
@@ -7516,16 +7888,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7516
7888
  // the row's children last, on the rebuilt value.
7517
7889
  const base = overrides
7518
7890
  ? overrides.helper === 'bf_reprops'
7519
- ? `(bf_reprops ${JSON.stringify(comp.name)} $.${comp.name}${suffix} ${overrides.args})`
7891
+ ? `(bf_reprops ${JSON.stringify(declaredName)} $.${comp.name}${suffix} ${overrides.args})`
7520
7892
  : `(bf_with_props $.${comp.name}${suffix} ${overrides.args})`
7521
7893
  : `$.${comp.name}${suffix}`
7522
7894
  templateCall = loopBodyDefine
7523
- ? `{{template "${comp.name}" (bf_with_children ${base} (bf_tmpl "${loopBodyDefine}" .))}}`
7524
- : `{{template "${comp.name}" ${base}}}`
7895
+ ? `{{template "${declaredName}" (bf_with_children ${base} (bf_tmpl "${loopBodyDefine}" .))}}`
7896
+ : `{{template "${declaredName}" ${base}}}`
7525
7897
  } else if (this.inLoop) {
7526
7898
  // Loop-nested component without a slotId: no parent field to route
7527
7899
  // through — legacy passthrough of the current dot.
7528
- templateCall = `{{template "${comp.name}" .}}`
7900
+ templateCall = `{{template "${declaredName}" .}}`
7529
7901
  } else if (comp.slotId) {
7530
7902
  // Static children with slotId: unique field name based on slotId.
7531
7903
  const suffix = slotIdToFieldSuffix(comp.slotId)
@@ -7541,11 +7913,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
7541
7913
  ? `(bf_with_props .${comp.name}${suffix} ${propArgs})`
7542
7914
  : `.${comp.name}${suffix}`
7543
7915
  templateCall = childrenDefine
7544
- ? `{{template "${comp.name}" (bf_with_children ${base} (bf_tmpl "${childrenDefine}" .))}}`
7545
- : `{{template "${comp.name}" ${base}}}`
7916
+ ? `{{template "${declaredName}" (bf_with_children ${base} (bf_tmpl "${childrenDefine}" .))}}`
7917
+ : `{{template "${declaredName}" ${base}}}`
7546
7918
  } else {
7547
7919
  // Static children without slotId: fall back to .ComponentName.
7548
- templateCall = `{{template "${comp.name}" .${comp.name}}}`
7920
+ templateCall = `{{template "${declaredName}" .${comp.name}}}`
7549
7921
  }
7550
7922
 
7551
7923
  // A root component in a client component needs a scope comment for the