@barefootjs/go-template 0.18.7 → 0.19.1

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.
@@ -135,7 +135,7 @@ import { lowerCtorExpr } from "./memo/ctor-lowering.ts"
135
135
  import { resolveBlockBodyMemoModuleConst } from "./memo/memo-value.ts"
136
136
  import { computeMemoInitialValue, computeMemoInitialValueOrNull, filterArmEarlierSiblingRefs } from "./memo/memo-compute.ts"
137
137
  import { collectSpreadSlots, buildSpreadInitializer } from "./spread/spread-codegen.ts"
138
- import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames } from "./props/prop-types.ts"
138
+ import { buildPropTypeOverrides, resolvePropGoType, collectNillablePropNames, collectNullishConsumedPropNames, collectOmittableAttrConsumedPropNames, collectTextConsumedPropNames, collectPresenceCheckedPropNames, NULLISH_SCALAR_GO_TYPES } from "./props/prop-types.ts"
139
139
  import { collectStringValueNames } from "./props/prop-classes.ts"
140
140
 
141
141
  export type { GoTemplateAdapterOptions } from "./lib/types.ts"
@@ -224,8 +224,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
224
224
  this.convertExpressionToGo(jsExpr, out, preParsed),
225
225
  convertConditionToGo: (jsCondition, preParsed) =>
226
226
  this.convertConditionToGo(jsCondition, preParsed),
227
- extractPropNameFromInitialValue: (initialValue) => this.extractPropNameFromInitialValue(initialValue),
228
- extractPropFallback: (initialValue) => this.extractPropFallback(initialValue),
227
+ extractPropNameFromInitialValue: (initialValue, preParsed) =>
228
+ this.extractPropNameFromInitialValue(initialValue, preParsed),
229
+ extractPropFallback: (initialValue, preParsed) => this.extractPropFallback(initialValue, preParsed),
229
230
  resolveModuleStringConst: (name) => this.resolveModuleStringConst(name),
230
231
  }
231
232
 
@@ -406,6 +407,21 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
406
407
  }
407
408
  this.state.loweringMatchers = prepareLoweringMatchers(ir.metadata)
408
409
  augmentInheritedPropAccesses(ir)
410
+ // The consumed-prop sets feed `resolvePropGoType`'s interface{} flips
411
+ // (#2248/#2259) and `collectNillablePropNames` is derived from
412
+ // `resolvePropGoType`, which also consults the local type tables — so
413
+ // build the tables first and populate all three HERE, where both entry
414
+ // points share them. Computing them only in `generate()` left the
415
+ // standalone `generateTypes()` entry (sibling IRs in the conformance
416
+ // harness) resolving structs against another component's sets, and
417
+ // `generate()` itself computing nillability against the PREVIOUS
418
+ // compile's type tables.
419
+ this.buildLocalTypeTables(ir, ir.metadata.componentName)
420
+ this.state.nullishConsumedPropNames = collectNullishConsumedPropNames(this.emitCtx, ir)
421
+ this.state.omittableAttrConsumedPropNames = collectOmittableAttrConsumedPropNames(this.emitCtx, ir)
422
+ this.state.textConsumedPropNames = collectTextConsumedPropNames(this.emitCtx, ir)
423
+ this.state.presenceCheckedPropNames = collectPresenceCheckedPropNames(this.emitCtx, ir)
424
+ this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
409
425
  }
410
426
 
411
427
  /** Generate template output for a component. */
@@ -416,7 +432,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
416
432
  this.state.templateVarCounter = 0
417
433
  this.state.pendingChildrenDefines = []
418
434
  this.primeCompileState(ir)
419
- this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
420
435
  this.state.stringValueNames = collectStringValueNames(ir)
421
436
 
422
437
  // Surface loop-body usages of sibling-imported components (see
@@ -1213,10 +1228,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1213
1228
  // from an omitted field, so it also fires on the type's zero value.
1214
1229
  const propFallbackVars = this.collectPropFallbackVars(ir)
