@barefootjs/go-template 0.31.9 → 0.32.0
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/emit-context.d.ts +16 -0
- package/dist/adapter/emit-context.d.ts.map +1 -1
- package/dist/adapter/go-template-adapter.d.ts +82 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +119 -9
- package/dist/adapter/lib/compile-state.d.ts +28 -1
- package/dist/adapter/lib/compile-state.d.ts.map +1 -1
- package/dist/adapter/lib/types.d.ts +13 -0
- package/dist/adapter/lib/types.d.ts.map +1 -1
- package/dist/adapter/props/prop-types.d.ts.map +1 -1
- package/dist/index.js +119 -9
- package/dist/render-divergences.d.ts +3 -3
- package/dist/vite.js +154 -11
- package/package.json +5 -5
- package/src/__tests__/go-template-adapter.test.ts +345 -8
- package/src/adapter/emit-context.ts +14 -0
- package/src/adapter/go-template-adapter.ts +330 -10
- package/src/adapter/lib/compile-state.ts +34 -0
- package/src/adapter/lib/types.ts +10 -0
- package/src/adapter/props/prop-types.ts +32 -1
- package/src/render-divergences.ts +3 -3
- package/src/test-render.ts +18 -1
|
@@ -119,7 +119,7 @@ import type {
|
|
|
119
119
|
} from "./lib/types.ts"
|
|
120
120
|
import { collectRootScopeNodes } from "./lib/ir-scope.ts"
|
|
121
121
|
import { GO_TEMPLATE_PRIMITIVES } from "./lib/constants.ts"
|
|
122
|
-
import { CompileState } from "./lib/compile-state.ts"
|
|
122
|
+
import { CompileState, resolveSignalParsedThroughSeedPlan } from "./lib/compile-state.ts"
|
|
123
123
|
import { hasClientInteractivity, findNestedComponents } from "./analysis/component-tree.ts"
|
|
124
124
|
import { analyzeBakeableStaticChildLoop, scalarToGoLiteral, type BakedStaticChildLoop } from "./analysis/static-child-loop-bake.ts"
|
|
125
125
|
import { analyzeBakeableStaticElementLoop } from "./analysis/static-element-loop-bake.ts"
|
|
@@ -255,6 +255,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
255
255
|
extractPropNameFromInitialValue: (initialValue, preParsed) =>
|
|
256
256
|
this.extractPropNameFromInitialValue(initialValue, preParsed),
|
|
257
257
|
extractPropFallback: (initialValue, preParsed) => this.extractPropFallback(initialValue, preParsed),
|
|
258
|
+
extractCollisionDerivation: (parsed) => this.extractCollisionDerivation(parsed),
|
|
258
259
|
resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
|
|
259
260
|
}
|
|
260
261
|
|
|
@@ -1873,6 +1874,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1873
1874
|
lines.push('')
|
|
1874
1875
|
}
|
|
1875
1876
|
|
|
1877
|
+
this.emitCallerPropsInit(lines, ir, nestedComponents, staticWithoutBody, staticWithBody, dynamicWithBody, emittedWrapperVars, propTypeOverrides)
|
|
1878
|
+
|
|
1876
1879
|
lines.push(`\treturn ${propsTypeName}{`)
|
|
1877
1880
|
lines.push('\t\tScopeID: scopeID,')
|
|
1878
1881
|
// Host context, for when *this* component is itself a slot-attached child.
|
|
@@ -1881,6 +1884,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1881
1884
|
if (this.usesSearchParams(ir)) {
|
|
1882
1885
|
lines.push('\t\tSearchParams: in.SearchParams,')
|
|
1883
1886
|
}
|
|
1887
|
+
lines.push('\t\tBfCallerProps: bfCallerProps,')
|
|
1884
1888
|
|
|
1885
1889
|
const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
|
|
1886
1890
|
|
|
@@ -1918,7 +1922,17 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1918
1922
|
if (this.isNestedArrayShadowed(param, nestedArrayFields)) continue
|
|
1919
1923
|
const hoisted = propFallbackVars.get(param.name)
|
|
1920
1924
|
if (hoisted) {
|
|
1921
|
-
|
|
1925
|
+
// #2683: a `collisionWrap` entry means this field is ALSO a
|
|
1926
|
+
// colliding signal's shared field — `hoisted.varName` alone only
|
|
1927
|
+
// carries the coalesced `props.X ?? <lit>` value, so the surrounding
|
|
1928
|
+
// arithmetic (`* 2`, etc.) is composed back on here, giving the
|
|
1929
|
+
// FULLY DERIVED value the signal's own initializer computes. Without
|
|
1930
|
+
// this the field would silently carry just the coalesce result (the
|
|
1931
|
+
// same class of bug this fix addresses, one operator short).
|
|
1932
|
+
const value = hoisted.collisionWrap
|
|
1933
|
+
? `${hoisted.varName} ${hoisted.collisionWrap.operator} ${hoisted.collisionWrap.operand}`
|
|
1934
|
+
: hoisted.varName
|
|
1935
|
+
lines.push(`\t\t${fieldName}: ${value},`)
|
|
1922
1936
|
} else {
|
|
1923
1937
|
const paramDefault = goPropDefault(param.defaultValue)
|
|
1924
1938
|
const memoFold = memoFallbacks.get(fieldName)
|
|
@@ -1948,7 +1962,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
1948
1962
|
if (propFieldNames.has(fieldName)) continue
|
|
1949
1963
|
// `props.X ?? N` reuses the hoisted fallback var so signal and memo share
|
|
1950
1964
|
// one value.
|
|
1951
|
-
const fallbackMatch = this.extractPropFallback(signal.initialValue, signal
|
|
1965
|
+
const fallbackMatch = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
|
|
1952
1966
|
const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
|
|
1953
1967
|
if (hoisted) {
|
|
1954
1968
|
lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
|
|
@@ -2038,6 +2052,139 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2038
2052
|
lines.push('}')
|
|
2039
2053
|
}
|
|
2040
2054
|
|
|
2055
|
+
/**
|
|
2056
|
+
* Build the `bfCallerProps` local (assigned to `BfCallerProps` in the
|
|
2057
|
+
* returned struct) — the hydration-only, caller-supplied-keys-only view
|
|
2058
|
+
* of this component's props (#2684). See the field's doc comment
|
|
2059
|
+
* (`emitPropsStructHeader`) for the two-consumers rationale.
|
|
2060
|
+
*
|
|
2061
|
+
* Three classes, mirroring the issue's taxonomy — gated on `param.optional`
|
|
2062
|
+
* (NOT on the resolved Go type's nillability alone, even though that reads
|
|
2063
|
+
* as the more "obvious" test): a REQUIRED prop must stay unconditional
|
|
2064
|
+
* even when its resolved type happens to be nillable (`ctx: unknown`, the
|
|
2065
|
+
* `array-flatmap-thisarg` fixture passes `ctx: null` explicitly) — Go's
|
|
2066
|
+
* `interface{}` cannot tell "explicit null" from "omitted" once
|
|
2067
|
+
* unmarshaled (both are the nil zero value), so nil-checking a required
|
|
2068
|
+
* field would silently turn an intentional `null` into a dropped key.
|
|
2069
|
+
* 1. Required prop → always included, raw `in.<InputField>` (never the
|
|
2070
|
+
* defaulted Props-field value — a caller who passes the same value
|
|
2071
|
+
* as the author's default is indistinguishable from one who didn't,
|
|
2072
|
+
* but that's inherent to a required prop having no "omitted" state).
|
|
2073
|
+
* 2. Optional prop whose resolved Go type is nillable (`interface{}`,
|
|
2074
|
+
* `map[string]interface{}`, or any slice `[]T`) → included only
|
|
2075
|
+
* when `in.<InputField> != nil`, i.e. only when the caller actually
|
|
2076
|
+
* passed something.
|
|
2077
|
+
* 3. Optional prop that nonetheless resolved to a CONCRETE type (a
|
|
2078
|
+
* string-union alias like `placement?: 'top' | 'bottom'`, a bare
|
|
2079
|
+
* scalar never consumed nullish/attr/text/presence-wise, OR a type
|
|
2080
|
+
* that resolves to `interface{}` some OTHER way — e.g. an inherited
|
|
2081
|
+
* `extends` clause the analyzer can't fully resolve — while
|
|
2082
|
+
* `param.optional` itself is, possibly inaccurately, `false`) —
|
|
2083
|
+
* presence is unknowable from Input alone, so it's included
|
|
2084
|
+
* unconditionally, same as class 1. A documented residual, not
|
|
2085
|
+
* silently dropped; see the PR that introduced this method for named
|
|
2086
|
+
* instances found in the corpus (e.g. `textarea`'s `rows`, inherited
|
|
2087
|
+
* through `TextareaHTMLAttributes`). Do NOT widen these props' types
|
|
2088
|
+
* to "fix" this — that's `resolvePropGoType`'s call, not this
|
|
2089
|
+
* method's, and doing so would risk the same `ctx`-style regression.
|
|
2090
|
+
*
|
|
2091
|
+
* `children` is excluded outright (#1952 — a separately-declared
|
|
2092
|
+
* position, not relitigated here). Rest-props bags are never added to
|
|
2093
|
+
* the Props struct's JSON at all (checked: no field exists for them
|
|
2094
|
+
* outside Input), so nothing to exclude here either — already at parity
|
|
2095
|
+
* with the reference, which never serializes rest keys.
|
|
2096
|
+
*
|
|
2097
|
+
* A prop shadowed by its own driving nested-array field (`isNestedArrayShadowed`
|
|
2098
|
+
* — e.g. `toggleItems` colliding with a `<ToggleItem>` loop's derived
|
|
2099
|
+
* `ToggleItems` field, #2672/#2525) has NO Props/Input field of its own;
|
|
2100
|
+
* the reshaped nested-array local (`varName`, already built above by the
|
|
2101
|
+
* static/dynamic body-wrapper emission) is its only remaining carrier, so
|
|
2102
|
+
* that's what gets keyed in here instead of `in.<InputField>`. KNOWN
|
|
2103
|
+
* RESIDUAL: each item within that array is still the OLD whole-struct
|
|
2104
|
+
* marshal (baked author defaults / null-for-absent / zero-for-required
|
|
2105
|
+
* inside every item) — recursing the sidecar into embedded Props-struct
|
|
2106
|
+
* arrays needs either a generated `MarshalJSON` per Props type or a
|
|
2107
|
+
* runtime array-flattening helper, a bigger surface this method's design
|
|
2108
|
+
* doesn't cover. Reported, not chased (see PR description).
|
|
2109
|
+
*/
|
|
2110
|
+
private emitCallerPropsInit(
|
|
2111
|
+
lines: string[],
|
|
2112
|
+
ir: ComponentIR,
|
|
2113
|
+
nestedComponents: NestedComponentInfo[],
|
|
2114
|
+
staticWithoutBody: NestedComponentInfo[],
|
|
2115
|
+
staticWithBody: NestedComponentInfo[],
|
|
2116
|
+
dynamicWithBody: NestedComponentInfo[],
|
|
2117
|
+
emittedWrapperVars: Set<string>,
|
|
2118
|
+
propTypeOverrides: Map<string, string>,
|
|
2119
|
+
): void {
|
|
2120
|
+
lines.push('\tbfCallerProps := map[string]interface{}{}')
|
|
2121
|
+
const bfCallerPropsTakenTags = new Set<string>()
|
|
2122
|
+
const nestedArrayFields = this.propDerivedNestedArrayFields(nestedComponents)
|
|
2123
|
+
|
|
2124
|
+
for (const param of ir.metadata.propsParams) {
|
|
2125
|
+
// Children are DOM content, not hydration data (#1952) — excluded the
|
|
2126
|
+
// same way `emitPropsDataFields` excludes them from the Props struct.
|
|
2127
|
+
if (param.name === 'children') continue
|
|
2128
|
+
// Shadowed by its own driving nested-array field (#2672/#2525) — no
|
|
2129
|
+
// Input field exists for it at all; handled below via the array local.
|
|
2130
|
+
if (this.isNestedArrayShadowed(param, nestedArrayFields)) continue
|
|
2131
|
+
// Same tag algorithm `emitPropsDataFields` uses for the Props field's
|
|
2132
|
+
// own `json:"…"` tag, re-derived against a FRESH set: props are always
|
|
2133
|
+
// processed first, in the same order, over an empty set in BOTH
|
|
2134
|
+
// places, so the two computations can't disagree.
|
|
2135
|
+
const callerKey = this.claimJsonTag(this.toJsonTag(param.sourceName ?? param.name), bfCallerPropsTakenTags)
|
|
2136
|
+
if (callerKey === '-') continue // tag collision — dropped from the wire, same as the Props field itself would be
|
|
2137
|
+
const inputField = `in.${capitalizeFieldName(param.sourceName ?? param.name)}`
|
|
2138
|
+
const goType = resolvePropGoType(this.emitCtx, param, propTypeOverrides)
|
|
2139
|
+
const isNillable = goType === 'interface{}' || goType === 'map[string]interface{}' || goType.startsWith('[]')
|
|
2140
|
+
// `param.optional` gates the branch (not "isNillable alone") because a
|
|
2141
|
+
// REQUIRED prop must stay unconditional even when its resolved type
|
|
2142
|
+
// happens to be nillable (`ctx: unknown`, #2094's array-flatmap-thisarg
|
|
2143
|
+
// fixture passes `ctx: null` explicitly) — Go's `interface{}` cannot
|
|
2144
|
+
// tell "explicit null" from "omitted" once unmarshaled (both are the
|
|
2145
|
+
// nil zero value), so a nil-check here would silently turn an
|
|
2146
|
+
// intentional `null` into a dropped key, regressing a fixture that
|
|
2147
|
+
// matched the reference before this method existed. `resolvePropGoType`
|
|
2148
|
+
// never applies its nillable flip to a required prop, so this ONLY
|
|
2149
|
+
// affects the rare case where a type resolves to `interface{}` some
|
|
2150
|
+
// OTHER way (e.g. `unknown`, or a type inherited through an
|
|
2151
|
+
// unresolved external `extends` clause) while `param.optional` is
|
|
2152
|
+
// (accurately or not) false — see the method docstring's residual list.
|
|
2153
|
+
if (param.optional && isNillable) {
|
|
2154
|
+
lines.push(`\tif ${inputField} != nil {`)
|
|
2155
|
+
lines.push(`\t\tbfCallerProps["${callerKey}"] = ${inputField}`)
|
|
2156
|
+
lines.push(`\t}`)
|
|
2157
|
+
} else {
|
|
2158
|
+
// Required (class 1), or optional-but-concrete residual (class 3),
|
|
2159
|
+
// or optional-but-misresolved-required residual — see docstring.
|
|
2160
|
+
lines.push(`\tbfCallerProps["${callerKey}"] = ${inputField}`)
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// Shadowed nested-array props — see method docstring.
|
|
2165
|
+
for (const nested of [...staticWithoutBody, ...staticWithBody, ...dynamicWithBody]) {
|
|
2166
|
+
if (!nested.isPropDerived) continue
|
|
2167
|
+
const varName = `${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`
|
|
2168
|
+
const isBuilt = staticWithoutBody.includes(nested) || emittedWrapperVars.has(varName)
|
|
2169
|
+
if (!isBuilt) continue
|
|
2170
|
+
const arrayFieldName = capitalizeFieldName(`${nested.name}s`)
|
|
2171
|
+
const param = ir.metadata.propsParams.find(
|
|
2172
|
+
p => capitalizeFieldName(p.name) === arrayFieldName || capitalizeFieldName(p.sourceName ?? p.name) === arrayFieldName,
|
|
2173
|
+
)
|
|
2174
|
+
if (!param || !this.isNestedArrayShadowed(param, nestedArrayFields)) continue
|
|
2175
|
+
const callerKey = this.claimJsonTag(this.toJsonTag(param.sourceName ?? param.name), bfCallerPropsTakenTags)
|
|
2176
|
+
if (callerKey === '-') continue
|
|
2177
|
+
if (param.optional) {
|
|
2178
|
+
lines.push(`\tif in.${nested.name}s != nil {`)
|
|
2179
|
+
lines.push(`\t\tbfCallerProps["${callerKey}"] = ${varName}`)
|
|
2180
|
+
lines.push(`\t}`)
|
|
2181
|
+
} else {
|
|
2182
|
+
lines.push(`\tbfCallerProps["${callerKey}"] = ${varName}`)
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
lines.push('')
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2041
2188
|
private emitStaticChildInstances(lines: string[], ir: ComponentIR): void {
|
|
2042
2189
|
const staticChildren = this.collectStaticChildInstances(ir.root, ir.metadata.propsParams)
|
|
2043
2190
|
for (const child of staticChildren) {
|
|
@@ -2741,6 +2888,24 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2741
2888
|
if (this.usesSearchParams(ir)) {
|
|
2742
2889
|
lines.push('\tSearchParams bf.SearchParams `json:"-"`')
|
|
2743
2890
|
}
|
|
2891
|
+
|
|
2892
|
+
// #2684: the raw, CALLER-SUPPLIED view of this component's props —
|
|
2893
|
+
// populated by `NewXxxProps` with exactly the keys the caller passed
|
|
2894
|
+
// (required props always; optional ones only when not nil), holding
|
|
2895
|
+
// the UNDEFAULTED value. This exists because every prop field above
|
|
2896
|
+
// (e.g. `X`) serves TWO different consumers that want two different
|
|
2897
|
+
// values: the Go TEMPLATE wants the DEFAULTED value so `{{.X}}` renders
|
|
2898
|
+
// the author's fallback, while the HYDRATION PAYLOAD wants to know only
|
|
2899
|
+
// what the caller actually supplied — baking the author's default into
|
|
2900
|
+
// it makes an omitted prop indistinguishable from one explicitly passed
|
|
2901
|
+
// with that value, and a Go zero value indistinguishable from an
|
|
2902
|
+
// explicit zero/empty/false (the reference, Hono, only ever serializes
|
|
2903
|
+
// caller-passed keys). One field can't answer both questions at once,
|
|
2904
|
+
// so this sidecar gives the hydration payload its own carrier;
|
|
2905
|
+
// `BfPropsAttr` (runtime/bf.go) marshals THIS map instead of the whole
|
|
2906
|
+
// struct when it's present. `json:"-"` — never marshaled as an ordinary
|
|
2907
|
+
// struct field; `BfPropsAttr` reads it directly by name via reflection.
|
|
2908
|
+
lines.push('\tBfCallerProps map[string]interface{} `json:"-"`')
|
|
2744
2909
|
}
|
|
2745
2910
|
|
|
2746
2911
|
private emitPropsDataFields(
|
|
@@ -2782,7 +2947,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2782
2947
|
if (signal.envReader) continue
|
|
2783
2948
|
const fieldName = capitalizeFieldName(signal.getter)
|
|
2784
2949
|
if (propFieldNames.has(fieldName)) continue
|
|
2785
|
-
|
|
2950
|
+
// Signal fields are component-internal state, not caller input (#2672):
|
|
2951
|
+
// the client never reads `_p.<signalGetter>` — it re-derives the value
|
|
2952
|
+
// from `createSignal(...)`'s own initial expression (which itself reads
|
|
2953
|
+
// whatever PROP field seeded it, already emitted above with a real tag
|
|
2954
|
+
// by the propsParams loop). Excluding the signal field from JSON stops
|
|
2955
|
+
// it from co-boarding into `bf-p` while leaving `{{.Field}}` SSR access
|
|
2956
|
+
// untouched — Go template field access doesn't consult json tags.
|
|
2957
|
+
const jsonTag = '-'
|
|
2786
2958
|
// A synthesised struct type wins outright — the signal is an untyped
|
|
2787
2959
|
// object array we gave a concrete element type.
|
|
2788
2960
|
const synthType = this.state.synthStructTypes.get(signal.getter)
|
|
@@ -2832,7 +3004,10 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2832
3004
|
for (const memo of ir.metadata.memos) {
|
|
2833
3005
|
const fieldName = capitalizeFieldName(memo.name)
|
|
2834
3006
|
if (propFieldNames.has(fieldName)) continue
|
|
2835
|
-
|
|
3007
|
+
// Memo fields are derived, re-computed client-side from the same prop
|
|
3008
|
+
// reads the memo body itself performs — never read as `_p.<memoName>`
|
|
3009
|
+
// (#2672). Same rationale as the signal fields above.
|
|
3010
|
+
const jsonTag = '-'
|
|
2836
3011
|
const goType = this.inferMemoType(memo, ir.metadata.signals, propsParamMap)
|
|
2837
3012
|
lines.push(`\t${fieldName} ${goType} \`json:"${jsonTag}"\``)
|
|
2838
3013
|
}
|
|
@@ -2868,10 +3043,27 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2868
3043
|
...ir.metadata.memos.map(m => capitalizeFieldName(m.name)),
|
|
2869
3044
|
])
|
|
2870
3045
|
for (const c of this.nonCollidingContextConsumers(takenProps)) {
|
|
2871
|
-
|
|
3046
|
+
// Context-consumer fields are resolved server-side from the enclosing
|
|
3047
|
+
// `Provider` and read via `{{.Field}}` in SSR only — the client's own
|
|
3048
|
+
// `useContext` re-reads the DOM-scoped provider value at hydration
|
|
3049
|
+
// time, never `_p.<contextField>` (#2672).
|
|
3050
|
+
const jsonTag = '-'
|
|
2872
3051
|
lines.push(`\t${this.contextFieldName(c)} ${this.contextConsumerGoType(c)} \`json:"${jsonTag}"\``)
|
|
2873
3052
|
}
|
|
2874
3053
|
|
|
3054
|
+
// Capitalized Go field names of every declared prop — BOTH the LOCAL
|
|
3055
|
+
// binding and the caller-facing (`sourceName`) spelling, unioned the
|
|
3056
|
+
// same way `isNestedArrayShadowed` does for the propsParams loop above
|
|
3057
|
+
// (an aliased destructure like `{ rows: items }` can collide under
|
|
3058
|
+
// either naming depending on alias direction — see the #2525 collision
|
|
3059
|
+
// tests). Used below to detect when a nested-array field's name
|
|
3060
|
+
// collides with (and shadows) an actual prop field.
|
|
3061
|
+
const propDrivingFieldNames = new Set<string>()
|
|
3062
|
+
for (const p of ir.metadata.propsParams) {
|
|
3063
|
+
propDrivingFieldNames.add(capitalizeFieldName(p.name))
|
|
3064
|
+
propDrivingFieldNames.add(capitalizeFieldName(p.sourceName ?? p.name))
|
|
3065
|
+
}
|
|
3066
|
+
|
|
2875
3067
|
for (const nested of nestedComponents) {
|
|
2876
3068
|
// An orphaned clientOnly nested loop (#2627 — see
|
|
2877
3069
|
// `isOrphanedClientOnlyNested`) gets NO Props field at all, not even
|
|
@@ -2889,8 +3081,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2889
3081
|
if (nested.isDynamic && !nested.isPropDerived) {
|
|
2890
3082
|
// Dynamic signal-array loops are template-only.
|
|
2891
3083
|
lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
|
|
3084
|
+
} else if (
|
|
3085
|
+
nested.isDynamic &&
|
|
3086
|
+
nested.isPropDerived &&
|
|
3087
|
+
!propDrivingFieldNames.has(`${nested.name}s`)
|
|
3088
|
+
) {
|
|
3089
|
+
// Prop-derived dynamic loops (`props.items.map(item => <Child/>)`,
|
|
3090
|
+
// #2672): this field is USUALLY a RE-SHAPED COPY of the driving prop,
|
|
3091
|
+
// built for SSR's `{{range}}` — not the prop itself. The client's own
|
|
3092
|
+
// `mapArray` re-derives every row straight from the real prop field
|
|
3093
|
+
// (`_p.items`, emitted with a real tag by the propsParams loop
|
|
3094
|
+
// above), never from `_p.<Name>s`, so co-boarding this copy into
|
|
3095
|
+
// `bf-p` is redundant component-internal derivation, same as a memo.
|
|
3096
|
+
//
|
|
3097
|
+
// EXCEPT when the two Go field names collide (`propDrivingFieldNames`
|
|
3098
|
+
// — mirrors `isNestedArrayShadowed`'s check the propsParams loop
|
|
3099
|
+
// itself runs): a prop named `toggleItems` driving a `<ToggleItem>`
|
|
3100
|
+
// loop capitalizes to the SAME Go field name as the nested-array
|
|
3101
|
+
// field (`ToggleItems`), so `emitPropsDataFields` shadows the prop's
|
|
3102
|
+
// OWN field entirely and this array field is the ONLY carrier of
|
|
3103
|
+
// that prop's data. Flipping it there would silently drop caller
|
|
3104
|
+
// input from `bf-p` instead of merely trimming a redundant copy —
|
|
3105
|
+
// the `else` branch below keeps a real tag for exactly that case.
|
|
3106
|
+
lines.push(`\t${nested.name}s []${elemType} \`json:"-"\``)
|
|
2892
3107
|
} else {
|
|
2893
|
-
// Static
|
|
3108
|
+
// Static arrays go in JSON so the client can hydrate (that data can
|
|
3109
|
+
// be non-literal, request-time Input the caller supplies with no
|
|
3110
|
+
// prop-field twin to fall back on) — and so does a prop-derived
|
|
3111
|
+
// dynamic array whose field name shadows its own driving prop's
|
|
3112
|
+
// field (the `propDrivingFieldNames` case above): this field is
|
|
3113
|
+
// that prop's ONLY remaining carrier in the struct.
|
|
2894
3114
|
const jsonTag = this.claimJsonTag(
|
|
2895
3115
|
this.toJsonTag(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`),
|
|
2896
3116
|
takenJsonTags,
|
|
@@ -2906,9 +3126,11 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
2906
3126
|
|
|
2907
3127
|
// Top-level intrinsic-element spreads: each gets a `Spread_<slotId>
|
|
2908
3128
|
// map[string]any` field the template reads via `{{bf_spread_attrs}}`.
|
|
2909
|
-
// Loop-internal spreads emit inline and don't appear here.
|
|
3129
|
+
// Loop-internal spreads emit inline and don't appear here. SSR-only —
|
|
3130
|
+
// the resolved attrs are already baked into the rendered HTML, and no
|
|
3131
|
+
// client runtime reads `_p.Spread_<slotId>` back out of `bf-p` (#2672).
|
|
2910
3132
|
for (const slot of spreadSlots) {
|
|
2911
|
-
const jsonTag =
|
|
3133
|
+
const jsonTag = '-'
|
|
2912
3134
|
lines.push(`\t${slot.slotId} map[string]any \`json:"${jsonTag}"\``)
|
|
2913
3135
|
}
|
|
2914
3136
|
}
|
|
@@ -3531,7 +3753,32 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
3531
3753
|
|
|
3532
3754
|
const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
|
|
3533
3755
|
for (const signal of ir.metadata.signals) {
|
|
3534
|
-
|
|
3756
|
+
let match = this.extractPropFallback(signal.initialValue, this.resolvedSignalParsed(signal))
|
|
3757
|
+
// #2683: the idempotent fold above declines a non-idempotent
|
|
3758
|
+
// derivation (`(props.count ?? 1) * 2`) at the top level. When that
|
|
3759
|
+
// signal's Go field name COLLIDES with the prop's own field — the
|
|
3760
|
+
// exact condition under which the props-field loop below would
|
|
3761
|
+
// otherwise silently drop the derivation and emit the raw prop
|
|
3762
|
+
// instead — fall back to the collision-derivation matcher so the
|
|
3763
|
+
// coalesced value still gets hoisted; the props-field loop composes
|
|
3764
|
+
// the surrounding arithmetic back on top of it (`collisionWrap`).
|
|
3765
|
+
// Gated strictly on the collision so a differently-named signal
|
|
3766
|
+
// deriving from the same prop (not colliding) is untouched — that
|
|
3767
|
+
// path's own dedicated field keeps today's behavior byte-for-byte.
|
|
3768
|
+
let collisionWrap: { operator: string; operand: string } | undefined
|
|
3769
|
+
if (!match) {
|
|
3770
|
+
const collision = this.extractCollisionDerivation(this.resolvedSignalParsed(signal))
|
|
3771
|
+
if (collision) {
|
|
3772
|
+
const collisionParam = ir.metadata.propsParams.find(p => p.name === collision.propName)
|
|
3773
|
+
const collisionField = collisionParam
|
|
3774
|
+
? capitalizeFieldName(collisionParam.sourceName ?? collision.propName)
|
|
3775
|
+
: null
|
|
3776
|
+
if (collisionField && capitalizeFieldName(signal.getter) === collisionField) {
|
|
3777
|
+
match = { propName: collision.propName, goFallback: collision.goFallback }
|
|
3778
|
+
collisionWrap = { operator: collision.operator, operand: collision.operand }
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3535
3782
|
if (!match) continue
|
|
3536
3783
|
if (result.has(match.propName)) continue
|
|
3537
3784
|
const param = ir.metadata.propsParams.find(p => p.name === match.propName)
|
|
@@ -3591,11 +3838,23 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
3591
3838
|
goFallback: match.goFallback,
|
|
3592
3839
|
zeroLiteral,
|
|
3593
3840
|
...(nullishLowered ? { assertType: concreteType } : {}),
|
|
3841
|
+
...(collisionWrap ? { collisionWrap } : {}),
|
|
3594
3842
|
})
|
|
3595
3843
|
}
|
|
3596
3844
|
return result
|
|
3597
3845
|
}
|
|
3598
3846
|
|
|
3847
|
+
/**
|
|
3848
|
+
* Resolve a signal's initializer through the SSR seed plan's const-hop
|
|
3849
|
+
* inlining (#2685) before prop-fallback extraction — see
|
|
3850
|
+
* {@link resolveSignalParsedThroughSeedPlan} (shared with
|
|
3851
|
+
* `collectNullishConsumedPropNames`'s signal-seed loop, the single door
|
|
3852
|
+
* both consumers of this resolution go through).
|
|
3853
|
+
*/
|
|
3854
|
+
private resolvedSignalParsed(signal: { getter: string; parsed?: ParsedExpr }): ParsedExpr | undefined {
|
|
3855
|
+
return resolveSignalParsedThroughSeedPlan(this.state, signal)
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3599
3858
|
/**
|
|
3600
3859
|
* Parse a signal-time initial value of the form `props.X ?? <literal>` —
|
|
3601
3860
|
* or, for destructured components, `x ?? <literal>` — into the source prop
|
|
@@ -3674,6 +3933,67 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
|
|
|
3674
3933
|
return { propName, goFallback }
|
|
3675
3934
|
}
|
|
3676
3935
|
|
|
3936
|
+
/**
|
|
3937
|
+
* #2683: match a signal's collision-derivation shape — `(props.X ?? <lit>)
|
|
3938
|
+
* <op> <int>` — the ONE non-idempotent shape this adapter faithfully lowers
|
|
3939
|
+
* when a signal's Go field name collides with its own prop's field (see the
|
|
3940
|
+
* collision-override loop in `collectPropFallbackVars` / the props-field
|
|
3941
|
+
* loop in `generateNewPropsFunction`). Composes two ALREADY-EXISTING
|
|
3942
|
+
* lowerings instead of adding a third: `extractPropFallbackFromParsed`
|
|
3943
|
+
* recognizes the embedded `props.X ?? <lit>` presence check exactly as it
|
|
3944
|
+
* does for the idempotent fold, and the wrap mirrors the SAME `<ref> <op>
|
|
3945
|
+
* <int>` arithmetic shape `memoInitialFromParsedBody`'s multiply-family
|
|
3946
|
+
* branch already supports for a bare `props.X <op> N` (non-negative
|
|
3947
|
+
* integer operand only, matching that branch's own restriction).
|
|
3948
|
+
*
|
|
3949
|
+
* Returns null for any other shape — the caller then keeps today's
|
|
3950
|
+
* raw-passthrough behavior (sound-or-loud; the remaining #2683 pin, if
|
|
3951
|
+
* any, covers whatever this doesn't reach).
|
|
3952
|
+
*/
|
|
3953
|
+
private extractCollisionDerivation(
|
|
3954
|
+
parsed: ParsedExpr | undefined,
|
|
3955
|
+
): { propName: string; goFallback: string; operator: string; operand: string } | null {
|
|
3956
|
+
if (!parsed || parsed.kind !== 'binary') return null
|
|
3957
|
+
if (!['*', '+', '-', '/'].includes(parsed.op)) return null
|
|
3958
|
+
const { right } = parsed
|
|
3959
|
+
if (
|
|
3960
|
+
right.kind !== 'literal' ||
|
|
3961
|
+
right.literalType !== 'number' ||
|
|
3962
|
+
typeof right.value !== 'number' ||
|
|
3963
|
+
!Number.isInteger(right.value) ||
|
|
3964
|
+
right.value < 0
|
|
3965
|
+
) {
|
|
3966
|
+
return null
|
|
3967
|
+
}
|
|
3968
|
+
// Constant division by zero is a Go COMPILE error (`invalid operation:
|
|
3969
|
+
// division by zero`), unlike JS's Infinity — declining keeps that shape
|
|
3970
|
+
// on the raw-passthrough path instead of breaking the build.
|
|
3971
|
+
if (parsed.op === '/' && right.value === 0) return null
|
|
3972
|
+
// The `??` fallback itself must be a NUMERIC literal before delegating
|
|
3973
|
+
// to the fold: `extractPropFallbackFromParsed` happily matches a string
|
|
3974
|
+
// fallback (`(props.label ?? 'x') + 2`), which would hoist
|
|
3975
|
+
// `var label string = "x"` and emit `Label: label + 2,` — invalid Go
|
|
3976
|
+
// (string + int), a compile break where the pre-existing behavior at
|
|
3977
|
+
// least built (Copilot review on #2694). JS's `'x' + 2` is string
|
|
3978
|
+
// concatenation besides, so a numeric compose could never be faithful
|
|
3979
|
+
// for it — the shape belongs on the raw-passthrough path until someone
|
|
3980
|
+
// lowers it through ConcatStr deliberately. Gating on `??` (not `||`)
|
|
3981
|
+
// also keeps JS nullish semantics exact for numeric props, where a
|
|
3982
|
+
// caller's explicit `0` must NOT trigger the fallback.
|
|
3983
|
+
const coalesce = parsed.left
|
|
3984
|
+
if (
|
|
3985
|
+
coalesce.kind !== 'logical' ||
|
|
3986
|
+
coalesce.op !== '??' ||
|
|
3987
|
+
coalesce.right.kind !== 'literal' ||
|
|
3988
|
+
coalesce.right.literalType !== 'number'
|
|
3989
|
+
) {
|
|
3990
|
+
return null
|
|
3991
|
+
}
|
|
3992
|
+
const inner = this.extractPropFallbackFromParsed(parsed.left)
|
|
3993
|
+
if (!inner) return null
|
|
3994
|
+
return { ...inner, operator: parsed.op, operand: String(right.value) }
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3677
3997
|
/**
|
|
3678
3998
|
* Extract the prop name from a signal's `props.xxx`-pattern initialValue,
|
|
3679
3999
|
* e.g. `"props.initial ?? 0"` → `"initial"`, `"props.checked"` → `"checked"`.
|
|
@@ -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,
|
|
@@ -262,3 +263,36 @@ export class CompileState {
|
|
|
262
263
|
* `strings` is added to the generated types file's import block. */
|
|
263
264
|
needsStringsImport = false
|
|
264
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
|
+
}
|
package/src/adapter/lib/types.ts
CHANGED
|
@@ -167,6 +167,16 @@ export interface PropFallbackVar {
|
|
|
167
167
|
* distinguishable from an absent one.
|
|
168
168
|
*/
|
|
169
169
|
assertType?: string
|
|
170
|
+
/**
|
|
171
|
+
* #2683: set when this fallback var was hoisted from a signal's
|
|
172
|
+
* COLLISION-DERIVATION initializer (`(props.X ?? <lit>) <op> <int>`, where
|
|
173
|
+
* the signal's Go field name collides with prop X's) rather than a plain
|
|
174
|
+
* `props.X ?? <lit>`. `varName` above still holds only the coalesced RAW
|
|
175
|
+
* value (the presence-check declaration is unchanged) — this records the
|
|
176
|
+
* arithmetic wrap the props-field loop applies ON TOP of it so the shared
|
|
177
|
+
* field carries the FULLY DERIVED value, not just the coalesce result.
|
|
178
|
+
*/
|
|
179
|
+
collisionWrap?: { operator: string; operand: string }
|
|
170
180
|
}
|
|
171
181
|
|
|
172
182
|
/**
|
|
@@ -9,6 +9,8 @@ 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'
|
|
13
|
+
import { capitalizeFieldName } from '../lib/go-naming.ts'
|
|
12
14
|
import { typeInfoToGo } from '../type/type-codegen.ts'
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -218,7 +220,36 @@ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: Componen
|
|
|
218
220
|
// tree — reuse the seam's existing `props.X ?? <literal>` recognizer. The
|
|
219
221
|
// zero-equivalent exclusion matches on the Go-formatted fallback literal.
|
|
220
222
|
for (const signal of ir.metadata.signals) {
|
|
221
|
-
const
|
|
223
|
+
// Resolved through the seed plan's const-hop inlining (#2685) — see
|
|
224
|
+
// `resolveSignalParsedThroughSeedPlan` — so a component-scope `const`
|
|
225
|
+
// between `props.X` and `createSignal` doesn't leave this loop and
|
|
226
|
+
// `collectPropFallbackVars`'s identical extraction disagreeing on
|
|
227
|
+
// whether the prop needs the nillable flip.
|
|
228
|
+
const resolvedParsed = resolveSignalParsedThroughSeedPlan(ctx.state, signal)
|
|
229
|
+
let match = ctx.extractPropFallback(signal.initialValue, resolvedParsed)
|
|
230
|
+
// #2683: the idempotent fold above declines a non-idempotent derivation
|
|
231
|
+
// (`(props.count ?? 1) * 2`) at the top level. When that signal's Go
|
|
232
|
+
// field name COLLIDES with the prop's own field, the props-struct
|
|
233
|
+
// emitter composes the derivation onto the SHARED field (see
|
|
234
|
+
// `collectPropFallbackVars`'s identical collision gate) — which needs
|
|
235
|
+
// the SAME nillable flip an ordinary `??` fallback gets, so an absent
|
|
236
|
+
// prop (nil) stays distinguishable from an explicit zero-equivalent
|
|
237
|
+
// value. Gated strictly on the collision, matching that other site, so
|
|
238
|
+
// a differently-named signal deriving from the same prop (not
|
|
239
|
+
// colliding — its own dedicated field, untouched by this fix) doesn't
|
|
240
|
+
// spuriously flip the prop's type.
|
|
241
|
+
if (!match) {
|
|
242
|
+
const collision = ctx.extractCollisionDerivation(resolvedParsed)
|
|
243
|
+
if (collision) {
|
|
244
|
+
const collisionParam = ir.metadata.propsParams.find(p => p.name === collision.propName)
|
|
245
|
+
const collisionField = collisionParam
|
|
246
|
+
? capitalizeFieldName(collisionParam.sourceName ?? collision.propName)
|
|
247
|
+
: null
|
|
248
|
+
if (collisionField && capitalizeFieldName(signal.getter) === collisionField) {
|
|
249
|
+
match = { propName: collision.propName, goFallback: collision.goFallback }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
222
253
|
if (!match || !optionalParams.has(match.propName)) continue
|
|
223
254
|
const f = match.goFallback
|
|
224
255
|
if (f === '""' || f === 'false' || f === 'nil' || Number(f) === 0) continue
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
* file even when the set is empty — the next divergence lands here, not in
|
|
7
7
|
* a re-created file.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* (#2630's `static-array-from-props-with-component-precomputed` divergence
|
|
10
|
+
* graduated once the harness (`test-render.ts`'s
|
|
11
11
|
* `buildDynamicChildLoopSeeding`, despite the name — see its doc comment)
|
|
12
12
|
* learned to seed a prop-backed static child-component loop's Props slice
|
|
13
13
|
* the same way it already seeded a signal-backed dynamic one: the adapter's
|
|
14
14
|
* own `emission` was never the bug, only this harness's route-handler
|
|
15
|
-
* stand-in was missing the prop-derived case.
|
|
15
|
+
* stand-in was missing the prop-derived case.)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { RenderDivergences } from '@barefootjs/jsx'
|
package/src/test-render.ts
CHANGED
|
@@ -914,11 +914,28 @@ function goSliceElemType(
|
|
|
914
914
|
* `[]any{…}`/`map[string]interface{}{…}` for that field no longer compiles
|
|
915
915
|
* against it the way it always did against the old `map[string]interface{}`
|
|
916
916
|
* element type.
|
|
917
|
+
*
|
|
918
|
+
* An ARRAY element (`elemType` itself starting with `[]`, i.e. the field is
|
|
919
|
+
* doubly-nested — `Rows [][]int`) recurses with the INNER element type
|
|
920
|
+
* (`int`) instead of falling to `goArrayLiteralFromArray`'s generic
|
|
921
|
+
* `[]any{…}` — #2677 widened the analyzer's destructured-parameter gate to
|
|
922
|
+
* resolve a nested-array prop (`{ rows }: { rows: number[][] }`) to a real
|
|
923
|
+
* `[][]int` field (previously `unknown` → `interface{}` → `[]any`, which
|
|
924
|
+
* `goArrayLiteralFromArray`'s untyped literal always compiled against fine).
|
|
925
|
+
* Without this, the harness's OWN convenience literal-builder — not the
|
|
926
|
+
* production adapter's `typeInfoToGo`, which already recurses correctly —
|
|
927
|
+
* bakes `[]any{1, 2}` for a `[1, 2]` row, and `[][]int{[]any{1, 2}, …}`
|
|
928
|
+
* fails to compile against the now-concrete field.
|
|
917
929
|
*/
|
|
918
930
|
function goTypedSliceLiteralFromArray(arr: unknown[], elemType: string, goTypes?: string): string {
|
|
919
931
|
const entries = arr.map(v => {
|
|
920
932
|
if (v instanceof Date) return goStringLit(v.toISOString())
|
|
921
|
-
if (
|
|
933
|
+
if (Array.isArray(v)) {
|
|
934
|
+
return elemType.startsWith('[]')
|
|
935
|
+
? goTypedSliceLiteralFromArray(v, elemType.slice(2), goTypes)
|
|
936
|
+
: goArrayLiteralFromArray(v)
|
|
937
|
+
}
|
|
938
|
+
if (v && typeof v === 'object') {
|
|
922
939
|
return goStructLiteral(v as Record<string, unknown>, elemType, goTypes)
|
|
923
940
|
}
|
|
924
941
|
if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`
|