@barefootjs/go-template 0.31.7 → 0.31.9

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/vite.js CHANGED
@@ -3095,6 +3095,34 @@ var SVG_ROOT_TAGS = new Set([
3095
3095
  "animateTransform",
3096
3096
  "animateMotion"
3097
3097
  ]);
3098
+ var MATHML_ROOT_TAGS = new Set([
3099
+ "math",
3100
+ "mrow",
3101
+ "mfrac",
3102
+ "msup",
3103
+ "msub",
3104
+ "msubsup",
3105
+ "mn",
3106
+ "mi",
3107
+ "mo",
3108
+ "mtext",
3109
+ "munder",
3110
+ "mover",
3111
+ "munderover",
3112
+ "mtable",
3113
+ "mtr",
3114
+ "mtd",
3115
+ "msqrt",
3116
+ "mroot",
3117
+ "mstyle",
3118
+ "merror",
3119
+ "mpadded",
3120
+ "mphantom",
3121
+ "menclose",
3122
+ "semantics",
3123
+ "annotation",
3124
+ "annotation-xml"
3125
+ ]);
3098
3126
 
3099
3127
  // ../jsx/src/ir-to-client-js/collect-elements.ts
3100
3128
  var EMPTY_RENDER_EXPRS = new Set(["null", "undefined", "false", "''", '""', "``"]);
@@ -5427,6 +5455,7 @@ class CompileState {
5427
5455
  localTypeAliases = new Map;
5428
5456
  localStructFields = new Map;
5429
5457
  synthStructTypes = new Map;
5458
+ synthObjectStructNames = new Map;
5430
5459
  needsStringsImport = false;
5431
5460
  }
5432
5461
 
@@ -6075,8 +6104,10 @@ function typeInfoToGo(ctx, _typeInfo, defaultValue, preParsed) {
6075
6104
  return `[]${typeInfoToGo(ctx, typeInfo.elementType)}`;
6076
6105
  }
6077
6106
  return "[]interface{}";
6078
- case "object":
6079
- return "map[string]interface{}";
6107
+ case "object": {
6108
+ const synthName = ctx.state.synthObjectStructNames.get(typeInfo);
6109
+ return synthName ?? "map[string]interface{}";
6110
+ }
6080
6111
  case "interface":
6081
6112
  if (typeInfo.raw && (ctx.state.localStructFields.has(typeInfo.raw) || ctx.state.localTypeAliases.has(typeInfo.raw))) {
6082
6113
  return typeInfo.raw;
@@ -7622,11 +7653,11 @@ function buildPropTypeOverrides(ctx, ir) {
7622
7653
  if (!param)
7623
7654
  continue;
7624
7655
  const propGoType = typeInfoToGo(ctx, param.type, param.defaultValue, param.parsed);
7625
- if (propGoType.includes("interface{}")) {
7626
- const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue, signal.parsed);
7627
- if (!signalGoType.includes("interface{}")) {
7628
- overrides.set(propName, signalGoType);
7629
- }
7656
+ const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue, signal.parsed);
7657
+ if (signalGoType.includes("interface{}"))
7658
+ continue;
7659
+ if (propGoType.includes("interface{}") || signalGoType !== propGoType) {
7660
+ overrides.set(propName, signalGoType);
7630
7661
  }
7631
7662
  }
7632
7663
  }
@@ -7856,6 +7887,11 @@ function collectStringValueNames(ir) {
7856
7887
  }
7857
7888
 
7858
7889
  // src/adapter/go-template-adapter.ts