1215
1230
  for (const [, info] of propFallbackVars) {
1216
- lines.push(`\t${info.varName} := in.${info.fieldName}`)
1217
- lines.push(`\tif ${info.varName} == ${info.zeroLiteral} {`)
1218
- lines.push(`\t\t${info.varName} = ${info.goFallback}`)
1219
- lines.push(`\t}`)
1231
+ if (info.assertType) {
1232
+ // Nillable-lowered prop (#2248): the `interface{}` field makes
1233
+ // "absent" (nil) distinguishable from an explicit `''`/`0`/`false`,
1234
+ // so the fallback applies ONLY on nil — JS `??` semantics. Numbers
1235
+ // coerce through the runtime (an untyped `Size: 3` literal boxes as
1236
+ // int even into a float64-shaped prop); string/bool assert directly.
1237
+ const deref =
1238
+ info.assertType === 'int' ? `bf.ToInt(in.${info.fieldName})`
1239
+ : info.assertType === 'float64' ? `bf.ToFloat64(in.${info.fieldName})`
1240
+ : `in.${info.fieldName}.(${info.assertType})`
1241
+ lines.push(`\tvar ${info.varName} ${info.assertType} = ${info.goFallback}`)
1242
+ lines.push(`\tif in.${info.fieldName} != nil {`)
1243
+ lines.push(`\t\t${info.varName} = ${deref}`)
1244
+ lines.push(`\t}`)
1245
+ } else {
1246
+ lines.push(`\t${info.varName} := in.${info.fieldName}`)
1247
+ lines.push(`\tif ${info.varName} == ${info.zeroLiteral} {`)
1248
+ lines.push(`\t\t${info.varName} = ${info.goFallback}`)
1249
+ lines.push(`\t}`)
1250
+ }
1220
1251
  }
1221
1252
  if (propFallbackVars.size > 0) lines.push('')
1222
1253
 
@@ -1325,7 +1356,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1325
1356
  if (propFieldNames.has(fieldName)) continue
1326
1357
  // `props.X ?? N` reuses the hoisted fallback var so signal and memo share
1327
1358
  // one value.
1328
- const fallbackMatch = this.extractPropFallback(signal.initialValue)
1359
+ const fallbackMatch = this.extractPropFallback(signal.initialValue, signal.parsed)
1329
1360
  const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
1330
1361
  if (hoisted) {
1331
1362
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
@@ -2715,8 +2746,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2715
2746
  localTaken.add(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
2716
2747
  }
2717
2748
 
2749
+ const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
2718
2750
  for (const signal of ir.metadata.signals) {
2719
- const match = this.extractPropFallback(signal.initialValue)
2751
+ const match = this.extractPropFallback(signal.initialValue, signal.parsed)
2720
2752
  if (!match) continue
2721
2753
  if (result.has(match.propName)) continue
2722
2754
  const param = ir.metadata.propsParams.find(p => p.name === match.propName)
@@ -2724,6 +2756,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2724
2756
  // A destructure default already wins via applyGoFallback below.
2725
2757
  if (goPropDefault(param.defaultValue) !== null) continue
2726
2758
  const fieldName = capitalizeFieldName(match.propName)
2759
+ // A `??`-consumed optional scalar lowered to `interface{}` (#2248) —
2760
+ // detected off the SAME `resolvePropGoType` pipeline the struct
2761
+ // generators use, so this can't drift from the emitted field type. The
2762
+ // concrete pre-flip type is what the hoisted local materializes as.
2763
+ const concreteType = propTypeOverrides.get(param.name) ?? typeInfoToGo(this.emitCtx, param.type, param.defaultValue)
2764
+ const nullishLowered =
2765
+ NULLISH_SCALAR_GO_TYPES.has(concreteType) &&
2766
+ resolvePropGoType(this.emitCtx, param, propTypeOverrides) === 'interface{}'
2727
2767
  // Pick the zero literal based on the fallback's literal shape. Bool
2728
2768
  // fallbacks (`?? true`) hoist against the `false` zero — the same Go-zero
2729
2769
  // conflation the int / string cases accept: the caller can't distinguish
@@ -2742,8 +2782,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2742
2782
  // (`?? 0`, `?? ''`, `?? false`, `?? 0.0`). Compare against the computed
2743
2783
  // zeroLiteral so spelling variants like `0.0` collapse to the same skip
2744
2784
  // as `0`.
2745
- if (match.goFallback === zeroLiteral) continue
2746
- if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
2785
+ //
2786
+ // NOT a no-op for a nillable-lowered prop (#2248): its `interface{}`
2787
+ // field can be nil, and the typed hoisted local is what downstream
2788
+ // consumers (`Count: size`, memo env maps) assign from — a nil
2789
+ // interface into a concrete field is a compile error, so the local
2790
+ // must exist even when the fallback equals the zero value.
2791
+ if (!nullishLowered) {
2792
+ if (match.goFallback === zeroLiteral) continue
2793
+ if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
2794
+ }
2747
2795
  // The JSX-side identifier is the natural local name; suffix with `_` if it
2748
2796
  // collides with a Go keyword or a local we already emit.
2749
2797
  let varName = match.propName
@@ -2751,21 +2799,43 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2751
2799
  varName += '_'
2752
2800
  }
2753
2801
  localTaken.add(varName)
2754
- result.set(match.propName, { varName, fieldName, goFallback: match.goFallback, zeroLiteral })
2802
+ result.set(match.propName, {
2803
+ varName,
2804
+ fieldName,
2805
+ goFallback: match.goFallback,
2806
+ zeroLiteral,
2807
+ ...(nullishLowered ? { assertType: concreteType } : {}),
2808
+ })
2755
2809
  }
