@barefootjs/go-template 0.19.0 → 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.
@@ -35,6 +35,34 @@ function getterCallName(e: ParsedExpr): string | null {
35
35
  : null
36
36
  }
37
37
 
38
+ /** Whether `t` is `boolean`, or a `T | undefined`/`T | null` union whose
39
+ * non-nullish branch is `boolean` (the controlled-signal shape,
40
+ * `createSignal<boolean | undefined>(...)`). */
41
+ function isBooleanTypeInfo(t: TypeInfo): boolean {
42
+ if (t.kind === 'primitive') return t.primitive === 'boolean'
43
+ if (t.kind === 'union' && t.unionTypes?.length === 2) {
44
+ const scalar = t.unionTypes.find(u => u.primitive !== 'undefined' && u.primitive !== 'null')
45
+ return scalar?.kind === 'primitive' && scalar.primitive === 'boolean'
46
+ }
47
+ return false
48
+ }
49
+
50
+ /** Whether the getter NAME (a signal or memo) has a boolean-compatible
51
+ * declared type — gates the ternary-over-getter-calls branch below
52
+ * (#2260) so it only fires for the boolean shape it hardcodes a `func()
53
+ * bool { ... }()` wrapper for; a string/number-typed getter falls through
54
+ * to the caller's normal handling instead of emitting invalid Go. */
55
+ function isBooleanTypedGetter(
56
+ ctx: GoEmitContext,
57
+ name: string,
58
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
59
+ ): boolean {
60
+ const signal = signals.find(s => s.getter === name)
61
+ if (signal) return signal.type !== undefined && isBooleanTypeInfo(signal.type)
62
+ const memo = (ctx.state.currentMemos ?? []).find(m => m.name === name)
63
+ return memo?.type !== undefined && isBooleanTypeInfo(memo.type)
64
+ }
65
+
38
66
  /** A `props.X` member access → the prop name, else null. */