7890
+ var SYNTH_TYPE_LOC = {
7891
+ file: "<synthesized>",
7892
+ start: { line: 0, column: 0 },
7893
+ end: { line: 0, column: 0 }
7894
+ };
7859
7895
  var STRING_METHODS = new Set([
7860
7896
  "replace",
7861
7897
  "trim",
@@ -8217,6 +8253,7 @@ ${scriptRegistrations}${templateBody}
8217
8253
  const lines = [];
8218
8254
  const componentName = ir.metadata.componentName;
8219
8255
  this.buildLocalTypeTables(ir, componentName);
8256
+ this.emitSynthPropStructs(lines, ir, componentName);
8220
8257
  this.emitLocalTypeStructs(lines, ir, componentName);
8221
8258
  this.emitSynthStructs(lines, ir, componentName);
8222
8259
  const nestedComponents = findNestedComponents(ir.root);
@@ -9076,6 +9113,65 @@ ${goFields.join(`
9076
9113
  }
9077
9114
  }
9078
9115
  }
9116
+ emitSynthPropStructs(lines, ir, componentName) {
9117
+ this.state.synthObjectStructNames = new Map;
9118
+ this.state.currentTypeDefinitions = [...this.state.currentTypeDefinitions];
9119
+ const visitObject = (typeInfo, desiredName) => {
9120
+ if (this.state.synthObjectStructNames.has(typeInfo))
9121
+ return;
9122
+ if (this.state.localTypeNames.has(desiredName))
9123
+ return;
9124
+ this.state.localTypeNames.add(desiredName);
9125
+ this.state.synthObjectStructNames.set(typeInfo, desiredName);
9126
+ for (const prop of typeInfo.properties ?? []) {
9127
+ visit(prop.type, desiredName, prop.name);
9128
+ }
9129
+ const fields = this.structFieldsFor(typeInfo);
9130
+ this.state.localStructFields.set(desiredName, new Map(fields.map((f) => [f.tsName, f.goName])));
9131
+ this.state.currentTypeDefinitions.push({
9132
+ kind: "type",
9133
+ name: desiredName,
9134
+ definition: "",
9135
+ properties: typeInfo.properties ?? [],
9136
+ loc: SYNTH_TYPE_LOC
9137
+ });
9138
+ const goFields = fields.map((f) => ` ${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``);
9139
+ lines.push(`// ${desiredName} is a synthesised type for an anonymous object type (#2674).`);
9140
+ lines.push(`type ${desiredName} struct {
9141
+ ${goFields.join(`
9142
+ `)}
9143
+ }`);
9144
+ lines.push("");
9145
+ };
9146
+ const visitArrayElem = (elemType, parentName, propName) => {
9147
+ if (!elemType)
9148
+ return;
9149
+ if (elemType.kind === "array") {
9150
+ visitArrayElem(elemType.elementType, parentName, propName);
9151
+ } else if (elemType.kind === "object") {
9152
+ visitObject(elemType, `${parentName}${goFieldNameForKey(propName)}Item`);
9153
+ }
9154
+ };
9155
+ const visit = (typeInfo, parentName, propName) => {
9156
+ if (typeInfo.kind === "array") {
9157
+ visitArrayElem(typeInfo.elementType, parentName, propName);
9158
+ } else if (typeInfo.kind === "object") {
9159
+ visitObject(typeInfo, `${parentName}${goFieldNameForKey(propName)}`);
9160
+ }
9161
+ };
9162
+ for (const td of ir.metadata.typeDefinitions) {
9163
+ if (td.name === "Props" || td.name === `${componentName}Props`)
9164
+ continue;
9165
+ if (td.name.endsWith("Props"))
9166
+ continue;
9167
+ for (const prop of td.properties ?? []) {
9168
+ visit(prop.type, td.name, prop.name);
9169
+ }
9170
+ }
9171
+ for (const param of ir.metadata.propsParams) {
9172
+ visit(param.type, componentName, param.name);
9173
+ }
9174
+ }
9079
9175
  emitLocalTypeStructs(lines, ir, componentName) {
9080
9176
  for (const td of ir.metadata.typeDefinitions) {
9081
9177
  if (td.name === "Props" || td.name === `${componentName}Props`)
@@ -9159,7 +9255,7 @@ ${goFields.join(`
9159
9255
  emitPropsStructHeader(lines, ir, propsTypeName, componentName) {
9160
9256
  lines.push(`// ${propsTypeName} is the props type for the ${componentName} component.`);
9161
9257
  lines.push(`type ${propsTypeName} struct {`);
9162
- lines.push('\tScopeID string `json:"scopeID"`');
9258
+ lines.push('\tScopeID string `json:"-"`');
9163
9259
  lines.push('\tBfIsRoot bool `json:"-"`');
9164
9260
  lines.push('\tBfIsChild bool `json:"-"`');
9165
9261
  lines.push('\tBfParent string `json:"-"`');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/go-template",
3
- "version": "0.31.7",
3
+ "version": "0.31.9",
4
4
  "description": "Go html/template adapter for BarefootJS - generates Go template files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  "directory": "packages/adapter-go-template"
50
50
  },
51
51
  "dependencies": {
52
- "@barefootjs/shared": "0.31.7"
52
+ "@barefootjs/shared": "0.31.9"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@barefootjs/jsx": ">=0.2.0",
@@ -67,9 +67,9 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@barefootjs/adapter-tests": "0.1.0",
70
- "@barefootjs/client": "0.31.7",
71
- "@barefootjs/jsx": "0.31.7",
72
- "@barefootjs/vite": "0.31.7",
70
+ "@barefootjs/client": "0.31.9",
71
+ "@barefootjs/jsx": "0.31.9",
72
+ "@barefootjs/vite": "0.31.9",
73
73
  "vite": "^6.0.0"
74
74
  }
75
75
  }
@@ -1092,15 +1092,18 @@ export function Rows() {
1092
1092
  expect(types).toContain('Rows: []Row{Row{DataID: "a"}},')
1093
1093
  })
1094
1094
 
1095
- test('bakes a non-Go-identifier key inside a nested INLINE object with the same sanitizer as the accessor side (#2089 review)', () => {
1096
- // A nested inline-object property (`meta: { 'data-x': string }` — no
1097
- // named Go struct, lowered to map[string]interface{}) bakes as a Go map
1098
- // literal. The map KEY must be produced by `goFieldNameForKey` — the
1099
- // same function `buildSegmentAccessor`/`structFieldsFor` use so the
1100
- // emitted accessor (`.Meta.DataX`, an exact-string MapIndex on maps)
1101
- // actually finds the value. `capitalizeFieldName` alone would bake
1102
- // `"Data-x"`, a key no emitted accessor can reach, silently rendering
1103
- // empty (flagged by Copilot on PR #2089).
1095
+ test('bakes a non-Go-identifier key inside a nested INLINE object via the sanitizer-named synthesized struct (#2089 review, superseded by #2674)', () => {
1096
+ // A nested inline-object property (`meta: { 'data-x': string }`) used
1097
+ // to have no named Go struct and lower to `map[string]interface{}`,
1098
+ // baked as a Go map literal with a `goFieldNameForKey`-sanitized key
1099
+ // (`"DataX"`, not the invalid-accessor `"Data-x"` — #2089). #2674's
1100
+ // struct-synthesis pre-pass now gives it a real struct (`RowMeta`)
1101
+ // instead the FIELD name still comes from the same sanitizer
1102
+ // (`goFieldNameForKey`), so the accessor (`.Meta.DataX`) and the json
1103
+ // tag (the ORIGINAL unsanitized source key, `"data-x"` — the struct
1104
+ // field's json tag, not the map-baking convention's capitalized key)
1105
+ // both still resolve correctly; only the container changed from a map
1106
+ // to a struct.
1104
1107
  const adapter = new GoTemplateAdapter()
1105
1108
  const ir = compileToIR(`
1106
1109
  "use client"
@@ -1113,8 +1116,11 @@ export function Rows() {
1113
1116
  }
1114
1117
  `)
1115
1118
  const types = adapter.generate(ir).types!
1116
- expect(types).toContain('map[string]interface{}{"DataX": "v"}')
1119
+ expect(types).toContain('type RowMeta struct {')
1120
+ expect(types).toContain('DataX string `json:"data-x"`')
1117
1121
  expect(types).not.toContain('"Data-x"')
1122
+ expect(types).toContain('Row{ID: "r1", Meta: RowMeta{DataX: "v"}}')
1123
+ expect(types).not.toContain('map[string]interface{}')
1118
1124
  })
1119
1125
 
1120
1126
  test('snake_case keys keep their underscore in the generated field name (#2089 review)', () => {
@@ -5029,11 +5035,14 @@ export function TodoItem(props: Props) {
5029
5035
  // field Done in type TodoItemProps`) or — since `bf_sort_eval`-style
5030
5036
  // evaluators are untouched by this fix, only the html/template dot-path
5031
5037
  // is — renders wrong. Post-fix: only the not-done todo (id 2) survives
5032
- // the 'active' filter.
5033
- expect(html).not.toContain('Eat breakfast')
5034
- expect(html).toContain('Write tests')
5035
- expect(html).toContain('data-key="2"')
5036
- expect(html).not.toContain('data-key="1"')
5038
+ // the 'active' filter. The bf-p hydration payload legitimately carries
5039
+ // the full unfiltered `initialTodos` (both quote styles, mirroring
5040
+ // `normalizeHTML`), so the assertions target the rendered list only.
5041
+ const rendered = html.replace(/\s*bf-p=(?:"[^"]*"|'[^']*')/g, '')
5042
+ expect(rendered).not.toContain('Eat breakfast')
5043
+ expect(rendered).toContain('Write tests')
5044
+ expect(rendered).toContain('data-key="2"')
5045
+ expect(rendered).not.toContain('data-key="1"')
5037
5046
  })
5038
5047
  })
5039
5048
 
@@ -5909,3 +5918,157 @@ export function Counter() {
5909
5918
  expect(template).not.toContain('<link')
5910
5919
  })
5911
5920
  })
5921
+
5922
+ describe('GoTemplateAdapter - #2674 anonymous object types synthesize named structs', () => {
5923
+ // Plan A: a type with no name — an inline array-element type or a nested
5924
+ // anonymous property inside a named type — used to lower to
5925
+ // `map[string]interface{}` with DELIBERATELY PascalCased keys
5926
+ // (`bakeInlineObjectAsGoMap`, #2087/#1487: `html/template`'s dot access on
5927
+ // a map does an exact-string `MapIndex`). SSR rendered fine off that
5928
+ // convention, but `BfPropsAttr`'s `json.Marshal` ships the SAME map, so
5929
+ // the hydration payload leaked Go casing (`{"Name":"Ada"}` instead of
5930
+ // `{"name":"Ada"}`). `emitSynthPropStructs` now synthesizes a
5931
+ // deterministically-named, json-tagged struct for these types instead, so
5932
+ // the SAME value bakes as a typed struct literal and `json.Marshal`
5933
+ // produces the correct camelCase payload without changing SSR at all.
5934
+
5935
+ test('inline array-element type synthesizes a named json-tagged struct (case i)', () => {
5936
+ // `items: { id: number; tags: string[] }[]` has no backing
5937
+ // `TypeDefinition` at all — only `ir.metadata.propsParams`' own
5938
+ // `TypeInfo` tree carries its shape.
5939
+ const adapter = new GoTemplateAdapter()
5940
+ const ir = compileToIR(`
5941
+ function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
5942
+ return <ul>{props.items.map((p) => <li key={p.title}>{p.title}</li>)}</ul>
5943
+ }
5944
+ export { TaggedList }
5945
+ `)
5946
+ const types = adapter.generateTypes(ir)!
5947
+ expect(types).toContain('type TaggedListItemsItem struct {')
5948
+ expect(types).toContain('Title string `json:"title"`')
5949
+ expect(types).toContain('Tags []string `json:"tags"`')
5950
+ // The Input/Props field is a typed struct slice, not the old
5951
+ // `[]map[string]interface{}` fallback.
5952
+ expect(types).toMatch(/Items \[\]TaggedListItemsItem/)
5953
+ expect(types).not.toContain('map[string]interface{}')
5954
+ })
5955
+
5956
+ test('nested anonymous property inside a NAMED type synthesizes a named struct (case ii, Row.user)', () => {
5957
+ // `Row` is a real `TypeDefinition`; its `user` property has no name of
5958
+ // its own — only `ir.metadata.typeDefinitions` carries `Row`'s property
5959
+ // list (a `{kind:'interface', raw:'Row'}` prop reference does not).
5960
+ const adapter = new GoTemplateAdapter()
5961
+ const ir = compileToIR(`
5962
+ "use client"
5963
+ import { createSignal } from "@barefootjs/client"
5964
+
5965
+ type Row = { id: string; user: { name: string } }
5966
+ export function NestedNames() {
5967
+ const [rows, setRows] = createSignal<Row[]>([
5968
+ { id: "r1", user: { name: "Ada" } },
5969
+ { id: "r2", user: { name: "Grace" } },
5970
+ ])
5971
+ return (
5972
+ <ul onClick={() => setRows((r) => r)}>
5973
+ {rows().map(({ id, user: { name } }) => (
5974
+ <li key={id}>{name}</li>
5975
+ ))}
5976
+ </ul>
5977
+ )
5978
+ }
5979
+ `)
5980
+ const types = adapter.generateTypes(ir)!
5981
+ expect(types).toContain('type RowUser struct {')
5982
+ expect(types).toContain('Name string `json:"name"`')
5983
+ expect(types).toContain('User RowUser `json:"user"`')
5984
+ // The signal's inline initial value bakes through the STRUCT literal
5985
+ // path, not `bakeInlineObjectAsGoMap`'s capitalized-key map convention.
5986
+ expect(types).toContain('Row{ID: "r1", User: RowUser{Name: "Ada"}}')
5987
+ expect(types).not.toContain('map[string]interface{}')
5988
+ })
5989
+
5990
+ test('a synthesized-name collision gracefully falls back to the pre-#2674 map convention, not a regression', () => {
5991
+ // `synthesizeStructFromSignal`'s existing collision precedent
5992
+ // (#1680, `keeps nil when the synthesised name collides with a user
5993
+ // type`), mirrored for `emitSynthPropStructs`: when the deterministic
5994
+ // name (`Row><Prop>`) is already taken by a real user type, synthesis
5995
+ // is declined for that ONE type and it keeps the historical map
5996
+ // fallback — SSR stays correct (`bakeInlineObjectAsGoMap` still bakes
5997
+ // it), only the hydration-payload casing fix doesn't apply to it.
5998
+ const adapter = new GoTemplateAdapter()
5999
+ const ir = compileToIR(`
6000
+ "use client"
6001
+ import { createSignal } from "@barefootjs/client"
6002
+
6003
+ type RowUser = { handle: string }
6004
+ type Row = { id: string; user: { name: string } }
6005
+ export function NestedNames() {
6006
+ const [rows] = createSignal<Row[]>([{ id: "r1", user: { name: "Ada" } }])
6007
+ return <ul>{rows().map(({ id, user: { name } }) => <li key={id}>{name}</li>)}</ul>
6008
+ }
6009
+ `)
6010
+ const types = adapter.generateTypes(ir)!
6011
+ // The user's own `RowUser` struct is emitted, untouched...
6012
+ expect(types).toContain('type RowUser struct {')
6013
+ expect(types).toContain('Handle string `json:"handle"`')
6014
+ // ...and `Row.user` — whose synthesized name collides with it — falls
6015
+ // back to the map convention rather than being silently mistyped as
6016
+ // the unrelated user type.
6017
+ expect(types).toMatch(/User map\[string\]interface\{\}/)
6018
+ expect(types).toContain('map[string]interface{}{"Name": "Ada"}')
6019
+ })
6020
+
6021
+ test('real go-run render: bf-p carries camelCase keys for an inline array-element prop (case i)', async () => {
6022
+ const source = `
6023
+ function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
6024
+ return <ul>{props.items.map((p) => <li key={p.title}>{p.title}</li>)}</ul>
6025
+ }
6026
+ export { TaggedList }
6027
+ `
6028
+ let html: string
6029
+ try {
6030
+ html = await renderGoTemplateComponent({
6031
+ source,
6032
+ adapter: new GoTemplateAdapter(),
6033
+ props: { items: [{ title: 'Alpha', tags: ['a', 'b'] }] },
6034
+ })
6035
+ } catch (err) {
6036
+ if (err instanceof GoNotAvailableError) return // Go toolchain not installed on this host
6037
+ throw err
6038
+ }
6039
+ const bfPMatch = html.match(/bf-p="([^"]*)"/)
6040
+ expect(bfPMatch).not.toBeNull()
6041
+ const decoded = bfPMatch![1]
6042
+ .replace(/&#34;/g, '"')
6043
+ .replace(/&quot;/g, '"')
6044
+ const payload = JSON.parse(decoded)
6045
+ expect(payload).toEqual({ items: [{ title: 'Alpha', tags: ['a', 'b'] }] })
6046
+ })
6047
+
6048
+ test('real go-run render: bf-p carries camelCase keys for a nested anonymous object inside a named type (case ii)', async () => {
6049
+ const source = `
6050
+ "use client"
6051
+ import { createSignal } from "@barefootjs/client"
6052
+
6053
+ type Row = { id: string; user: { name: string } }
6054
+ export function NestedNames() {
6055
+ const [rows] = createSignal<Row[]>([{ id: "r1", user: { name: "Ada" } }])
6056
+ return <ul>{rows().map(({ id, user: { name } }) => <li key={id}>{name}</li>)}</ul>
6057
+ }
6058
+ `
6059
+ let html: string
6060
+ try {
6061
+ html = await renderGoTemplateComponent({ source, adapter: new GoTemplateAdapter() })
6062
+ } catch (err) {
6063
+ if (err instanceof GoNotAvailableError) return // Go toolchain not installed on this host
6064
+ throw err
6065
+ }
6066
+ const bfPMatch = html.match(/bf-p="([^"]*)"/)
6067
+ expect(bfPMatch).not.toBeNull()
6068
+ const decoded = bfPMatch![1]
6069
+ .replace(/&#34;/g, '"')
6070
+ .replace(/&quot;/g, '"')
6071
+ const payload = JSON.parse(decoded)
6072
+ expect(payload).toEqual({ rows: [{ id: 'r1', user: { name: 'Ada' } }] })
6073
+ })
6074
+ })
@@ -18,6 +18,7 @@ import type {
18
18
  IRProp,
19
19
  TypeInfo,
20
20
  TypeDefinition,
21
+ PropertyInfo,
21
22
  CompilerError,
22
23
  SourceLocation,
23
24
  ParsedExpr,
@@ -142,6 +143,20 @@ import { collectStringValueNames } from "./props/prop-classes.ts"
142
143
 
143
144
  export type { GoTemplateAdapterOptions } from "./lib/types.ts"
144
145
 
146
+ /**
147
+ * Placeholder `SourceLocation` for a `TypeDefinition` `emitSynthPropStructs`
148
+ * (#2674) pushes onto `ctx.state.currentTypeDefinitions` for a synthesized
149
+ * anonymous-object struct. Never rendered or used for diagnostics — the
150
+ * synthesized entry exists only so `parsed-literal-to-go.ts`'s
151
+ * `structPropertyType` can look its properties up BY NAME the same way it
152
+ * looks up a real user type.
153
+ */
154
+ const SYNTH_TYPE_LOC: SourceLocation = {
155
+ file: '<synthesized>',
156
+ start: { line: 0, column: 0 },
157
+ end: { line: 0, column: 0 },
158
+ }
159
+
145
160
  /**
146
161
  * Local re-materialisation of the (removed) `higher-order` ParsedExpr variant
147
162
  * (#2018 P5). Predicate callback methods (`.filter`/`.find`/`.every`/…) now
@@ -1076,6 +1091,12 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1076
1091
 
1077
1092
  this.buildLocalTypeTables(ir, componentName)
1078
1093
 
1094
+ // #2674 Plan A: synthesize named structs for anonymous object types
1095
+ // BEFORE emitting named-type structs — a nested anonymous property
1096
+ // inside a named type (`Row.user`) needs its synthesized name registered
1097
+ // before `emitLocalTypeStructs` computes `Row`'s own struct fields.
1098
+ this.emitSynthPropStructs(lines, ir, componentName)
1099
+
1079
1100
  this.emitLocalTypeStructs(lines, ir, componentName)
1080
1101
 
1081
1102
  this.emitSynthStructs(lines, ir, componentName)
@@ -1295,8 +1316,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1295
1316
  * needs a real field to bake into, or the whole literal defers to nil); a
1296
1317
  * dedup guard drops a later key that sanitizes to a Go name already taken
1297
1318
  * (rare, but two fields can't share one Go identifier).
1319
+ *
1320
+ * Accepts anything carrying a `PropertyInfo[]` — a `TypeDefinition` (a
1321
+ * user-named type) OR a bare `TypeInfo` of `kind: 'object'` (an anonymous
1322
+ * type `emitSynthPropStructs` is synthesizing a struct for, #2674) — so
1323
+ * both the named-type struct emitter and the anonymous-type synthesis
1324
+ * pre-pass share one field-derivation path.
1298
1325
  */
1299
- private structFieldsFor(td: TypeDefinition): Array<{ tsName: string; goName: string; goType: string }> {
1326
+ private structFieldsFor(td: { properties?: PropertyInfo[] }): Array<{ tsName: string; goName: string; goType: string }> {
1300
1327
  const fields: Array<{ tsName: string; goName: string; goType: string }> = []
1301
1328
  const seenGoNames = new Set<string>()
1302
1329
  for (const prop of td.properties ?? []) {
@@ -2450,6 +2477,142 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2450
2477
  }
2451
2478
  }
2452
2479
 
2480
+ /**
2481
+ * #2674 Plan A: synthesize a deterministically-named, json-tagged struct
2482
+ * for every ANONYMOUS (`kind: 'object'`) type reachable from the
2483
+ * component's type surface, so `typeInfoToGo`'s `'object'` case (consulted
2484
+ * by `emitLocalTypeStructs`/`generateInputStruct` right after this runs)
2485
+ * resolves a real struct instead of falling to `map[string]interface{}` —
2486
+ * the map `bakeInlineObjectAsGoMap` (`parsed-literal-to-go.ts`) bakes with
2487
+ * DELIBERATELY PascalCased keys for `html/template`'s exact-case
2488
+ * `MapIndex` (#2087/#1487), which `BfPropsAttr`'s `json.Marshal` then
2489
+ * ships verbatim — the source of the hydration-payload Go-casing leak
2490
+ * this pass closes.
2491
+ *
2492
+ * Two independent walk roots, because a NAMED type reference
2493
+ * (`{kind:'interface', raw:'Row'}`) carries no inline `properties` — only
2494
+ * `ir.metadata.typeDefinitions` has `Row`'s own property list:
2495
+ *
2496
+ * 1. Every user `TypeDefinition`'s properties (closes a nested anonymous
2497
+ * property inside a named type — `type Row = { id: string; user: {
2498
+ * name: string } }` → `Row.user`). MUST run before
2499
+ * `emitLocalTypeStructs`: `Row`'s own struct-field computation
2500
+ * (`typeDefinitionToGo` → `structFieldsFor` → `typeInfoToGo`) needs
2501
+ * the synthesized name for its `user` field already registered.
2502
+ * 2. Every props param's own `TypeInfo` tree (closes an inline
2503
+ * array-element type with no backing `TypeDefinition` at all —
2504
+ * `items: { id: number; tags: string[] }[]`).
2505
+ *
2506
+ * Naming is deterministic on STRUCTURAL POSITION (matching
2507
+ * `synthesizeStructFromSignal`'s `<component><Getter>Item` convention, not
2508
+ * shape/content — two anonymous types shaped identically at different
2509
+ * positions get different names, and the same position always yields the
2510
+ * same name run-to-run): an array-element object gets
2511
+ * `<parent><Prop>Item`; a direct nested object property gets
2512
+ * `<parent><Prop>`. `<parent>` is the enclosing struct's OWN Go name for a
2513
+ * walk-root-1 type (or the newly-synthesized name of an enclosing
2514
+ * anonymous type, for a doubly-nested object — `RowUserAddress`), or
2515
+ * `componentName` for a walk-root-2 (top-level props) type — threaded
2516
+ * through the recursion so nesting chains correctly regardless of which
2517
+ * root reached it.
2518
+ *
2519
+ * A synthesized name colliding with an EXISTING local type (rare: two
2520
+ * structurally-unrelated anonymous types resolving to the same
2521
+ * deterministic name) skips synthesis for that ONE type — and its own
2522
+ * subtree, since there is no struct to attach nested field names to —
2523
+ * gracefully, not as a regression: `typeInfoToGo` keeps returning the
2524
+ * pre-#2674 map fallback for exactly that type, so the corpus never
2525
+ * breaks, it just doesn't graduate for that one shape (see the
2526
+ * `synthObjectStructNames` docstring on `CompileState`).
2527
+ *
2528
+ * A synthesized struct is ALSO pushed onto `ctx.state.currentTypeDefinitions`
2529
+ * as a `TypeDefinition` (empty `definition`, a dummy `loc` — never
2530
+ * rendered or used for diagnostics, only looked up by name) so
2531
+ * `parsed-literal-to-go.ts`'s `structPropertyType` — which resolves a
2532
+ * struct literal's nested-property TYPE by struct name against that same
2533
+ * list — finds a synthesized parent's properties exactly like it finds a
2534
+ * real named type's, with no separate lookup path to keep in sync.
2535
+ */
2536
+ private emitSynthPropStructs(lines: string[], ir: ComponentIR, componentName: string): void {
2537
+ this.state.synthObjectStructNames = new Map<TypeInfo, string>()
2538
+ // `primeCompileState` assigns `currentTypeDefinitions` the SAME array
2539
+ // reference as `ir.metadata.typeDefinitions` (no clone) — copy before
2540
+ // pushing synthesized entries onto it below, so this per-compile
2541
+ // scratch state never mutates the IR's own metadata (which could be
2542
+ // compiled again, or read by another consumer sharing the same IR).
2543
+ this.state.currentTypeDefinitions = [...this.state.currentTypeDefinitions]
2544
+
2545
+ const visitObject = (typeInfo: TypeInfo, desiredName: string): void => {
2546
+ // Identity guard: the exact same anonymous TypeInfo object reached via
2547
+ // both walk roots (defensive — not expected given how the analyzer
2548
+ // builds distinct TypeInfo instances per source occurrence).
2549
+ if (this.state.synthObjectStructNames.has(typeInfo)) return
2550
+ // Name-collision guard: graceful fallback to the map convention for
2551
+ // just this type (see docstring above).
2552
+ if (this.state.localTypeNames.has(desiredName)) return
2553
+ this.state.localTypeNames.add(desiredName)
2554
+ this.state.synthObjectStructNames.set(typeInfo, desiredName)
2555
+ // Register nested children FIRST (depth-first) so this struct's OWN
2556
+ // field-type resolution below (`structFieldsFor` → `typeInfoToGo`)
2557
+ // sees synthesized names for any of ITS OWN nested object /
2558
+ // array-of-object properties instead of racing ahead of them.
2559
+ for (const prop of typeInfo.properties ?? []) {
2560
+ visit(prop.type, desiredName, prop.name)
2561
+ }
2562
+ const fields = this.structFieldsFor(typeInfo)
2563
+ this.state.localStructFields.set(desiredName, new Map(fields.map(f => [f.tsName, f.goName])))
2564
+ this.state.currentTypeDefinitions.push({
2565
+ kind: 'type',
2566
+ name: desiredName,
2567
+ definition: '',
2568
+ properties: typeInfo.properties ?? [],
2569
+ loc: SYNTH_TYPE_LOC,
2570
+ })
2571
+ const goFields = fields.map(
2572
+ f => `\t${f.goName} ${f.goType} \`json:"${this.toJsonTag(f.tsName)}"\``,
2573
+ )
2574
+ lines.push(`// ${desiredName} is a synthesised type for an anonymous object type (#2674).`)
2575
+ lines.push(`type ${desiredName} struct {\n${goFields.join('\n')}\n}`)
2576
+ lines.push('')
2577
+ }
2578
+
2579
+ const visitArrayElem = (elemType: TypeInfo | undefined, parentName: string, propName: string): void => {
2580
+ if (!elemType) return
2581
+ if (elemType.kind === 'array') {
2582
+ // Array-of-array (`matrix: {id:number}[][]`): keep the same
2583
+ // parent/prop naming context at every depth — rare shape, not one
2584
+ // the two documented #2674 cases exercise, so this just needs to
2585
+ // stay deterministic and non-colliding, not maximally descriptive.
2586
+ visitArrayElem(elemType.elementType, parentName, propName)
2587
+ } else if (elemType.kind === 'object') {
2588
+ visitObject(elemType, `${parentName}${goFieldNameForKey(propName)}Item`)
2589
+ }
2590
+ }
2591
+
2592
+ const visit = (typeInfo: TypeInfo, parentName: string, propName: string): void => {
2593
+ if (typeInfo.kind === 'array') {
2594
+ visitArrayElem(typeInfo.elementType, parentName, propName)
2595
+ } else if (typeInfo.kind === 'object') {
2596
+ visitObject(typeInfo, `${parentName}${goFieldNameForKey(propName)}`)
2597
+ }
2598
+ }
2599
+
2600
+ // Walk root 1: named types' own properties (closes `Row.user`).
2601
+ for (const td of ir.metadata.typeDefinitions) {
2602
+ if (td.name === 'Props' || td.name === `${componentName}Props`) continue
2603
+ if (td.name.endsWith('Props')) continue
2604
+ for (const prop of td.properties ?? []) {
2605
+ visit(prop.type, td.name, prop.name)
2606
+ }
2607
+ }
2608
+
2609
+ // Walk root 2: inline prop types with no backing TypeDefinition (closes
2610
+ // `items: { id: number; tags: string[] }[]`).
2611
+ for (const param of ir.metadata.propsParams) {
2612
+ visit(param.type, componentName, param.name)
2613
+ }
2614
+ }
2615
+
2453
2616
  private emitLocalTypeStructs(lines: string[], ir: ComponentIR, componentName: string): void {
2454
2617
  for (const td of ir.metadata.typeDefinitions) {
2455
2618
  if (td.name === 'Props' || td.name === `${componentName}Props`) continue
@@ -2552,7 +2715,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2552
2715
  private emitPropsStructHeader(lines: string[], ir: ComponentIR, propsTypeName: string, componentName: string): void {
2553
2716
  lines.push(`// ${propsTypeName} is the props type for the ${componentName} component.`)
2554
2717
  lines.push(`type ${propsTypeName} struct {`)
2555
- lines.push('\tScopeID string `json:"scopeID"`')
2718
+ // Internal scope id: used by the Go template (`{{.ScopeID}}`) to render bf-s
2719
+ // markers, but no client runtime consumer ever reads it back out of the
2720
+ // hydration bf-p JSON (audited: the only bf-p parser is
2721
+ // packages/client/src/runtime/hydrate.ts's parseProps/runInit, and nothing
2722
+ // downstream of it reads scopeID). Excluded from Marshal via `json:"-"` — Go's
2723
+ // json tag only affects (un)marshalling, not template field access, so
2724
+ // `{{.ScopeID}}` keeps working unchanged.
2725
+ lines.push('\tScopeID string `json:"-"`')
2556
2726
  lines.push('\tBfIsRoot bool `json:"-"`')
2557
2727
  lines.push('\tBfIsChild bool `json:"-"`')
2558
2728
  // Slot identity for child scopes: host scope id + slot id. Emitted as bf-h /
@@ -242,6 +242,22 @@ export class CompileState {
242
242
  */
243
243
  synthStructTypes: Map<string, TypeInfo> = new Map()
244
244
 
245
+ /**
246
+ * #2674 Plan A: ANONYMOUS (`kind: 'object'`) `TypeInfo` instance → the
247
+ * deterministically-named json-tagged struct `emitSynthPropStructs`
248
+ * synthesized for it, populated during generateTypes. Keyed by object
249
+ * IDENTITY, not name/shape: two anonymous object types at different
250
+ * structural positions (an inline array-element type vs. a nested
251
+ * property inside a named type) get different synthesized names even
252
+ * when shaped identically, so a content/shape key would wrongly unify
253
+ * them. `typeInfoToGo`'s `'object'` case consults this before falling
254
+ * back to `map[string]interface{}` — the map fallback stays reachable
255
+ * for the ONE case this pass declines: a synthesized name colliding with
256
+ * an existing local type (graceful, not a regression — see
257
+ * `emitSynthPropStructs`'s docstring).
258
+ */
259
+ synthObjectStructNames: Map<TypeInfo, string> = new Map()
260
+
245
261
  /** Set when a constructor-context lowering emits a `strings.` call, so
246
262
  * `strings` is added to the generated types file's import block. */
247
263
  needsStringsImport = false