2756
2810
  return result
2757
2811
  }
2758
2812
 
2759
2813
  /**
2760
- * Parse a signal-time initial value of the form `props.X ?? <literal>` into
2761
- * the source prop name and the Go-formatted fallback. Returns null when the
2762
- * expression isn't a `??` against a property access on `propsObjectName`, or
2763
- * the fallback isn't a simple literal `goPropDefault` can translate.
2814
+ * Parse a signal-time initial value of the form `props.X ?? <literal>`
2815
+ * or, for destructured components, `x ?? <literal>` into the source prop
2816
+ * name and the Go-formatted fallback. Returns null when the expression
2817
+ * isn't that shape or the fallback isn't a simple literal `goPropDefault`
2818
+ * can translate.
2819
+ *
2820
+ * `preParsed` (the signal's best-effort `ParsedExpr`) is matched
2821
+ * structurally when available; the regex handles only the props-object
2822
+ * member form for callers without a tree (memo computations). A bare
2823
+ * identifier can shadow a same-named prop (loop/callback params — the
2824
+ * `collectNullishConsumedPropNames` limitation class), so every caller
2825
+ * validates the returned name against `ir.metadata.propsParams`.
2764
2826
  *
2765
2827
  * Keeps the original prop reference (not just the resolved value) so
2766
2828
  * caller-supplied non-zero inputs are honoured.
2767
2829
  */
