@barefootjs/go-template 0.18.7 → 0.19.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.
@@ -119,6 +119,37 @@ runAdapterConformanceTests({
119
119
  // (#1897) data-table no longer skipped — loop body children + wrapper
120
120
  // struct + block-body memo baking render correctly on Go.
121
121
  ]),
122
+ skipDataPoints: new Set<string>([
123
+ // #2255 — Go `len` counts bytes; JS counts UTF-16 code units
124
+ // ('日本語' is 3 in JS, 9 here; '👍' is 2 in JS, 4 here).
125
+ 'string-length-text:multibyte',
126
+ 'string-length-text:astral',
127
+ // #2256 — the nullish gate covers only BARE nillable prop refs; a
128
+ // member-access left operand (`user?.name ?? '…'`) still lowers to
129
+ // the truthiness `or`, so a present-but-empty member falls back.
130
+ 'optional-chaining-prop:empty-name',
131
+ // #2260 — controlled/derived boolean props: the SSR seed evaluates
132
+ // only the static fallback of `props.X ?? internal()` chains, so a
133
+ // caller-supplied true never reaches aria-*/data-state.
134
+ 'toggle:gen:defaultPressed:true',
135
+ 'toggle:gen:pressed:true',
136
+ 'switch:gen:defaultChecked:true',
137
+ 'switch:gen:checked:true',
138
+ 'checkbox:gen:defaultChecked:true',
139
+ 'checkbox:gen:checked:true',
140
+ // #2261 — invalid dynamic CSS value: html/template emits the
141
+ // ZgotmplZ sentinel where the oracle drops the property.
142
+ 'style-object-dynamic:gen:color:markup',
143
+ // #2262 — dynamic `.flat` depth 0/negative renders empty instead of
144
+ // the contract's shallow copy.
145
+ 'array-flat-dynamic-depth:gen:depth:zero',
146
+ 'array-flat-dynamic-depth:gen:depth:negative',
147
+ // #2266 — asChild=true with plain-text children: the Slot define
148
+ // dereferences `.Children.Props.Children`, which hard-errors on a
149
+ // string child (`can't evaluate field Props in type interface {}`).
150
+ 'button:gen:asChild:true',
151
+ 'kbd:gen:asChild:true',
152
+ ]),
122
153
  onRenderError: (err, id) => {
123
154
  if (err instanceof GoNotAvailableError) {
124
155
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -506,9 +537,11 @@ export function Counter(props: { initial?: number }) {
506
537
  expect(result.types).toBeDefined()
507
538
  const types = result.types!
508
539
 
509
- // Hoist: `initial := in.Initial` + zero-check + fallback assign.
510
- expect(types).toContain('initial := in.Initial')
511
- expect(types).toMatch(/if initial == 0 \{\s*initial = 99\s*\}/)
540
+ // The fallback applies only when the prop is ABSENT: an explicit
541
+ // `Initial: 0` is honoured (JS `0 ?? 99` is `0`, #2248).
542
+ expect(types).toContain('Initial interface{}')
543
+ expect(types).toContain('var initial int = 99')
544
+ expect(types).toMatch(/if in\.Initial != nil \{\s*initial = bf\.ToInt\(in\.Initial\)\s*\}/)
512
545
 
513
546
  // Prop, signal, and memo all reference the hoisted variable.
514
547
  expect(types).toContain('Initial: initial,')
@@ -553,8 +586,11 @@ export function Label(props: { label?: string }) {
553
586
  `)
554
587
  const result = adapter.generate(ir)
555
588
  const types = result.types!
556
- expect(types).toContain('label := in.Label')
557
- expect(types).toMatch(/if label == ""\s*\{\s*label = "Default"\s*\}/)
589
+ // An explicit `Label: ""` input stays empty (JS `'' ?? 'Default'` is
590
+ // `''`, #2248).
591
+ expect(types).toContain('Label interface{}')
592
+ expect(types).toContain('var label string = "Default"')
593
+ expect(types).toMatch(/if in\.Label != nil \{\s*label = in\.Label\.\(string\)\s*\}/)
558
594
  expect(types).toContain('Label: label,')
559
595
  // Signal name `text` differs from prop name `label`, so the
560
596
  // signal field gets its own entry that resolves through the
@@ -562,12 +598,11 @@ export function Label(props: { label?: string }) {
562
598
  expect(types).toContain('Text: label,')
563
599
  })
564
600
 
565
- test('hoists `props.X ?? true` against the bool zero (#1423 review)', () => {
566
- // Bool-true falls through the same hoist path as int / string
567
- // the asymmetry is documented (caller can't thread "explicit
568
- // false" through because Go's bool zero IS false), but emitting
569
- // a hoisted local matches the int case's shape so a derived
570
- // memo can inherit it.
601
+ test('hoists `props.X ?? true` against nil (#1423 review, #2248)', () => {
602
+ // Bool-true falls through the same hoist path as int / string. The
603
+ // interface{} field is nil only when the caller omitted the prop, so
604
+ // an explicit `Checked: false` survives (JS `false ?? true` is
605
+ // `false`).
571
606
  const adapter = new GoTemplateAdapter()
572
607
  const ir = compileToIR(`
573
608
  "use client"
@@ -580,12 +615,124 @@ export function Check(props: { checked?: boolean }) {
580
615
  `)
581
616
  const result = adapter.generate(ir)
582
617
  const types = result.types!
583
- expect(types).toContain('checked := in.Checked')
584
- expect(types).toMatch(/if checked == false\s*\{\s*checked = true\s*\}/)
618
+ expect(types).toContain('Checked interface{}')
619
+ expect(types).toContain('var checked bool = true')
620
+ expect(types).toMatch(/if in\.Checked != nil \{\s*checked = in\.Checked\.\(bool\)\s*\}/)
585
621
  expect(types).toContain('Checked: checked,')
586
622
  expect(types).toContain('C: checked,')
587
623
  })
588
624
 
625
+ test('hoists the DESTRUCTURED `x ?? N` seed via signal.parsed (#2259)', () => {
626
+ // Destructured components have no `propsObjectName`, so the seed's
627
+ // prop reference is a bare identifier — matched structurally on the
628
+ // signal's ParsedExpr, then routed through the same nullish flip +
629
+ // hoisted-var machinery as `props.size ?? 1` (#2248/#2252 parity).
630
+ const adapter = new GoTemplateAdapter()
631
+ const ir = compileToIR(`
632
+ "use client"
633
+ import { createSignal } from "@barefootjs/client"
634
+
635
+ export function Counter({ size }: { size?: number }) {
636
+ const [count, setCount] = createSignal(size ?? 1)
637
+ return <div>{count()}</div>
638
+ }
639
+ `)
640
+ const result = adapter.generate(ir)
641
+ const types = result.types!
642
+ expect(types).toContain('Size interface{}')
643
+ expect(types).toContain('var size int = 1')
644
+ expect(types).toMatch(/if in\.Size != nil \{\s*size = bf\.ToInt\(in\.Size\)\s*\}/)
645
+ expect(types).toContain('Size: size,')
646
+ expect(types).toContain('Count: size,')
647
+ })
648
+
649
+ test('destructured `x ?? 0` stays concrete and seeds the prop directly (#2259)', () => {
650
+ // A zero-equivalent fallback earns no nillable flip (nil and the Go
651
+ // zero value land on the same output), so the field keeps its scalar
652
+ // type and the signal seeds straight off the input — NOT the literal
653
+ // `0` the pre-#2259 lowering emitted for the unrecognized identifier
654
+ // form.
655
+ const adapter = new GoTemplateAdapter()
656
+ const ir = compileToIR(`
657
+ "use client"
658
+ import { createSignal } from "@barefootjs/client"
659
+
660
+ export function Counter({ size }: { size?: number }) {
661
+ const [count, setCount] = createSignal(size ?? 0)
662
+ return <div>{count()}</div>
663
+ }
664
+ `)
665
+ const result = adapter.generate(ir)
666
+ const types = result.types!
667
+ expect(types).toContain('Size int')
668
+ expect(types).not.toContain('Size interface{}')
669
+ expect(types).toContain('Size: in.Size,')
670
+ expect(types).toContain('Count: in.Size,')
671
+ })
672
+
673
+ test('negative fallback (`?? -1`) hoists in both prop styles (#2259 review)', () => {
674
+ // `-1` parses as unary minus around a number literal, not a literal —
675
+ // the structural seed match must reconstruct the sign, in the
676
+ // props-object form (where it preempts the regex path) and the
677
+ // destructured form alike.
678
+ for (const [params, ref] of [
679
+ ['props: { size?: number }', 'props.size'],
680
+ ['{ size }: { size?: number }', 'size'],
681
+ ]) {
682
+ const adapter = new GoTemplateAdapter()
683
+ const ir = compileToIR(`
684
+ "use client"
685
+ import { createSignal } from "@barefootjs/client"
686
+
687
+ export function Counter(${params}) {
688
+ const [count, setCount] = createSignal(${ref} ?? -1)
689
+ return <div>{count()}</div>
690
+ }
691
+ `, adapter)
692
+ const types = adapter.generate(ir).types!
693
+ expect(types).toContain('Size interface{}')
694
+ expect(types).toContain('var size int = -1')
695
+ expect(types).toContain('Count: size,')
696
+ }
697
+ })
698
+
699
+ test('string fallback with quotes survives the structural match unmangled (#2259 review)', () => {
700
+ const adapter = new GoTemplateAdapter()
701
+ const ir = compileToIR(`
702
+ "use client"
703
+ import { createSignal } from "@barefootjs/client"
704
+
705
+ export function Label({ label }: { label?: string }) {
706
+ const [text, setText] = createSignal(label ?? 'say "hi"')
707
+ return <div>{text()}</div>
708
+ }
709
+ `, adapter)
710
+ const types = adapter.generate(ir).types!
711
+ expect(types).toContain('var label string = "say \\"hi\\""')
712
+ expect(types).not.toContain('\\\\')
713
+ })
714
+
715
+ test('destructure DEFAULT keeps the concrete-typed applyGoFallback baking (#2259)', () => {
716
+ // `{ size = 5 }` never sees a nullish binding in JS (the default
717
+ // already applied), so it must stay excluded from the nillable flip
718
+ // and keep the zero-check baking on the concrete field.
719
+ const adapter = new GoTemplateAdapter()
720
+ const ir = compileToIR(`
721
+ "use client"
722
+ import { createSignal } from "@barefootjs/client"
723
+
724
+ export function Counter({ size = 5 }: { size?: number }) {
725
+ const [count, setCount] = createSignal(size)
726
+ return <div>{count()}</div>
727
+ }
728
+ `)
729
+ const result = adapter.generate(ir)
730
+ const types = result.types!
731
+ expect(types).toContain('Size int')
732
+ expect(types).not.toContain('Size interface{}')
733
+ expect(types).toContain('Size: func() int { if in.Size == 0 { return 5 }; return in.Size }(),')
734
+ })
735
+
589
736
  test('skips hoist for zero-equivalent string and float fallbacks (#1423 review)', () => {
590
737
  // The skip predicate compares the Go fallback against the
591
738
  // type's zero literal — covers `?? ''` (string) and `?? 0.0`
@@ -979,10 +1126,13 @@ function Box({ describedBy }: { describedBy?: string }) {
979
1126
  // Template emission is unchanged from the proven {...props} path.
980
1127
  expect(template).toContain('{{bf_spread_attrs .Spread_0}}')
981
1128
  // The bag value is a conditional map built in NewBoxProps. The
982
- // prop type is unresolved (interface{}), so the condition routes
983
- // through `bf.Truthy` for a faithful JS `Boolean(x)` test.
1129
+ // optional prop resolves to a concrete `string` (#2259) it is
1130
+ // consumed only inside the spread condition, not as a bare attr, so
1131
+ // no nillable flip — and the field-typed condition is a faithful
1132
+ // `!= ""` truthiness test rather than reflection: JS string
1133
+ // truthiness IS non-emptiness, and an absent prop zeroes to `""`.
984
1134
  expect(types).toContain('Spread_0: func() map[string]any {')
985
- expect(types).toContain('if bf.Truthy(in.DescribedBy) {')
1135
+ expect(types).toContain('if in.DescribedBy != "" {')
986
1136
  expect(types).toContain('return map[string]any{"aria-describedby": in.DescribedBy}')
987
1137
  expect(types).toContain('return map[string]any{}')
988
1138
  })
@@ -3631,8 +3781,9 @@ export function C(props: Props) {
3631
3781
  const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3632
3782
  // The `{}` fallback lowers to the safe `""` Go string sentinel — never the
3633
3783
  // `[UNSUPPORTED: …]` marker text, which would break `text/template` parsing
3634
- // once spliced as an `or` operand.
3635
- expect(template).toContain('{{or .Config ""}}')
3784
+ // once spliced as an operand. `??` on a nillable prop lowers to the
3785
+ // nil-testing `bf_nullish` (#2248), equally valid template syntax.
3786
+ expect(template).toContain('{{bf_nullish .Config ""}}')
3636
3787
  expect(template).not.toContain('UNSUPPORTED')
3637
3788
  })
3638
3789
 
@@ -41,14 +41,23 @@ export interface GoEmitContext {
41
41
  preParsed?: ParsedExpr,
42
42
  ): { condition: string; preamble: string }
43
43
 
44
- /** Extract the prop name from a `props.X ?? …` initial value, or null. */
45
- extractPropNameFromInitialValue(initialValue: string): string | null
44
+ /**
45
+ * Extract the prop name from a `props.X ?? …` initial value — or, given
46
+ * `preParsed` in a destructured component, an `x ?? …` identifier form —
47
+ * or null. Callers validate the name against `propsParams`.
48
+ */
49
+ extractPropNameFromInitialValue(initialValue: string, preParsed?: ParsedExpr): string | null
46
50
 
47
51
  /**
48
- * Parse a signal-time initial value `props.X ?? <literal>` into the source
49
- * prop name and the Go-formatted fallback, or null when it isn't that shape.
52
+ * Parse a signal-time initial value `props.X ?? <literal>` (or the
53
+ * destructured `x ?? <literal>` form when `preParsed` is given) into the
54
+ * source prop name and the Go-formatted fallback, or null when it isn't
55
+ * that shape. Callers validate the name against `propsParams`.
50
56
  */
51
- extractPropFallback(initialValue: string): { propName: string; goFallback: string } | null
57
+ extractPropFallback(
58
+ initialValue: string,
59
+ preParsed?: ParsedExpr,
60
+ ): { propName: string; goFallback: string } | null
52
61
 
53
62
  /**
54
63
  * Inline a module string const by name as a Go double-quoted literal
@@ -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, 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,19 @@ 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.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
409
423
  }
410
424
 
411
425
  /** Generate template output for a component. */
@@ -416,7 +430,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
416
430
  this.state.templateVarCounter = 0
417
431
  this.state.pendingChildrenDefines = []
418
432
  this.primeCompileState(ir)
419
- this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
420
433
  this.state.stringValueNames = collectStringValueNames(ir)
421
434
 
422
435
  // Surface loop-body usages of sibling-imported components (see
@@ -1213,10 +1226,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1213
1226
  // from an omitted field, so it also fires on the type's zero value.
1214
1227
  const propFallbackVars = this.collectPropFallbackVars(ir)
1215
1228
  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}`)
1229
+ if (info.assertType) {
1230
+ // Nillable-lowered prop (#2248): the `interface{}` field makes
1231
+ // "absent" (nil) distinguishable from an explicit `''`/`0`/`false`,
1232
+ // so the fallback applies ONLY on nil — JS `??` semantics. Numbers
1233
+ // coerce through the runtime (an untyped `Size: 3` literal boxes as
1234
+ // int even into a float64-shaped prop); string/bool assert directly.
1235
+ const deref =
1236
+ info.assertType === 'int' ? `bf.ToInt(in.${info.fieldName})`
1237
+ : info.assertType === 'float64' ? `bf.ToFloat64(in.${info.fieldName})`
1238
+ : `in.${info.fieldName}.(${info.assertType})`
1239
+ lines.push(`\tvar ${info.varName} ${info.assertType} = ${info.goFallback}`)
1240
+ lines.push(`\tif in.${info.fieldName} != nil {`)
1241
+ lines.push(`\t\t${info.varName} = ${deref}`)
1242
+ lines.push(`\t}`)
1243
+ } else {
1244
+ lines.push(`\t${info.varName} := in.${info.fieldName}`)
1245
+ lines.push(`\tif ${info.varName} == ${info.zeroLiteral} {`)
1246
+ lines.push(`\t\t${info.varName} = ${info.goFallback}`)
1247
+ lines.push(`\t}`)
1248
+ }
1220
1249
  }
1221
1250
  if (propFallbackVars.size > 0) lines.push('')
1222
1251
 
@@ -1325,7 +1354,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
1325
1354
  if (propFieldNames.has(fieldName)) continue
1326
1355
  // `props.X ?? N` reuses the hoisted fallback var so signal and memo share
1327
1356
  // one value.
1328
- const fallbackMatch = this.extractPropFallback(signal.initialValue)
1357
+ const fallbackMatch = this.extractPropFallback(signal.initialValue, signal.parsed)
1329
1358
  const hoisted = fallbackMatch ? propFallbackVars.get(fallbackMatch.propName) : undefined
1330
1359
  if (hoisted) {
1331
1360
  lines.push(`\t\t${fieldName}: ${hoisted.varName},`)
@@ -2715,8 +2744,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2715
2744
  localTaken.add(`${nested.name.charAt(0).toLowerCase()}${nested.name.slice(1)}s`)
2716
2745
  }
2717
2746
 
2747
+ const propTypeOverrides = buildPropTypeOverrides(this.emitCtx, ir)
2718
2748
  for (const signal of ir.metadata.signals) {
2719
- const match = this.extractPropFallback(signal.initialValue)
2749
+ const match = this.extractPropFallback(signal.initialValue, signal.parsed)
2720
2750
  if (!match) continue
2721
2751
  if (result.has(match.propName)) continue
2722
2752
  const param = ir.metadata.propsParams.find(p => p.name === match.propName)
@@ -2724,6 +2754,14 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2724
2754
  // A destructure default already wins via applyGoFallback below.
2725
2755
  if (goPropDefault(param.defaultValue) !== null) continue
2726
2756
  const fieldName = capitalizeFieldName(match.propName)
2757
+ // A `??`-consumed optional scalar lowered to `interface{}` (#2248) —
2758
+ // detected off the SAME `resolvePropGoType` pipeline the struct
2759
+ // generators use, so this can't drift from the emitted field type. The
2760
+ // concrete pre-flip type is what the hoisted local materializes as.
2761
+ const concreteType = propTypeOverrides.get(param.name) ?? typeInfoToGo(this.emitCtx, param.type, param.defaultValue)
2762
+ const nullishLowered =
2763
+ NULLISH_SCALAR_GO_TYPES.has(concreteType) &&
2764
+ resolvePropGoType(this.emitCtx, param, propTypeOverrides) === 'interface{}'
2727
2765
  // Pick the zero literal based on the fallback's literal shape. Bool
2728
2766
  // fallbacks (`?? true`) hoist against the `false` zero — the same Go-zero
2729
2767
  // conflation the int / string cases accept: the caller can't distinguish
@@ -2742,8 +2780,16 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2742
2780
  // (`?? 0`, `?? ''`, `?? false`, `?? 0.0`). Compare against the computed
2743
2781
  // zeroLiteral so spelling variants like `0.0` collapse to the same skip
2744
2782
  // as `0`.
2745
- if (match.goFallback === zeroLiteral) continue
2746
- if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
2783
+ //
2784
+ // NOT a no-op for a nillable-lowered prop (#2248): its `interface{}`
2785
+ // field can be nil, and the typed hoisted local is what downstream
2786
+ // consumers (`Count: size`, memo env maps) assign from — a nil
2787
+ // interface into a concrete field is a compile error, so the local
2788
+ // must exist even when the fallback equals the zero value.
2789
+ if (!nullishLowered) {
2790
+ if (match.goFallback === zeroLiteral) continue
2791
+ if (zeroLiteral === '0' && Number(match.goFallback) === 0) continue
2792
+ }
2747
2793
  // The JSX-side identifier is the natural local name; suffix with `_` if it
2748
2794
  // collides with a Go keyword or a local we already emit.
2749
2795
  let varName = match.propName
@@ -2751,21 +2797,43 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2751
2797
  varName += '_'
2752
2798
  }
2753
2799
  localTaken.add(varName)
2754
- result.set(match.propName, { varName, fieldName, goFallback: match.goFallback, zeroLiteral })
2800
+ result.set(match.propName, {
2801
+ varName,
2802
+ fieldName,
2803
+ goFallback: match.goFallback,
2804
+ zeroLiteral,
2805
+ ...(nullishLowered ? { assertType: concreteType } : {}),
2806
+ })
2755
2807
  }
2756
2808
  return result
2757
2809
  }
2758
2810
 
2759
2811
  /**
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.
2812
+ * Parse a signal-time initial value of the form `props.X ?? <literal>`
2813
+ * or, for destructured components, `x ?? <literal>` into the source prop
2814
+ * name and the Go-formatted fallback. Returns null when the expression
2815
+ * isn't that shape or the fallback isn't a simple literal `goPropDefault`
2816
+ * can translate.
2817
+ *
2818
+ * `preParsed` (the signal's best-effort `ParsedExpr`) is matched
2819
+ * structurally when available; the regex handles only the props-object
2820
+ * member form for callers without a tree (memo computations). A bare
2821
+ * identifier can shadow a same-named prop (loop/callback params — the
2822
+ * `collectNullishConsumedPropNames` limitation class), so every caller
2823
+ * validates the returned name against `ir.metadata.propsParams`.
2764
2824
  *
2765
2825
  * Keeps the original prop reference (not just the resolved value) so
2766
2826
  * caller-supplied non-zero inputs are honoured.
2767
2827
  */
2768
- private extractPropFallback(initialValue: string): { propName: string; goFallback: string } | null {
2828
+ private extractPropFallback(
2829
+ initialValue: string,
2830
+ preParsed?: ParsedExpr,
2831
+ ): { propName: string; goFallback: string } | null {
2832
+ const structural = preParsed ? this.extractPropFallbackFromParsed(preParsed) : null
2833
+ if (structural) return structural
2834
+
2835
+ // Regex fallback for callers without a tree (memo computation strings)
2836
+ // and for props-object shapes the structural match doesn't model.
2769
2837
  if (!this.state.propsObjectName) return null
2770
2838
  const trimmed = initialValue.trim()
2771
2839
  const name = this.state.propsObjectName
@@ -2779,12 +2847,64 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2779
2847
  return { propName: m[1], goFallback }
2780
2848
  }
2781
2849
 
2850
+ /** Structural half of {@link extractPropFallback}. */
2851
+ private extractPropFallbackFromParsed(
2852
+ preParsed: ParsedExpr,
2853
+ ): { propName: string; goFallback: string } | null {
2854
+ if (preParsed.kind !== 'logical' || preParsed.op !== '??') return null
2855
+ const left = preParsed.left
2856
+ const propName =
2857
+ left.kind === 'identifier' && !this.state.propsObjectName
2858
+ ? left.name
2859
+ : left.kind === 'member' &&
2860
+ !left.computed &&
2861
+ left.object.kind === 'identifier' &&
2862
+ left.object.name === this.state.propsObjectName
2863
+ ? left.property
2864
+ : null
2865
+ if (!propName) return null
2866
+ // A negative fallback (`?? -1`) parses as unary minus around a number
2867
+ // literal, not a literal.
2868
+ let right = preParsed.right
2869
+ let negate = ''
2870
+ if (right.kind === 'unary' && right.op === '-') {
2871
+ negate = '-'
2872
+ right = right.argument
2873
+ }
2874
+ if (right.kind !== 'literal') return null
2875
+ if (negate && right.literalType !== 'number') return null
2876
+ // A string fallback is already the decoded value — quote it directly;
2877
+ // routing it through `goPropDefault` would strip JSON.stringify's outer
2878
+ // quotes and escape the body a second time (`"` → `\\\"`).
2879
+ if (right.literalType === 'string') {
2880
+ return { propName, goFallback: JSON.stringify(right.value) }
2881
+ }
2882
+ // Numbers keep their source spelling when available (`raw` is only
2883
+ // populated for numbers); booleans/null stringify losslessly.
2884
+ const goFallback = goPropDefault(negate + (right.raw ?? String(right.value)))
2885
+ if (goFallback === null) return null
2886
+ return { propName, goFallback }
2887
+ }
2888
+
2782
2889
  /**
2783
2890
  * Extract the prop name from a signal's `props.xxx`-pattern initialValue,
2784
2891
  * e.g. `"props.initial ?? 0"` → `"initial"`, `"props.checked"` → `"checked"`.
2892
+ * For destructured components (`propsObjectName` null) the same shapes are
2893
+ * matched structurally on `preParsed` with an identifier left operand
2894
+ * (`size ?? 0` → `"size"`); callers validate the name against
2895
+ * `propsParams`, which filters shadowing locals.
2785
2896
  */
2786
- private extractPropNameFromInitialValue(initialValue: string): string | null {
2787
- if (!this.state.propsObjectName) return null
2897
+ private extractPropNameFromInitialValue(initialValue: string, preParsed?: ParsedExpr): string | null {
2898
+ if (!this.state.propsObjectName) {
2899
+ if (
2900
+ preParsed?.kind === 'logical' &&
2901
+ (preParsed.op === '??' || preParsed.op === '||') &&
2902
+ preParsed.left.kind === 'identifier'
2903
+ ) {
2904
+ return preParsed.left.name
2905
+ }
2906
+ return null
2907
+ }
2788
2908
  const trimmed = initialValue.trim()
2789
2909
  const name = this.state.propsObjectName
2790
2910
 
@@ -3526,9 +3646,54 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3526
3646
  const wrapLeft = wrapIfMultiToken(emit(left))
3527
3647
  const wrapRight = wrapIfMultiToken(emit(right))
3528
3648
  if (op === '&&') return `and ${wrapLeft} ${wrapRight}`
3649
+ // `??` on a nillable prop needs true JS nullish semantics (#2248): Go's
3650
+ // `or` is truthiness-based, so `{{or .Label "Default"}}` falls back on a
3651
+ // present-but-empty `""` — JS `??` keeps it. `bf_nullish` tests nil-ness
3652
+ // only. Non-nillable operands keep `or`: their concrete Go type can't
3653
+ // represent "absent" at all, so `or` and `??` are indistinguishable there.
3654
+ if (op === '??' && this.nillablePropNameOf(left) !== null) {
3655
+ return `bf_nullish ${wrapLeft} ${wrapRight}`
3656
+ }
3529
3657
  return `or ${wrapLeft} ${wrapRight}`
3530
3658
  }
3531
3659
 
3660
+ /**
3661
+ * The nillable-prop name a `??` left operand refers to, or null. Matches
3662
+ * the two prop-reference shapes (`label` destructured, `props.label`
3663
+ * object-style) against `nullishConsumedPropNames ∩ nillablePropNames`.
3664
+ *
3665
+ * The intersection matters: `nillablePropNames` alone OVERAPPROXIMATES —
3666
+ * it is collected before local type aliases are registered, so an
3667
+ * alias-typed prop (`placement?: TooltipPlacement`) can sit in the set
3668
+ * while its emitted struct field is the concrete alias type. On such a
3669
+ * concrete field "absent" is invisible (the zero value), so the
3670
+ * truthiness-based `or` is the correct approximation and `bf_nullish`
3671
+ * would wrongly KEEP the zero value (e.g. Tooltip's
3672
+ * `placementClasses[props.placement ?? 'top']` would resolve to no
3673
+ * class for an omitted placement). Requiring `nullishConsumedPropNames`
3674
+ * membership pins the
3675
+ * gate to props the `??` analysis actually saw — the same set that drives
3676
+ * the `interface{}` flip in `resolvePropGoType`.
3677
+ */
3678
+ private nillablePropNameOf(expr: ParsedExpr): string | null {
3679
+ let name: string | null = null
3680
+ if (expr.kind === 'identifier') {
3681
+ name = expr.name
3682
+ } else if (
3683
+ expr.kind === 'member' &&
3684
+ !expr.computed &&
3685
+ expr.object.kind === 'identifier' &&
3686
+ expr.object.name === this.state.propsObjectName
3687
+ ) {
3688
+ name = expr.property
3689
+ }
3690
+ return name !== null &&
3691
+ this.state.nullishConsumedPropNames.has(name) &&
3692
+ this.state.nillablePropNames.has(name)
3693
+ ? name
3694
+ : null
3695
+ }
3696
+
3532
3697
  // JSX-level ternaries (`{expr ? a : b}`) are handled at the IR level as
3533
3698
  // IRConditional (via convertConditionToGo → renderConditionExpr). This method
3534
3699
  // is only reached for ternaries nested inside other ParsedExpr trees (e.g.
@@ -146,6 +146,24 @@ 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
+
149
167
  /**
150
168
  * String-typed signal getter / prop names (#2168 string-concat-plus).
151
169
  * 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
  /**