39
67
  function propsMemberName(e: ParsedExpr): string | null {
40
68
  return e.kind === 'member' &&
@@ -45,6 +73,25 @@ function propsMemberName(e: ParsedExpr): string | null {
45
73
  : null
46
74
  }
47
75
 
76
+ /**
77
+ * A prop reference resolved against the component's ACTUAL props-object
78
+ * binding (`ctx.state.propsObjectName` — may be a non-`props` name, or
79
+ * `null` for a destructured signature), else null. Mirrors
80
+ * `collectPresenceCheckedPropNames`'s (prop-types.ts) exact shape so the
81
+ * presence-check codegen branch below can't drift from the collector that
82
+ * decides which props got the nillable flip in the first place — unlike
83
+ * `propsMemberName` (hardcoded to the literal name `props`), which only
84
+ * matches the conventional object-props signature.
85
+ */
86
+ function propNameForPropsBinding(ctx: GoEmitContext, e: ParsedExpr): string | null {
87
+ const propsObject = ctx.state.propsObjectName
88
+ if (e.kind === 'member' && !e.computed && e.object.kind === 'identifier' && e.object.name === propsObject) {
89
+ return e.property
90
+ }
91
+ if (!propsObject && e.kind === 'identifier') return e.name
92
+ return null
93
+ }
94
+
48
95
  /** A `() => props.X.filter((p) => <predicate>)` match: the array prop name,
49
96
  * the predicate serialized to the runtime evaluator's ParsedExpr JSON, the
50
97
  * arrow's param name, and the free variable names its predicate captures.
@@ -56,7 +103,7 @@ function propsMemberName(e: ParsedExpr): string | null {
56
103
  export function matchFilterArmMemo(
57
104
  ctx: GoEmitContext,
58
105
  body: ParsedExpr,
59
- signals: { getter: string; initialValue: string }[],
106
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
60
107
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
61
108
  ): { propName: string; predJSON: string; paramName: string; freeVars: string[] } | null {
62
109
  const cb = asCallbackMethodCall(body)
@@ -93,7 +140,7 @@ export function matchFilterArmMemo(
93
140
  export function filterArmEarlierSiblingRefs(
94
141
  ctx: GoEmitContext,
95
142
  memo: { name: string; parsed?: ParsedExpr },
96
- signals: { getter: string; initialValue: string }[],
143
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
97
144
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
98
145
  ): string[] {
99
146
  if (!memo.parsed) return []
@@ -126,7 +173,7 @@ export function filterArmEarlierSiblingRefs(
126
173
  export function computeMemoInitialValue(
127
174
  ctx: GoEmitContext,
128
175
  memo: { name: string; computation: string; deps: string[]; parsed?: ParsedExpr },
129
- signals: { getter: string; initialValue: string }[],
176
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
130
177
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
131
178
  propFallbackVars: ReadonlyMap<string, PropFallbackVar> = EMPTY_PROP_FALLBACK_VARS,
132
179
  goType?: string,
@@ -168,7 +215,7 @@ export function computeMemoInitialValue(
168
215
  export function memoInitialFromParsedBody(
169
216
  ctx: GoEmitContext,
170
217
  body: ParsedExpr,
171
- signals: { getter: string; initialValue: string }[],
218
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
172
219
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
173
220
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
174
221
  currentMemoName: string,
@@ -310,6 +357,42 @@ export function memoInitialFromParsedBody(
310
357
  if (propName) return propRef(propName)
311
358
  }
312
359
 
360
+ // () => props.X !== undefined (or !=/===/==) — the "controlled component"
361
+ // idiom's `isControlled` memo (#2260). Distinguishing "caller passed a
362
+ // value" from "caller omitted the prop" needs the nillable `interface{}`
363
+ // representation `collectPresenceCheckedPropNames`/`resolvePropGoType`
364
+ // flip `props.X` to; the input field's Go zero value for `interface{}` IS
365
+ // `nil`, so a plain nil-check is exact (no `bf_nullish`-style helper
366
+ // needed — this is presence, not a `??` fallback).
367
+ if (
368
+ body.kind === 'binary' &&
369
+ (body.op === '!==' || body.op === '!=' || body.op === '===' || body.op === '==')
370
+ ) {
371
+ const other =
372
+ body.right.kind === 'identifier' && body.right.name === 'undefined' ? body.left :
373
+ body.left.kind === 'identifier' && body.left.name === 'undefined' ? body.right : null
374
+ const propName = other ? propNameForPropsBinding(ctx, other) : null
375
+ if (propName) {
376
+ const param = propsParams.find(p => p.name === propName)
377
+ // Guard on the field ACTUALLY being nillable-flipped — a presence
378
+ // check against a CONCRETE-typed field (a required prop, or an
379
+ // optional one this memo's shape didn't trigger the flip for, e.g.
380
+ // via `collectPresenceCheckedPropNames`'s primitive-only /
381
+ // no-default gate) can't be tested this way (`x != nil` doesn't even
382
+ // compile against a `string`/`bool` field) — fall through to the
383
+ // caller's zero-value default instead of emitting invalid Go. Reads
384
+ // the RAW `in.<Field>` directly — NOT through `propRef`, which
385
+ // prefers a hoisted `?? <fallback>` local when one exists: that local
386
+ // already collapsed "absent" into the fallback value at hoist time,
387
+ // so it's never nil and testing it here would be nonsensical (and,
388
+ // being concretely typed, wouldn't compile against `nil` either).
389
+ if (param && ctx.state.nillablePropNames.has(propName)) {
390
+ const isNe = body.op === '!==' || body.op === '!='
391
+ return `in.${capitalizeFieldName(propName)} ${isNe ? '!=' : '=='} nil`
392
+ }
393
+ }
394
+ }
395
+
313
396
  // () => cond() ? A : B where each branch is a module string const or a
314
397
  // string literal, and `cond` is a signal/memo this resolver can evaluate.
315
398
  if (body.kind === 'conditional') {
@@ -350,6 +433,37 @@ export function memoInitialFromParsedBody(
350
433
  }
351
434
  }
352
435
  }
436
+
437
+ // () => cond() ? branchA() : branchB() — a ternary whose CONDITION and
438
+ // BOTH branches are getter calls (signals/memos), not string literals
439
+ // (#2260's `isPressed = isControlled() ? controlledPressed() :
440
+ // internalPressed()`). `resolveGetterValueAsGo` resolves each — a
441
+ // presence-check memo like `isControlled` above already yields a Go
442
+ // `bool` expression, so the condition needs no extra wrapping.
443
+ //
444
+ // GATED to both branches being boolean-typed signals/memos
445
+ // (`isBooleanTypedGetter`) — `getterCallName` only checks call SHAPE,
446
+ // not type, so an ungated version would also match a derived
447
+ // string/number memo (`label = isActive() ? activeLabel() :
448
+ // inactiveLabel()`) and hardcode an invalid `func() bool { return
449
+ // "..." }()` (Copilot review finding on the initial version of this
450
+ // branch).
451
+ if (condName) {
452
+ const tName = getterCallName(body.consequent)
453
+ const fName = getterCallName(body.alternate)
454
+ if (
455
+ tName && fName &&
456
+ isBooleanTypedGetter(ctx, tName, signals) &&
457
+ isBooleanTypedGetter(ctx, fName, signals)
458
+ ) {
459
+ const condGo = resolveGetterValueAsGo(ctx, condName, signals, propsParams, propFallbackVars, resolving)
460
+ const tGo = resolveGetterValueAsGo(ctx, tName, signals, propsParams, propFallbackVars, resolving)
461
+ const fGo = resolveGetterValueAsGo(ctx, fName, signals, propsParams, propFallbackVars, resolving)
462
+ if (condGo !== null && tGo !== null && fGo !== null) {
463
+ return `func() bool { if ${condGo} { return ${tGo} }; return ${fGo} }()`
464
+ }
465
+ }
466
+ }
353
467
  }
354
468
 
355
469
  // () => <ref> <*|+|-|/> <non-negative int>. The operand must be a
@@ -473,7 +587,7 @@ export function memoInitialFromParsedBody(
473
587
  export function computeMemoInitialValueOrNull(
474
588
  ctx: GoEmitContext,
475
589
  memo: { name: string; computation: string; deps: string[]; parsed?: ParsedExpr; parsedBlock?: ParsedStatement[]; parsedBlockComplete?: boolean },
476
- signals: { getter: string; initialValue: string }[],
590
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
477
591
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
478
592
  propFallbackVars: ReadonlyMap<string, PropFallbackVar> = EMPTY_PROP_FALLBACK_VARS,
479
593
  /**
@@ -555,14 +669,14 @@ export function computeMemoInitialValueOrNull(
555
669
  export function resolveGetterValueAsGo(
556
670
  ctx: GoEmitContext,
557
671
  name: string,
558
- signals: { getter: string; initialValue: string }[],
672
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
559
673
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
560
674
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
561
675
  resolving: ReadonlySet<string> = new Set(),
562
676
  ): string | null {
563
677
  const signal = signals.find(s => s.getter === name)
564
678
  if (signal) {
565
- return getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars)
679
+ return getSignalInitialValueAsGo(ctx, signal.initialValue, propsParams, propFallbackVars, signal.type)
566
680
  }
567
681
  const memo = (ctx.state.currentMemos ?? []).find(m => m.name === name)
568
682
  if (memo) {
@@ -598,7 +712,7 @@ export function resolveGetterValueAsGo(
598
712
  export function computeComparisonTernaryGo(
599
713
  ctx: GoEmitContext,
600
714
  parsed: ParsedExpr | undefined,
601
- signals: { getter: string; initialValue: string }[],
715
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
602
716
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
603
717
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
604
718
  resolving: ReadonlySet<string> = new Set(),
@@ -654,7 +768,7 @@ export function computeComparisonTernaryGo(
654
768
  export function resolveComparisonOperandGo(
655
769
  ctx: GoEmitContext,
656
770
  node: ParsedExpr,
657
- signals: { getter: string; initialValue: string }[],
771
+ signals: { getter: string; initialValue: string; type?: TypeInfo }[],
658
772
  propsParams: { name: string; type?: TypeInfo; defaultValue?: string }[],
659
773
  propFallbackVars: ReadonlyMap<string, PropFallbackVar>,
660
774
  resolving: ReadonlySet<string> = new Set(),
@@ -148,13 +148,19 @@ export function collectNullishConsumedPropNames(ctx: GoEmitContext, ir: Componen
148
148
  const propsObject = ctx.state.propsObjectName
149
149
  const propNameOfLeft = (left: ParsedExpr): string | null => {
150
150
  if (left.kind === 'identifier') return left.name
151
- if (
152
- left.kind === 'member' &&
153
- !left.computed &&
154
- left.object.kind === 'identifier' &&
155
- left.object.name === propsObject
156
- ) {
157
- return left.property
151
+ if (left.kind === 'member' && !left.computed && left.object.kind === 'identifier') {
152
+ if (left.object.name === propsObject) return left.property
153
+ // Single-hop member access rooted at a bare destructured optional
154
+ // OBJECT prop (`user?.name ?? ''`, #2256): the flip belongs to the
155
+ // ROOT prop (`user`) — an optional-object prop already lowers to a
156
+ // nillable `map[string]interface{}` by construction
157
+ // (`resolvePropGoType`'s struct-map branch), so it's the root's
158
+ // membership here (not the accessed field) that lets
159
+ // `nillablePropNameOf` route the `??` to `bf_nullish`. A deeper
160
+ // chain (`props.user?.name`, `user?.address?.city`) isn't matched —
161
+ // same single-hop `?.` caveat documented on the `member` ParsedExpr
162
+ // variant.
163
+ return left.object.name
158
164
  }
159
165
  return null
160
166
  }
@@ -252,6 +258,126 @@ export function collectOmittableAttrConsumedPropNames(ctx: GoEmitContext, ir: Co
252
258
  return names
253
259
  }
254
260
 
261
+ /**
262
+ * Names of OPTIONAL no-default props consumed as the BARE value of a
263
+ * TEXT-position expression (`{size}` / `{props.size}`) anywhere in the
264
+ * component's element tree.
265
+ *
266
+ * Why this matters (#2267): with no flip, an absent `size?: number` prop
267
+ * resolves to a concrete `int` field and `{{.Size}}` prints its zero value
268
+ * (`0`) instead of empty — the JS reference renders `undefined` as "".
269
+ * These props take the same `interface{}` flip as `??`/omittable-attribute
270
+ * consumption (`resolvePropGoType`); `renderExpression`'s emitter then
271
+ * routes the flipped field through `bf_string` (nil-safe stringify)
272
+ * instead of a bare `{{.X}}`, since `text/template` prints a nil
273
+ * `interface{}` as the literal `<no value>`, not "".
274
+ *
275
+ * Generic recursive walk (matching `type: 'expression'` ANYWHERE, like
276
+ * `collectNullishConsumedPropNames`) rather than only `element.children` —
277
+ * a fragment return (`<>{size}</>`) or a conditional branch's children
278
+ * would otherwise be missed. Same shadowing caveat as the sibling
279
+ * collectors: a same-named loop/callback param can misattribute, making
280
+ * the flip merely unnecessary, not incorrect.
281
+ *
282
+ * Filtered to `kind: 'primitive'` — unlike the `??`/omittable-attribute
283
+ * collectors (whose consumers, `bf_nullish` and the `{{if ne .X nil}}`
284
+ * guard, are nil-test-only and pass the underlying value through
285
+ * untouched), this collector's consumer (`renderExpression`'s `bf_string`
286
+ * wrap) RESTRINGIFIES the value via `fmt.Sprintf`. An optional prop
287
+ * carrying already-rendered markup (a JSX-element/children prop) also
288
+ * independently resolves to `interface{}` via `resolvePropGoType`'s
289
+ * struct-map branch — routing THAT through `bf_string` would strip its
290
+ * `template.HTML` safe-markup typing, so `text/template` re-escapes
291
+ * already-escaped HTML on print (observed as literal `&lt;div&gt;...`).
292
+ * Restricting to primitives keeps this collector disjoint from that flip.
293
+ */
294
+ export function collectTextConsumedPropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
295
+ const names = new Set<string>()
296
+ const optionalParams = new Set(
297
+ ir.metadata.propsParams
298
+ .filter(p => p.optional && p.defaultValue == null && p.type.kind === 'primitive')
299
+ .map(p => p.name),
300
+ )
301
+ if (optionalParams.size === 0) return names
302
+
303
+ const propsObject = ctx.state.propsObjectName
304
+ const walk = (node: unknown): void => {
305
+ if (!node || typeof node !== 'object') return
306
+ if (Array.isArray(node)) {
307
+ for (const item of node) walk(item)
308
+ return
309
+ }
310
+ const rec = node as Record<string, unknown>
311
+ if (rec.type === 'expression') {
312
+ const bareId = String(rec.expr ?? '').trim()
313
+ const propName =
314
+ propsObject && bareId.startsWith(`${propsObject}.`)
315
+ ? bareId.slice(propsObject.length + 1)
316
+ : bareId
317
+ if (/^[A-Za-z_$][\w$]*$/.test(propName) && optionalParams.has(propName)) {
318
+ names.add(propName)
319
+ }
320
+ }
321
+ for (const value of Object.values(rec)) walk(value)
322
+ }
323
+ walk(ir.root)
324
+ return names
325
+ }
326
+
327
+ /**
328
+ * Names of OPTIONAL no-default props whose PRESENCE is tested — `props.X
329
+ * !== undefined` / `!= undefined` (or the `===`/`==` negation) — anywhere in
330
+ * a memo's computation (#2260). This is the "controlled component" idiom's
331
+ * `isControlled` check (`createMemo(() => props.pressed !== undefined)`):
332
+ * distinguishing "caller passed a value" from "caller omitted the prop" is
333
+ * only expressible on the nillable `interface{}` representation (a
334
+ * concrete `bool` field can't tell `pressed={false}` from omitted `pressed`
335
+ * apart — both are the Go zero value `false`), so these props take the
336
+ * same `interface{}` flip as `??`/attribute/text consumption
337
+ * (`resolvePropGoType`). `memoInitialFromParsedBody`'s presence-check
338
+ * branch then lowers the comparison to `in.X != nil`.
339
+ *
340
+ * Scoped to `ir.metadata.memos[].parsed` (not the generic tree walk the
341
+ * sibling collectors use) — a presence check is a memo-computation shape,
342
+ * never a JSX child/attribute expression.
343
+ */
344
+ export function collectPresenceCheckedPropNames(ctx: GoEmitContext, ir: ComponentIR): Set<string> {
345
+ const names = new Set<string>()
346
+ const optionalParams = new Set(
347
+ ir.metadata.propsParams
348
+ .filter(p => p.optional && p.defaultValue == null && p.type.kind === 'primitive')
349
+ .map(p => p.name),
350
+ )
351
+ if (optionalParams.size === 0) return names
352
+
353
+ const propsObject = ctx.state.propsObjectName
354
+ const isUndefinedCheck = (op: string): boolean =>
355
+ op === '!==' || op === '!=' || op === '===' || op === '=='
356
+
357
+ for (const memo of ir.metadata.memos) {
358
+ const body = memo.parsed
359
+ if (!body || body.kind !== 'binary' || !isUndefinedCheck(body.op)) continue
360
+ const other = body.right.kind === 'identifier' && body.right.name === 'undefined'
361
+ ? body.left
362
+ : body.left.kind === 'identifier' && body.left.name === 'undefined'
363
+ ? body.right
364
+ : null
365
+ if (!other) continue
366
+ // Object-props style (`props.X`) — `other` is a member access rooted at
367
+ // the props object. Destructured style (`X` bare) — `other` is a bare
368
+ // identifier naming the prop directly.
369
+ const propName =
370
+ other.kind === 'member' && !other.computed &&
371
+ other.object.kind === 'identifier' && other.object.name === propsObject
372
+ ? other.property
373
+ : !propsObject && other.kind === 'identifier'
374
+ ? other.name
375
+ : null
376
+ if (propName && optionalParams.has(propName)) names.add(propName)
377
+ }
378
+ return names
379
+ }
380
+
255
381
  /**
256
382
  * Resolve a prop param's Go struct-field type using the SAME logic
257
383
  * `generatePropsStruct` / `generateInputStruct` use: a `propTypeOverrides` entry
@@ -298,12 +424,18 @@ export function resolvePropGoType(
298
424
  // documented pre-#2248 trade-off there.)
299
425
  // A bare-attribute consumption (`rows={rows}`) takes the same flip (#2259):
300
426
  // attribute omission for an absent optional needs a nil to test — see
301
- // `collectOmittableAttrConsumedPropNames`.
427
+ // `collectOmittableAttrConsumedPropNames`. A bare TEXT-position
428
+ // consumption (`{size}`) takes the same flip too (#2267) — see
429
+ // `collectTextConsumedPropNames`. A presence check (`props.X !==
430
+ // undefined`, the "controlled component" idiom's `isControlled` memo)
431
+ // takes the same flip too (#2260) — see `collectPresenceCheckedPropNames`.
302
432
  if (
303
433
  param.optional &&
304
434
  param.type.kind === 'primitive' &&
305
435
  (ctx.state.nullishConsumedPropNames.has(param.name) ||
306
- ctx.state.omittableAttrConsumedPropNames.has(param.name)) &&
436
+ ctx.state.omittableAttrConsumedPropNames.has(param.name) ||
437
+ ctx.state.textConsumedPropNames.has(param.name) ||
438
+ ctx.state.presenceCheckedPropNames.has(param.name)) &&
307
439
  NULLISH_SCALAR_GO_TYPES.has(base)
308
440
  ) {
309
441
  return 'interface{}'
@@ -15,6 +15,45 @@ import { parsedLiteralToGo } from './parsed-literal-to-go.ts'
15
15
  /** Default for `getSignalInitialValueAsGo`'s optional fallback-var map. */