2768
- private extractPropFallback(initialValue: string): { propName: string; goFallback: string } | null {
2830
+ private extractPropFallback(
2831
+ initialValue: string,
2832
+ preParsed?: ParsedExpr,
2833
+ ): { propName: string; goFallback: string } | null {
2834
+ const structural = preParsed ? this.extractPropFallbackFromParsed(preParsed) : null
2835
+ if (structural) return structural
2836
+
2837
+ // Regex fallback for callers without a tree (memo computation strings)
2838
+ // and for props-object shapes the structural match doesn't model.
2769
2839
  if (!this.state.propsObjectName) return null
2770
2840
  const trimmed = initialValue.trim()
2771
2841
  const name = this.state.propsObjectName
@@ -2779,12 +2849,64 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2779
2849
  return { propName: m[1], goFallback }
2780
2850
  }
2781
2851
 
2852
+ /** Structural half of {@link extractPropFallback}. */
2853
+ private extractPropFallbackFromParsed(
2854
+ preParsed: ParsedExpr,
2855
+ ): { propName: string; goFallback: string } | null {
2856
+ if (preParsed.kind !== 'logical' || preParsed.op !== '??') return null
2857
+ const left = preParsed.left
2858
+ const propName =
2859
+ left.kind === 'identifier' && !this.state.propsObjectName
2860
+ ? left.name
2861
+ : left.kind === 'member' &&
2862
+ !left.computed &&
2863
+ left.object.kind === 'identifier' &&
2864
+ left.object.name === this.state.propsObjectName
2865
+ ? left.property
2866
+ : null
2867
+ if (!propName) return null
2868
+ // A negative fallback (`?? -1`) parses as unary minus around a number
2869
+ // literal, not a literal.
2870
+ let right = preParsed.right
2871
+ let negate = ''
2872
+ if (right.kind === 'unary' && right.op === '-') {
2873
+ negate = '-'
2874
+ right = right.argument
2875
+ }
2876
+ if (right.kind !== 'literal') return null
2877
+ if (negate && right.literalType !== 'number') return null
2878
+ // A string fallback is already the decoded value — quote it directly;
2879
+ // routing it through `goPropDefault` would strip JSON.stringify's outer
2880
+ // quotes and escape the body a second time (`"` → `\\\"`).
2881
+ if (right.literalType === 'string') {
2882
+ return { propName, goFallback: JSON.stringify(right.value) }
2883
+ }
2884
+ // Numbers keep their source spelling when available (`raw` is only
2885
+ // populated for numbers); booleans/null stringify losslessly.
2886
+ const goFallback = goPropDefault(negate + (right.raw ?? String(right.value)))
2887
+ if (goFallback === null) return null
2888
+ return { propName, goFallback }
2889
+ }
2890
+
2782
2891
  /**
2783
2892
  * Extract the prop name from a signal's `props.xxx`-pattern initialValue,
2784
2893
  * e.g. `"props.initial ?? 0"` → `"initial"`, `"props.checked"` → `"checked"`.
2894
+ * For destructured components (`propsObjectName` null) the same shapes are
2895
+ * matched structurally on `preParsed` with an identifier left operand
2896
+ * (`size ?? 0` → `"size"`); callers validate the name against
2897
+ * `propsParams`, which filters shadowing locals.
2785
2898
  */
2786
- private extractPropNameFromInitialValue(initialValue: string): string | null {
2787
- if (!this.state.propsObjectName) return null
2899
+ private extractPropNameFromInitialValue(initialValue: string, preParsed?: ParsedExpr): string | null {
2900
+ if (!this.state.propsObjectName) {
2901
+ if (
2902
+ preParsed?.kind === 'logical' &&
2903
+ (preParsed.op === '??' || preParsed.op === '||') &&
2904
+ preParsed.left.kind === 'identifier'
2905
+ ) {
2906
+ return preParsed.left.name
2907
+ }
2908
+ return null
2909
+ }
2788
2910
  const trimmed = initialValue.trim()
2789
2911
  const name = this.state.propsObjectName
2790
2912
 
@@ -2973,13 +3095,52 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2973
3095
  return goExpr
2974
3096
  }
2975
3097
 
3098
+ // A bare TEXT-position reference to an optional no-default scalar prop
3099
+ // that `resolvePropGoType` flipped to nillable `interface{}` (#2267,
3100
+ // `collectTextConsumedPropNames`) needs a nil-safe stringify: plain
3101
+ // `{{.X}}` prints a nil `interface{}` as the literal `<no value>`, not
3102
+ // "" — `bf_string` (the runtime's nil-safe `String()`) prints "" for
3103
+ // nil and formats a present value identically to `text/template`'s own
3104
+ // default printing, so this is a no-op for the non-nil case.
3105
+ const finalExpr =
3106
+ this.textNillablePropNameOf(classify.parsed) !== null
3107
+ ? `bf_string ${wrapIfMultiToken(goExpr)}`
3108
+ : goExpr
3109
+
2976
3110
  // Mark expressions with slotId using comment nodes for client JS to find.
2977
3111
  // This includes reactive expressions AND loop-param-dependent expressions.
2978
3112
  if (expr.slotId) {
2979
- return `{{bfTextStart "${expr.slotId}"}}{{${goExpr}}}{{bfTextEnd}}`
3113
+ return `{{bfTextStart "${expr.slotId}"}}{{${finalExpr}}}{{bfTextEnd}}`
2980
3114
  }
2981
3115
 
2982
- return `{{${goExpr}}}`
3116
+ return `{{${finalExpr}}}`
3117
+ }
3118
+
3119
+ /**
3120
+ * The nillable-prop name a bare TEXT-position expression refers to, or
3121
+ * null. Mirrors `nillablePropNameOf` (the `??`-lowering gate) but keys on
3122
+ * `textConsumedPropNames` instead of `nullishConsumedPropNames` — a
3123
+ * text-only optional scalar prop (`{size}`, never consumed by `??` or a
3124
+ * bare omittable attribute) is in neither of those other two sets.
3125
+ */
3126
+ private textNillablePropNameOf(expr: ParsedExpr | undefined): string | null {
3127
+ if (!expr) return null
3128
+ let name: string | null = null
3129
+ if (expr.kind === 'identifier') {
3130
+ name = expr.name
3131
+ } else if (
3132
+ expr.kind === 'member' &&
3133
+ !expr.computed &&
3134
+ expr.object.kind === 'identifier' &&
3135
+ expr.object.name === this.state.propsObjectName
3136
+ ) {
3137
+ name = expr.property
3138
+ }
3139
+ return name !== null &&
3140
+ this.state.textConsumedPropNames.has(name) &&
3141
+ this.state.nillablePropNames.has(name)
3142
+ ? name
3143
+ : null
2983
3144
  }
2984
3145
 
2985
3146
  /**
@@ -3392,7 +3553,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3392
3553
  }
3393
3554
 
3394
3555
  const obj = emit(object)
3395
- if (property === 'length') return `len ${obj}`
3556
+ // JS `.length`: array element count OR, for a string, UTF-16 code-unit
3557
+ // count (#2255) — NOT Go's native `len`, which is byte count for a
3558
+ // string. `Length`/`bf_length` (bf.go) dispatches on the runtime value's
3559
+ // shape to match. The specialized array-only `.length` shapes above
3560
+ // (filter-result count, memo-backed loop slice count) stay on `len`,
3561
+ // since arrays never hit the UTF-16 divergence.
3562
+ if (property === 'length') return `bf_length ${wrapIfMultiToken(obj)}`
3396
3563
  // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
3397
3564
  // a plain `.Field` dot-chain panics evaluating a field on a nil
3398
3565
  // interface/pointer (`nil pointer evaluating interface {}.Name`), so
@@ -3526,9 +3693,60 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3526
3693
  const wrapLeft = wrapIfMultiToken(emit(left))
3527
3694
  const wrapRight = wrapIfMultiToken(emit(right))
3528
3695
  if (op === '&&') return `and ${wrapLeft} ${wrapRight}`
3696
+ // `??` on a nillable prop needs true JS nullish semantics (#2248): Go's
3697
+ // `or` is truthiness-based, so `{{or .Label "Default"}}` falls back on a
3698
+ // present-but-empty `""` — JS `??` keeps it. `bf_nullish` tests nil-ness
3699
+ // only. Non-nillable operands keep `or`: their concrete Go type can't
3700
+ // represent "absent" at all, so `or` and `??` are indistinguishable there.
3701
+ if (op === '??' && this.nillablePropNameOf(left) !== null) {
3702
+ return `bf_nullish ${wrapLeft} ${wrapRight}`
3703
+ }
3529
3704
  return `or ${wrapLeft} ${wrapRight}`
3530
3705
  }
3531
3706
 
3707
+ /**
3708
+ * The nillable-prop name a `??` left operand refers to, or null. Matches
3709
+ * the two prop-reference shapes (`label` destructured, `props.label`
3710
+ * object-style) against `nullishConsumedPropNames ∩ nillablePropNames`.
3711
+ *
3712
+ * The intersection matters: `nillablePropNames` alone OVERAPPROXIMATES —
3713
+ * it is collected before local type aliases are registered, so an
3714
+ * alias-typed prop (`placement?: TooltipPlacement`) can sit in the set
3715
+ * while its emitted struct field is the concrete alias type. On such a
3716
+ * concrete field "absent" is invisible (the zero value), so the
3717
+ * truthiness-based `or` is the correct approximation and `bf_nullish`
3718
+ * would wrongly KEEP the zero value (e.g. Tooltip's
3719
+ * `placementClasses[props.placement ?? 'top']` would resolve to no
3720
+ * class for an omitted placement). Requiring `nullishConsumedPropNames`
3721
+ * membership pins the
3722
+ * gate to props the `??` analysis actually saw — the same set that drives
3723
+ * the `interface{}` flip in `resolvePropGoType`.
3724
+ */
3725
+ private nillablePropNameOf(expr: ParsedExpr): string | null {
3726
+ let name: string | null = null
3727
+ if (expr.kind === 'identifier') {
3728
+ name = expr.name
3729
+ } else if (expr.kind === 'member' && !expr.computed && expr.object.kind === 'identifier') {
3730
+ if (expr.object.name === this.state.propsObjectName) {
3731
+ name = expr.property
3732
+ } else {
3733
+ // Single-hop member access rooted at a bare destructured optional
3734
+ // OBJECT prop (`user?.name ?? '…'`, #2256) — the ROOT prop is what
3735
+ // needs to be in the nillable/nullish-consumed sets, not the
3736
+ // accessed field; `member()`'s own `bf_get` lowering for the `?.`
3737
+ // hop already returns Go `nil` on a nil/missing root, so gating on
3738
+ // the root here is sufficient. Mirrors
3739
+ // `collectNullishConsumedPropNames`'s `propNameOfLeft`.
3740
+ name = expr.object.name
3741
+ }
3742
+ }
3743
+ return name !== null &&
3744
+ this.state.nullishConsumedPropNames.has(name) &&
3745
+ this.state.nillablePropNames.has(name)
3746
+ ? name
3747
+ : null
3748
+ }
3749
+
3532
3750
  // JSX-level ternaries (`{expr ? a : b}`) are handled at the IR level as
3533
3751
  // IRConditional (via convertConditionToGo → renderConditionExpr). This method
3534
3752
  // is only reached for ternaries nested inside other ParsedExpr trees (e.g.
@@ -4252,11 +4470,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4252
4470
  return this.renderFilterExpr(pred, param, new Map(), datumField ?? undefined)
4253
4471
  }
