@barefootjs/go-template 0.31.8 → 0.31.10

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.
@@ -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,
@@ -118,7 +119,7 @@ import type {
118
119
  } from "./lib/types.ts"
119
120
  import { collectRootScopeNodes } from "./lib/ir-scope.ts"
120
121
  import { GO_TEMPLATE_PRIMITIVES } from "./lib/constants.ts"
121
- import { CompileState } from "./lib/compile-state.ts"
122
+ import { CompileState, resolveSignalParsedThroughSeedPlan } from "./lib/compile-state.ts"
122
123
  import { hasClientInteractivity, findNestedComponents } from "./analysis/component-tree.ts"
123
124
  import { analyzeBakeableStaticChildLoop, scalarToGoLiteral, type BakedStaticChildLoop } from "./analysis/static-child-loop-bake.ts"
124
125
  import { analyzeBakeableStaticElementLoop } from "./analysis/static-element-loop-bake.ts"
@@ -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 ?? []) {
@@ -1921,7 +1948,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1921
1948
  if (propFieldNames.has(fieldName)) continue
1922
1949
  // `props.X ?? N` reuses the hoisted fallback var so signal and memo share
1923
1950
  // one value.
1924
- const fallbackMatch = this.extractPropFallback(signal.initialValue, signal.parsed)
1951
+ const fallbackMatch = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
1925
1952
  const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
1926
1953
  if (hoisted) {
1927
1954
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
@@ -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 /
@@ -2612,7 +2782,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2612
2782
  if (signal.envReader) continue
2613
2783
  const fieldName = capitalizeFieldName(signal.getter)
2614
2784
  if (propFieldNames.has(fieldName)) continue
2615
- const jsonTag = this.claimJsonTag(this.toJsonTag(signal.getter), takenJsonTags)
2785
+ // Signal fields are component-internal state, not caller input (#2672):
2786
+ // the client never reads `_p.<signalGetter>` — it re-derives the value
2787
+ // from `createSignal(...)`'s own initial expression (which itself reads
2788
+ // whatever PROP field seeded it, already emitted above with a real tag
2789
+ // by the propsParams loop). Excluding the signal field from JSON stops
2790
+ // it from co-boarding into `bf-p` while leaving `{{.Field}}` SSR access
2791
+ // untouched — Go template field access doesn't consult json tags.
2792
+ const jsonTag = '-'
2616
2793
  // A synthesised struct type wins outright — the signal is an untyped
2617
2794
  // object array we gave a concrete element type.
2618
2795
  const synthType = this.state.synthStructTypes.get(signal.getter)
@@ -2662,7 +2839,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2662
2839
  for (const memo of ir.metadata.memos) {
2663
2840
  const fieldName = capitalizeFieldName(memo.name)
2664
2841
  if (propFieldNames.has(fieldName)) continue
2665
- const jsonTag = this.claimJsonTag(this.toJsonTag(memo.name), takenJsonTags)
2842
+ // Memo fields are derived, re-computed client-side from the same prop
2843
+ // reads the memo body itself performs — never read as `_p.<memoName>`
2844
+ // (#2672). Same rationale as the signal fields above.
2845
+ const jsonTag = '-'
2666
2846
  const goType = this.inferMemoType(memo, ir.metadata.signals, propsParamMap)
2667
2847
  lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
2668
2848
  }
@@ -2698,10 +2878,27 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2698
2878
  ...ir.metadata.memos.map(m => capitalizeFieldName(m.name)),
2699
2879
  ])
2700
2880
  for (const c of this.nonCollidingContextConsumers(takenProps)) {
2701
- const jsonTag = this.claimJsonTag(this.toJsonTag(c.localName), takenJsonTags)
2881
+ // Context-consumer fields are resolved server-side from the enclosing
2882
+ // `Provider` and read via `{{.Field}}` in SSR only — the client's own
2883
+ // `useContext` re-reads the DOM-scoped provider value at hydration
2884
+ // time, never `_p.<contextField>` (#2672).
2885
+ const jsonTag = '-'
2702
2886
  lines.push(`\t${this.contextFieldName(c)} ${this.contextConsumerGoType(c)} \`json:"${jsonTag}"\``)
2703
2887
  }
