@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.
- package/dist/adapter/go-template-adapter.d.ts +71 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +97 -15
- package/dist/adapter/lib/compile-state.d.ts +43 -1
- package/dist/adapter/lib/compile-state.d.ts.map +1 -1
- package/dist/adapter/props/prop-types.d.ts +25 -2
- package/dist/adapter/props/prop-types.d.ts.map +1 -1
- package/dist/adapter/type/type-codegen.d.ts.map +1 -1
- package/dist/index.js +99 -16
- package/dist/render-divergences.d.ts +8 -0
- package/dist/render-divergences.d.ts.map +1 -1
- package/dist/test-render.d.ts.map +1 -1
- package/dist/vite.js +160 -17
- package/package.json +5 -5
- package/src/__tests__/go-template-adapter.test.ts +276 -16
- package/src/adapter/go-template-adapter.ts +249 -11
- package/src/adapter/lib/compile-state.ts +50 -0
- package/src/adapter/props/prop-types.ts +36 -8
- package/src/adapter/type/type-codegen.ts +19 -2
- package/src/render-divergences.ts +31 -5
- package/src/test-render.ts +273 -52
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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`.
|
|
18
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
// #
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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
|
}
|