4254
4472
 
4255
- /** Whether an expression needs parentheses when used in and/or. */
4256
- private needsParens(expr: ParsedExpr): boolean {
4257
- return expr.kind === 'logical' || expr.kind === 'unary' || expr.kind === 'conditional'
4258
- }
4259
-
4260
4473
  /**
4261
4474
  * Split a rendered template block into preamble + final expression.
4262
4475
  * The last `{{...}}` must be a variable reference (`$bf_rN` or
@@ -4935,19 +5148,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4935
5148
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
4936
5149
  return plain(this.rootFieldRef(expr.callee.name))
4937
5150
  }
4938
- // `isValidElement(x)` — the framework "is this a renderable element?"
4939
- // predicate. In the Go SSR children model an element is represented by
4940
- // its already-rendered markup, so this evaluates faithfully as a
4941
- // truthiness check on the argument (an element is "valid" when there is
4942
- // something to render). Lowering it as a real, evaluatable expression —
4943
- // rather than a fabricated `.IsValidElement` field access is what lets
4944
- // the `Slot` dynamic-tag guard register and run cleanly on Go.
5151
+ // `isValidElement(x)` — the framework "is this a renderable element
5152
+ // (not plain text)?" predicate. #2266: a passed-through JSX child is
5153
+ // ALSO represented as pre-rendered markup on Go's SSR model, so a
5154
+ // plain non-empty STRING child is truthy but is NOT a valid element
5155
+ // a bare truthiness check (the pre-#2266 lowering) wrongly took
5156
+ // `Slot`'s element-merge branch and panicked dereferencing
5157
+ // `.Props`/`.Tag` on a string (`can't evaluate field Props in type
5158
+ // interface {}`). `bf_is_element` (bf.go) does a real reflect-based
5159
+ // shape check (map/struct carrying both `tag`+`props` keys/fields,
5160
+ // case-insensitively), matching JS's `'tag' in x && 'props' in x`.
5161
+ // Pre-parenthesised — `call`-kind results aren't covered by
5162
+ // `needsParens`, so an unparenthesised `bf_is_element X` splices as
5163
+ // extra sibling args into an enclosing `and`/`or`.
4945
5164
  if (
4946
5165
  expr.callee.kind === 'identifier' &&
4947
5166
  (identifierPath(expr.callee) ?? expr.callee.name) === 'isValidElement' &&
4948
5167
  expr.args.length === 1
4949
5168
  ) {
4950
- return this.renderConditionExpr(expr.args[0])
5169
+ const inner = this.renderConditionExpr(expr.args[0])
5170
+ return { preamble: inner.preamble, expr: `(bf_is_element ${wrapIfMultiToken(inner.expr)})` }
4951
5171
  }
4952
5172
  // Any other user-defined predicate call with arguments (e.g.
4953
5173
  // `isAdmin(user)`) has no server-side evaluator and is not a registered
@@ -5087,11 +5307,27 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5087
5307
  const leftResult = this.renderConditionExpr(expr.left)
5088
5308
  const rightResult = this.renderConditionExpr(expr.right)
5089
5309
  const preamble = leftResult.preamble + rightResult.preamble
5090
- const wrapLeft = this.needsParens(expr.left) ? `(${leftResult.expr})` : leftResult.expr
5091
- const wrapRight = this.needsParens(expr.right) ? `(${rightResult.expr})` : rightResult.expr
5310
+ // `wrapIfMultiToken` (whitespace-based, on the RENDERED string) not
5311
+ // `needsParens` (AST-kind-based, only `logical`/`unary`/`conditional`)
5312
+ // — matches the main `logical()` emitter's own wrapping. `needsParens`
5313
+ // misses any other multi-token rendering (`len .X`, `bf_add a b`,
5314
+ // `.SearchParams.Get "k"`), which `and`/`or`/`bf_nullish` (all prefix
5315
+ // builtins) would otherwise parse as extra sibling args instead of one
5316
+ // operand.
5317
+ const wrapLeft = wrapIfMultiToken(leftResult.expr)
5318
+ const wrapRight = wrapIfMultiToken(rightResult.expr)
5319
+ // `??` on a nillable prop needs true JS nullish semantics (#2254,
5320
+ // sibling of #2248/#2252's fix for text-expression/signal-seed
5321
+ // positions): Go's `or` is truthiness-based, so a present-but-empty
5322
+ // `""`/`0`/`false` operand wrongly falls back. `bf_nullish` tests
5323
+ // nil-ness only. Mirrors the `logical()` emitter's own gate (used
5324
+ // for non-condition expression positions) — same
5325
+ // `nullishConsumed ∩ nillable` check via `nillablePropNameOf`.
5092
5326
  const result = expr.op === '&&'
5093
5327
  ? `and ${wrapLeft} ${wrapRight}`
5094
- : `or ${wrapLeft} ${wrapRight}`
5328
+ : expr.op === '??' && this.nillablePropNameOf(expr.left) !== null
5329
+ ? `bf_nullish ${wrapLeft} ${wrapRight}`
5330
+ : `or ${wrapLeft} ${wrapRight}`
5095
5331
  return { preamble, expr: result }
5096
5332
  }
5097
5333
 
@@ -5954,17 +6190,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5954
6190
  for (const e of entries) {
5955
6191
  if (e.kind === 'expr' && !isSupported(parseExpression(e.expr)).supported) return null
5956
6192
  }
5957
- // The static CSS key + literal value are inlined into a double-quoted
5958
- // `style="..."` attribute, so HTML-attr escape them (a value like `'"'`
5959
- // would otherwise terminate the attribute / inject markup). The dynamic
5960
- // arm's `{{…}}` action is escaped by `html/template`'s attribute context.
5961
- return entries
5962
- .map(e =>
5963
- e.kind === 'literal'
5964
- ? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}`
5965
- : `${this.escapeAttrText(e.cssKey)}:{{${this.convertExpressionToGo(e.expr)}}}`,
5966
- )
5967
- .join(';')
6193
+ // Routed through the single `bf_style_object` runtime call (#2261)
6194
+ // rather than per-pair `key:value` template interpolation a dynamic
6195
+ // value that fails the ported `hasUnsafeStyleValue` CSS-injection scan
6196
+ // must DROP its entire pair to match Hono's oracle behavior, which
6197
+ // isn't expressible as a per-pair inline substitution (a dropped
6198
+ // MIDDLE pair would otherwise leave a dangling `key:` / stray `;;` in
6199
+ // a `.map().join(';')` splice). `String()` (already returns "" for
6200
+ // nil) also has an established zero-value contract for numbers/bools,
6201
+ // avoiding html/template's own contextual CSS auto-escaper (whose
6202
+ // `ZgotmplZ` sentinel — the pre-#2261 divergence — is bypassed by the
6203
+ // call returning a trusted `template.CSS`, not a plain `string`).
6204
+ const args = entries.flatMap(e => [
6205
+ JSON.stringify(e.cssKey),
6206
+ e.kind === 'literal' ? JSON.stringify(e.value) : wrapIfMultiToken(this.convertExpressionToGo(e.expr)),
6207
+ ])
6208
+ return `{{bf_style_object ${args.join(' ')}}}`
5968
6209
  }
5969
6210
 
5970
6211
  private renderAttributes(element: IRElement): string {
@@ -146,6 +146,42 @@ export class CompileState {
146
146
  */