2704
2888
 
2889
+ // Capitalized Go field names of every declared prop — BOTH the LOCAL
2890
+ // binding and the caller-facing (`sourceName`) spelling, unioned the
2891
+ // same way `isNestedArrayShadowed` does for the propsParams loop above
2892
+ // (an aliased destructure like `{ rows: items }` can collide under
2893
+ // either naming depending on alias direction — see the #2525 collision
2894
+ // tests). Used below to detect when a nested-array field's name
2895
+ // collides with (and shadows) an actual prop field.
2896
+ const propDrivingFieldNames = new Set<string>()
2897
+ for (const p of ir.metadata.propsParams) {
2898
+ propDrivingFieldNames.add(capitalizeFieldName(p.name))
2899
+ propDrivingFieldNames.add(capitalizeFieldName(p.sourceName ?? p.name))
2900
+ }
2901
+
2705
2902
  for (const nested of nestedComponents) {
2706
2903
  // An orphaned clientOnly nested loop (#2627 — see
2707
2904
  // `isOrphanedClientOnlyNested`) gets NO Props field at all, not even
@@ -2719,8 +2916,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2719
2916
  if (nested.isDynamic && !nested.isPropDerived) {
2720
2917
  // Dynamic signal-array loops are template-only.
2721
2918
  lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
2919
+ } else if (
2920
+ nested.isDynamic &&
2921
+ nested.isPropDerived &&
2922
+ !propDrivingFieldNames.has(`${nested.name}s`)
2923
+ ) {
2924
+ // Prop-derived dynamic loops (`props.items.map(item => <Child/>)`,
2925
+ // #2672): this field is USUALLY a RE-SHAPED COPY of the driving prop,
2926
+ // built for SSR's `{{range}}` — not the prop itself. The client's own
2927
+ // `mapArray` re-derives every row straight from the real prop field
2928
+ // (`_p.items`, emitted with a real tag by the propsParams loop
2929
+ // above), never from `_p.<Name>s`, so co-boarding this copy into
2930
+ // `bf-p` is redundant component-internal derivation, same as a memo.
2931
+ //
2932
+ // EXCEPT when the two Go field names collide (`propDrivingFieldNames`
2933
+ // — mirrors `isNestedArrayShadowed`'s check the propsParams loop
2934
+ // itself runs): a prop named `toggleItems` driving a `<ToggleItem>`
2935
+ // loop capitalizes to the SAME Go field name as the nested-array
2936
+ // field (`ToggleItems`), so `emitPropsDataFields` shadows the prop's
2937
+ // OWN field entirely and this array field is the ONLY carrier of
2938
+ // that prop's data. Flipping it there would silently drop caller
2939
+ // input from `bf-p` instead of merely trimming a redundant copy —
2940
+ // the `else` branch below keeps a real tag for exactly that case.
2941
+ lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
2722
2942
  } else {
2723
- // Static + prop-derived arrays go in JSON so the client can hydrate.
2943
+ // Static arrays go in JSON so the client can hydrate (that data can
2944
+ // be non-literal, request-time Input the caller supplies with no
2945
+ // prop-field twin to fall back on) — and so does a prop-derived
2946
+ // dynamic array whose field name shadows its own driving prop's
2947
+ // field (the `propDrivingFieldNames` case above): this field is
2948
+ // that prop's ONLY remaining carrier in the struct.
2724
2949
  const jsonTag = this.claimJsonTag(
2725
2950
  this.toJsonTag(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`),
2726
2951
  takenJsonTags,
@@ -2736,9 +2961,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2736
2961
 
2737
2962
  // Top-level intrinsic-element spreads: each gets a `Spread_<slotId>
2738
2963
  // map[string]any` field the template reads via `{{bf_spread_attrs}}`.
2739
- // Loop-internal spreads emit inline and don't appear here.
2964
+ // Loop-internal spreads emit inline and don't appear here. SSR-only —
2965
+ // the resolved attrs are already baked into the rendered HTML, and no
2966
+ // client runtime reads `_p.Spread_<slotId>` back out of `bf-p` (#2672).
2740
2967
  for (const slot of spreadSlots) {
2741
- const jsonTag = this.claimJsonTag(this.toJsonTag(slot.slotId), takenJsonTags)
2968
+ const jsonTag = '-'
2742
2969
  lines.push(`\t${slot.slotId} map[string]any \`json:"${jsonTag}"\``)
2743
2970
  }
2744
2971
  }
@@ -3361,7 +3588,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3361
3588
 
3362
3589
  const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
3363
3590
  for (const signal of ir.metadata.signals) {
3364
- const match = this.extractPropFallback(signal.initialValue, signal.parsed)
3591
+ const match = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
3365
3592
  if (!match) continue
3366
3593
  if (result.has(match.propName)) continue
3367
3594
  const param = ir.metadata.propsParams.find(p => p.name === match.propName)
@@ -3426,6 +3653,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3426
3653
  return result
3427
3654
  }
3428
3655
 
3656
+ /**
3657
+ * Resolve a signal's initializer through the SSR seed plan's const-hop
3658
+ * inlining (#2685) before prop-fallback extraction — see
3659
+ * {@link resolveSignalParsedThroughSeedPlan} (shared with
3660
+ * `collectNullishConsumedPropNames`'s signal-seed loop, the single door
3661
+ * both consumers of this resolution go through).
3662
+ */
3663
+ private resolvedSignalParsed(signal: { getter: string; parsed?: ParsedExpr }): ParsedExpr | undefined {
3664
+ return resolveSignalParsedThroughSeedPlan(this.state, signal)
3665
+ }
3666
+
3429
3667
  /**
3430
3668
  * Parse a signal-time initial value of the form `props.X ?? <literal>` —
3431
3669
  * or, for destructured components, `x ?? <literal>` — into the source prop
@@ -22,6 +22,7 @@ import type {
22
22
  IRNode,
23
23
  LoweringMatcher,
24
24
  MemoInfo,
25
+ ParsedExpr,
25
26
  SsrSeedPlan,
26
27
  TypeDefinition,
27
28
  TypeInfo,
@@ -242,7 +243,56 @@ export class CompileState {
242
243
  */
243
244
  synthStructTypes: Map<string, TypeInfo> = new Map()
244
245
 
246
+ /**
247
+ * #2674 Plan A: ANONYMOUS (`kind: 'object'`) `TypeInfo` instance → the
248
+ * deterministically-named json-tagged struct `emitSynthPropStructs`
249
+ * synthesized for it, populated during generateTypes. Keyed by object
250
+ * IDENTITY, not name/shape: two anonymous object types at different
251
+ * structural positions (an inline array-element type vs. a nested
252
+ * property inside a named type) get different synthesized names even
253
+ * when shaped identically, so a content/shape key would wrongly unify
254
+ * them. `typeInfoToGo`'s `'object'` case consults this before falling
255
+ * back to `map[string]interface{}` — the map fallback stays reachable
256
+ * for the ONE case this pass declines: a synthesized name colliding with
257
+ * an existing local type (graceful, not a regression — see
258
+ * `emitSynthPropStructs`'s docstring).
259
+ */
260
+ synthObjectStructNames: Map<TypeInfo, string> = new Map()
261
+
245
262
  /** Set when a constructor-context lowering emits a `strings.` call, so
246
263
  * `strings` is added to the generated types file's import block. */
247
264
  needsStringsImport = false
248
265
  }
266
+
267
+ /**
268
+ * Resolve a signal's initializer through the SSR seed plan's const-hop
269
+ * inlining (#2685, `resolveThroughLocalConsts` in
270
+ * `packages/jsx/src/ssr-seed-plan.ts`) before prop-fallback shape matching,
271
+ * so a component-scope `const` sitting between a `props.X` read and
272
+ * `createSignal` (`const mid = props.label; createSignal(mid ?? 'Default')`)
273
+ * doesn't hide the `props.X ?? <literal>` shape from
274
+ * `extractPropFallbackFromParsed` / `collectNullishConsumedPropNames`'s
275
+ * signal-seed loop — both need the SAME resolved tree (single door, not two
276
+ * divergent walks: a mismatch between them is exactly what let the field-type
277
+ * flip and the fallback-var construction disagree on nullish handling for
278
+ * the const-hop shape).
279
+ *
280
+ * Falls back to the signal's own `parsed` when the plan didn't classify this
281
+ * signal `derived` (opaque for an unrelated reason, e.g. a free identifier
282
+ * genuinely out of scope) — never invents a substitution the plan itself
283
+ * didn't make.
284
+ *
285
+ * `signal` takes the minimal structural shape (not the internal `SignalInfo`
286
+ * type, which `@barefootjs/jsx`'s public index doesn't export — the same
287
+ * inline-shape convention `memo-compute.ts`'s extracted helpers already use
288
+ * for signal params).
289
+ */
290
+ export function resolveSignalParsedThroughSeedPlan(
291
+ state: CompileState,
292
+ signal: { getter: string; parsed?: ParsedExpr },
293
+ ): ParsedExpr | undefined {
294
+ const step = state.ssrSeedPlan.steps.find(
295
+ s => s.kind === 'derived' && s.origin === 'signal' && s.name === signal.getter,
296
+ )
297
+ return step?.kind === 'derived' ? step.parsed : signal.parsed
298
+ }
@@ -9,13 +9,37 @@ import type { ComponentIR, IRMetadata, IRNode, ParsedExpr } from '@barefootjs/js
9
9
  import { isBooleanAttr } from '@barefootjs/jsx'
10
10
 
11
11
  import type { GoEmitContext } from '../emit-context.ts'
12
+ import { resolveSignalParsedThroughSeedPlan } from '../lib/compile-state.ts'
12
13
  import { typeInfoToGo } from '../type/type-codegen.ts'
13
14
 
14
15
  /**
15
16
  * Build a map from prop name to a better Go type inferred from signals. When a
16
17
  * signal is initialized from a prop (`createSignal(props.initial ?? 0)`), the
17
- * signal's type annotation may be more specific than the prop's `TypeInfo`. Only
18
- * generic prop types (containing `interface{}`) are overridden.
18
+ * signal's type annotation may be more specific than the prop's `TypeInfo`.
19
+ *
20
+ * Two trigger conditions, mirroring `emitPropsDataFields`'s identical
21
+ * signal-vs-prop reconciliation for the PROPS struct field (`generate - Go
22
+ * struct types` — "Let a specific signal type override a less-specific prop
23
+ * type in either direction"), so the Input/Props field type this override
24
+ * feeds (via `resolvePropGoType`) and the constructor's baked VALUE
25
+ * (`convertInitialValue`'s `extractPropNameFromInitialValue` prop-passthrough
26
+ * shortcut) never disagree about which type is authoritative:
27
+ *
28
+ * 1. A generic prop type (containing `interface{}`) — the historical case.
29
+ * 2. #2674: BOTH sides resolve to a CONCRETE type that DISAGREES — e.g. an
30
+ * inline array-element prop type (`initialTodos: Array<{ id, text,
31
+ * done }>`) now independently synthesizes its OWN named struct
32
+ * (`TodoAppInitialTodosItem`) rather than falling to `interface{}`, but
33
+ * a signal seeded from it via a shape-widening transform
34
+ * (`createSignal<Todo[]>((props.initialTodos ?? []).map(t => ({...t,
35
+ * editing: false})))`, `Todo` carrying an EXTRA `editing` field) still
36
+ * wants the signal's own `Todo` element type. Before #2674 this case
37
+ * was unreachable — every inline object/array prop type WAS
38
+ * `interface{}`-containing, so case 1 always fired — leaving the
39
+ * passthrough shortcut safe by accident (prop and signal Go types were
40
+ * always forced equal). Widening to case 2 restores that same
41
+ * equality now that a prop's own type can independently resolve to
42
+ * something concrete but narrower.
19
43
  */
20
44
  export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map<string, string> {
21
45
  const overrides = new Map<string, string>()
@@ -28,11 +52,10 @@ export function buildPropTypeOverrides(ctx: GoEmitContext, ir: ComponentIR): Map
28
52
  const param = ir.metadata.propsParams.find(p => p.name === propName)
29
53
  if (!param) continue
30
54
  const propGoType = typeInfoToGo(ctx, param.type, param.defaultValue, param.parsed)
31
- if (propGoType.includes('interface{}')) {
32
- const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue, signal.parsed)
33
- if (!signalGoType.includes('interface{}')) {
34
- overrides.set(propName, signalGoType)
35
- }
55
+ const signalGoType = typeInfoToGo(ctx, signal.type, signal.initialValue, signal.parsed)
56
+ if (signalGoType.includes('interface{}')) continue // never widen TO something less resolved
57
+ if (propGoType.includes('interface{}') || signalGoType !== propGoType) {
58
+ overrides.set(propName, signalGoType)
36
59
  }
37
60
  }
38
61
  }
@@ -196,7 +219,12 @@ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: Componen
196
219
  // tree — reuse the seam's existing `props.X ?? <literal>` recognizer. The
197
220
  // zero-equivalent exclusion matches on the Go-formatted fallback literal.
198
221
  for (const signal of ir.metadata.signals) {
199
- const match = ctx.extractPropFallback(signal.initialValue, signal.parsed)
222
+ // Resolved through the seed plan's const-hop inlining (#2685) — see
223
+ // `resolveSignalParsedThroughSeedPlan` — so a component-scope `const`
224
+ // between `props.X` and `createSignal` doesn't leave this loop and
225
+ // `collectPropFallbackVars`'s identical extraction disagreeing on
226
+ // whether the prop needs the nillable flip.
227
+ const match = ctx.extractPropFallback(signal.initialValue, resolveSignalParsedThroughSeedPlan(ctx.state, signal))
200
228
  if (!match || !optionalParams.has(match.propName)) continue
201
229
  const f = match.goFallback
202
230
  if (f === '""' || f === 'false' || f === 'nil' || Number(f) === 0) continue
@@ -141,8 +141,25 @@ export function typeInfoToGo(
141
141
  return `[]${typeInfoToGo(ctx, typeInfo.elementType)}`
142
142
  }
143
143
  return '[]interface{}'
144
- case 'object':
145
- return 'map[string]interface{}'
144
+ case 'object': {
145
+ // #2674 Plan A: an ANONYMOUS object type — an inline array-element
146
+ // type (`items: { id: number }[]`) or a nested anonymous property
147
+ // inside a named type (`Row.user`) — synthesizes a deterministically-
148
+ // named, json-tagged struct in `emitSynthPropStructs`, registered here
149
+ // by the exact `TypeInfo` object's IDENTITY (not name/shape — two
150
+ // anonymous types at different structural positions get different
151
+ // synthesized names even when shaped identically). That pre-pass runs
152
+ // before this function is ever consulted for the current compile (see
153
+ // `generateTypes()`'s ordering), so a hit here means a real struct was
154
+ // emitted; a miss (no pre-pass ran, or the pre-pass skipped this exact
155
+ // type on a synthesized-name collision) falls back to the historical
156
+ // `map[string]interface{}` — the same PascalCase-baked map
157
+ // `bakeInlineObjectAsGoMap` (`parsed-literal-to-go.ts`) still targets,
158
+ // so SSR stays correct and only the hydration-payload leak the
159
+ // synthesis closes remains open for that one skipped type.
160
+ const synthName = ctx.state.synthObjectStructNames.get(typeInfo)
161
+ return synthName ?? 'map[string]interface{}'
162
+ }
146
163
  case 'interface':
147
164
  // Gate on an ACTUAL backing (a generated struct — `localStructFields` —
148
165
  // or a string-union alias — `localTypeAliases`, which emits `type X =
@@ -5,14 +5,40 @@
5
5
  * one object, so the skip list and the declaration can't drift. Keep the
6
6
  * file even when the set is empty — the next divergence lands here, not in
7
7
  * a re-created file.
8
+ *
9
+ * (#2630's `static-array-from-props-with-component-precomputed` divergence
10
+ * graduated once the harness (`test-render.ts`'s
11
+ * `buildDynamicChildLoopSeeding`, despite the name — see its doc comment)
12
+ * learned to seed a prop-backed static child-component loop's Props slice
13
+ * the same way it already seeded a signal-backed dynamic one: the adapter's
14
+ * own `emission` was never the bug, only this harness's route-handler
15
+ * stand-in was missing the prop-derived case.)
8
16
  */
9
17
 
10
18
  import type { RenderDivergences } from '@barefootjs/jsx'
11
19
 
12
20
  export const renderDivergences: RenderDivergences = {
13
- // #2630: the exact shape BF101's pass-as-prop suggestion (#2321) steers
14
- // go-template users into compiles clean, runs clean, silently renders the
15
- // loop host empty. Full analysis in the issue.
16
- 'static-array-from-props-with-component-precomputed':
17
- 'prop-backed child-component loop renders the loop host empty at SSR (https://github.com/piconic-ai/barefootjs/issues/2630)',
21
+ // #2683: the props-struct emitter (`go-template-adapter.ts`) skips a
22
+ // signal whose Go field name collides with a prop field, keyed purely on
23
+ // the NAME collision never on whether `extractPropFallback` actually
24
+ // matched a supported `props.x ?? <default>` shape. For a non-idempotent
25
+ // derivation (`createSignal((props.count ?? 1) * 2)`) the fallback
26
+ // extractor correctly declines to fold `* 2` into the struct default, but
27
+ // the `continue` fires anyway on the name match alone, so the emitted
28
+ // struct field silently drops the `* 2` and the signal's initial value
29
+ // renders as the raw prop instead of its derived value. Not a one-liner:
30
+ // two Go struct fields can't share an identifier, so simply removing the
31
+ // skip emits a duplicate field — the real fix needs its own PR.
32
+ 'signal-prop-same-name-derived':
33
+ 'self-derived signal collides with its prop field name in the generated Go props struct — the non-idempotent `* 2` derivation is dropped and the signal renders the raw prop value instead (https://github.com/piconic-ai/barefootjs/issues/2683)',
34
+ // #2685 review: same #2683 bug, one hop of const indirection removed —
35
+ // the props-struct field-name collision is keyed on the SIGNAL's name,
36
+ // not on how its initializer reaches the prop, so
37
+ // `const mid = props.count; createSignal((mid ?? 1) * 2)` collides
38
+ // exactly like the direct-access form above. This is go's PRE-EXISTING
39
+ // #2683 defect surfacing through a new fixture, not a regression from
40
+ // the #2685 review fix (which lands correctly on every other
41
+ // template-stash adapter — see those adapters' conformance runs).
42
+ 'signal-prop-same-name-via-const-derived':
43
+ 'self-derived signal (reached through a component-scope const) collides with its prop field name in the generated Go props struct — the non-idempotent `* 2` derivation is dropped and the signal renders the raw prop value instead (https://github.com/piconic-ai/barefootjs/issues/2683)',
18
44
  }