16
16
  const EMPTY_PROP_FALLBACK_VARS: ReadonlyMap<string, PropFallbackVar> = new Map()
17
17
 
18
+ /**
19
+ * A bare prop-field reference (`in.<Field>`), type-asserted when the prop
20
+ * was flipped to nillable `interface{}` (#2248/#2259/#2260's
21
+ * `resolvePropGoType` flips) while THE CONSUMER's own expected type is a
22
+ * concrete scalar — e.g. `createSignal<boolean | undefined>(props.pressed)`
23
+ * resolves to a plain `bool` signal field (the `| undefined` half doesn't
24
+ * itself trigger a flip), but `props.pressed` now bakes as `interface{}`.
25
+ * A bare `interface{}` value can't assign into a `bool` field/branch (Go
26
+ * compile error) — safely type-assert with a zero-value fallback for the
27
+ * concrete-scalar case instead of the bare field reference. Object/array
28
+ * expected types are left alone (already `interface{}`-compatible).
29
+ *
30
+ * `expectedType` may be `kind: 'union'` — a `T | undefined` signal type
31
+ * annotation (the controlled-component idiom's controlled signal) — the `|
32
+ * undefined` half is source-level documentation of nullability, not a
33
+ * Go-representable branch, so it's unwrapped to its single non-
34
+ * undefined/null primitive branch.
35
+ */
36
+ function nillableAwarePropRef(ctx: GoEmitContext, propName: string, expectedType: TypeInfo): string {
37
+ const fieldRef = `in.${capitalizeFieldName(propName)}`
38
+ const scalar =
39
+ expectedType.kind === 'primitive'
40
+ ? expectedType
41
+ : expectedType.kind === 'union' && expectedType.unionTypes?.length === 2
42
+ ? expectedType.unionTypes.find(t => t.primitive !== 'undefined' && t.primitive !== 'null')
43
+ : undefined
44
+ if (ctx.state.nillablePropNames.has(propName) && scalar?.kind === 'primitive') {
45
+ const goType =
46
+ scalar.primitive === 'boolean' ? 'bool' :
47
+ scalar.primitive === 'number' ? 'float64' :
48
+ scalar.primitive === 'string' ? 'string' : null
49
+ if (goType) {
50
+ const zero = goType === 'bool' ? 'false' : goType === 'string' ? '""' : '0'
51
+ return `func() ${goType} { if v, ok := ${fieldRef}.(${goType}); ok { return v }; return ${zero} }()`
52
+ }
53
+ }
54
+ return fieldRef
55
+ }
56
+
18
57
  /**
19
58
  * Lower a signal/const initial value to its Go SSR literal: a prop reference
20
59
  * becomes `in.<Field>`, a non-literal falls back to the type's zero value.
@@ -26,15 +65,17 @@ export function convertInitialValue(
26
65
  propsParams?: { name: string }[],
27
66
  preParsed?: ParsedExpr,
28
67
  ): string {
68
+ const propRef = (propName: string): string => nillableAwarePropRef(ctx, propName, typeInfo)
69
+
29
70
  if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)) {
30
71
  if (propsParams?.some(p => p.name === value)) {
31
- return `in.${capitalizeFieldName(value)}`
72
+ return propRef(value)
32
73
  }
33
74
  }
34
75
 
35
76
  const propName = ctx.extractPropNameFromInitialValue(value, preParsed)
36
77
  if (propName && propsParams?.some(p => p.name === propName)) {
37
- return `in.${capitalizeFieldName(propName)}`
78
+ return propRef(propName)
38
79
  }
39
80
 
40
81
  if (typeInfo.kind === 'primitive') {
@@ -148,24 +189,36 @@ export function objectLiteralToGoMap(ctx: GoEmitContext, expr: ParsedExpr): stri
148
189
  * Get a signal's initial value as Go code — a literal, or a props reference
149
190
  * (`in.<Field>`, or the hoisted fallback var when `props.X ?? N` has one).
150
191
  * Unrecognized values default to `0`.
192
+ *
193
+ * `signalType`, when passed, drives the same nillable-prop type-assertion
194
+ * `convertInitialValue` applies (#2260) — a caller resolving a getter as the
195
+ * operand of a boolean condition/ternary branch (`resolveGetterValueAsGo`)
196
+ * needs a concrete-typed result, not a bare `interface{}` field reference,
197
+ * when the referenced prop was flipped to nillable. Omitted by call sites
198
+ * that splice the result into an `interface{}`-typed context (e.g. a
199
+ * `map[string]any{...}` env entry), where the bare reference is fine.
151
200
  */