147
147
  nillablePropNames: Set<string> = new Set()
148
148
 
149
+ /**
150
+ * OPTIONAL prop names consumed nullish-sensitively (`??` left operand in a
151
+ * parsed expression tree, or a signal's `props.X ?? <literal>` seed) —
152
+ * #2248. Consulted by `resolvePropGoType` to flip an optional scalar to the
153
+ * nillable `interface{}` representation, so it MUST be populated before the
154
+ * first `resolvePropGoType` call of a compile (see `generate()`'s ordering
155
+ * against `collectNillablePropNames`).
156
+ */
157
+ nullishConsumedPropNames: Set<string> = new Set()
158
+
159
+ /**
160
+ * OPTIONAL no-default prop names consumed as a BARE omittable-attribute
161
+ * value (`rows={rows}`) — #2259. Same `resolvePropGoType` flip and same
162
+ * populate-before-first-resolve ordering as `nullishConsumedPropNames`:
163
+ * the attribute-omission guard (`{{if ne .X nil}}`) needs a nillable field.
164
+ */
165
+ omittableAttrConsumedPropNames: Set<string> = new Set()
166
+
167
+ /**
168
+ * OPTIONAL no-default prop names consumed as a BARE TEXT-position
169
+ * expression value (`{size}`) — #2267. Same `resolvePropGoType` flip and
170
+ * same populate-before-first-resolve ordering as
171
+ * `nullishConsumedPropNames`: the text emitter's nil-safe `bf_string`
172
+ * wrap needs a nillable field to have something to guard.
173
+ */
174
+ textConsumedPropNames: Set<string> = new Set()
175
+
176
+ /**
177
+ * OPTIONAL no-default prop names whose PRESENCE is tested — `props.X !==
178
+ * undefined` (the "controlled component" idiom's `isControlled` memo) —
179
+ * #2260. Same `resolvePropGoType` flip and same populate-before-first-
180
+ * resolve ordering as `nullishConsumedPropNames`: distinguishing "caller
181
+ * passed a value" from "caller omitted the prop" needs a nillable field.
182
+ */
183
+ presenceCheckedPropNames: Set<string> = new Set()
184
+
149
185
  /**
150
186
  * String-typed signal getter / prop names (#2168 string-concat-plus).
151
187
  * Feeds `isStringName` for `isStringConcatBinary`, which decides whether a
@@ -140,6 +140,15 @@ export interface PropFallbackVar {
140
140
  goFallback: string
141
141
  /** Go zero literal for the prop's type (`0`, `""`, etc.). */
142
142
  zeroLiteral: string
143
+ /**
144
+ * Set when the prop lowered to the nillable `interface{}` representation
145
+ * (#2248): the concrete scalar Go type (`string`/`int`/`float64`/`bool`)
146
+ * the constructor materializes the hoisted local as. Presence switches
147
+ * the emission from the zero-value check (`if v == 0`) to a nil check
148
+ * (`if in.X != nil`), which is what makes an explicit `''`/`0` input
149
+ * distinguishable from an absent one.
150
+ */
151
+ assertType?: string
143
152
  }
144
153
 
145
154
  /**