152
201
  export function getSignalInitialValueAsGo(
153
202
  ctx: GoEmitContext,
154
203
  initialValue: string,
155
204
  propsParams: { name: string }[],
156
205
  propFallbackVars: ReadonlyMap<string, PropFallbackVar> = EMPTY_PROP_FALLBACK_VARS,
206
+ signalType?: TypeInfo,
157
207
  ): string {
208
+ const propRef = (propName: string): string =>
209
+ signalType ? nillableAwarePropRef(ctx, propName, signalType) : `in.${capitalizeFieldName(propName)}`
210
+
158
211
  if (propsParams.some(p => p.name === initialValue)) {
159
212
  const hoisted = propFallbackVars.get(initialValue)
160
213
  if (hoisted) return hoisted.varName
161
- return `in.${capitalizeFieldName(initialValue)}`
214
+ return propRef(initialValue)
162
215
  }
163
216
 
164
217
  const propName = ctx.extractPropNameFromInitialValue(initialValue)
165
218
  if (propName && propsParams.some(p => p.name === propName)) {
166
219
  const hoisted = propFallbackVars.get(propName)
167
220
  if (hoisted) return hoisted.varName
168
- return `in.${capitalizeFieldName(propName)}`
221
+ return propRef(propName)
169
222
  }
170
223
 
171
224
  // single quotes are normalized to Go double quotes