@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.
@@ -301,10 +301,17 @@ export function P(props: { items: { id: string }[] }) {
301
301
  `
302
302
  const { template } = generate(src)
303
303
  expect(template).not.toContain('len .Visible')
304
- // The loop over visible() is handler-filled as `.Rows`; the count reuses it.
304
+ expect(template).not.toContain('bf_length .Visible')
305
+ // The loop over visible() is handler-filled as `.Rows`; this is one of
306
+ // the specialized ARRAY-only `.length` shapes (memo-backed loop slice
307
+ // count) that stays on native `len` even after #2255 — see the
308
+ // `member()` docstring on the generic `bf_length` fallback.
305
309
  expect(template).toContain('len .Rows')
306
- // props.items.length is unaffected.
307
- expect(template).toContain('len .Items')
310
+ // props.items.length is the GENERIC (non-specialized) `.length`
311
+ // fallback, which #2255 routed through `bf_length` (UTF-16-aware for
312
+ // strings, and functionally equivalent to `len` for an array/slice —
313
+ // both dispatch on the underlying value's shape).
314
+ expect(template).toContain('bf_length .Items')
308
315
  })
309
316
  })
310
317
 
@@ -119,37 +119,7 @@ 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
+ skipDataPoints: new Set<string>(),
153
123
  onRenderError: (err, id) => {
154
124
  if (err instanceof GoNotAvailableError) {
155
125
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -1301,10 +1271,15 @@ export function Widget(props: P) {
1301
1271
  expect(types).toContain('Classes: "a b" + " " + "c d" + " " + in.ClassName + " tail"')
1302
1272
  })
1303
1273
 
1304
- // A boolean ternary memo (`isChecked = ctrl() ? c() : i()`) renders its
1305
- // SSR zero as `false`, not the int `0`, so `aria-checked={isChecked()}`
1306
- // matches Hono's `aria-checked="false"`.
1307
- test('boolean ternary memo defaults to false, not 0', () => {
1274
+ // A boolean ternary memo (`isChecked = ctrl() ? c() : i()`) types its
1275
+ // SSR field as `bool` (not `int`), so `aria-checked={isChecked()}`
1276
+ // matches Hono's `aria-checked="false"` shape. Since #2260, `checked`'s
1277
+ // presence is expressible (nillable `interface{}`), so the constructor
1278
+ // bakes a runtime presence check + type-asserted read instead of the
1279
+ // pre-#2260 unconditional `false` — see the `Controlled/derived
1280
+ // boolean props honour a caller-supplied true` describe block below for
1281
+ // the caller-supplied-`true` case this unlocks.
1282
+ test('boolean ternary memo types as bool, resolves controlled presence at SSR', () => {
1308
1283
  const adapter = new GoTemplateAdapter()
1309
1284
  const source = `
1310
1285
  "use client"
@@ -1319,7 +1294,9 @@ export function Toggle(props: { checked?: boolean; defaultChecked?: boolean }) {
1319
1294
  `
1320
1295
  const types = adapter.generateTypes(compileToIR(source, adapter))!
1321
1296
  expect(types).toContain('IsChecked bool')
1322
- expect(types).toContain('IsChecked: false,')
1297
+ expect(types).toContain(
1298
+ 'IsChecked: func() bool { if in.Checked != nil { return func() bool { if v, ok := in.Checked.(bool); ok { return v }; return false }() }; return in.DefaultChecked }(),',
1299
+ )
1323
1300
  })
1324
1301
  })
1325
1302
 
@@ -4720,3 +4697,134 @@ export function List() {
4720
4697
  }
4721
4698
  })
4722
4699
  })
4700
+
4701
+ // #2254: `??` in CONDITION position (ternaries/`{{if}}` via
4702
+ // `convertConditionToGo` → `renderConditionExpr`'s `logical` case) still
4703
+ // emitted Go's truthiness-based `or` even for a nillable-lowered prop, so a
4704
+ // present-but-empty (`''`) value wrongly fell back to the `??` default —
4705
+ // diverging from JS, where `??` only falls back on null/undefined. The
4706
+ // sibling `logical()` emitter (used for non-condition expression positions,
4707
+ // e.g. plain interpolation) already routed nillable operands through
4708
+ // `bf_nullish` since #2248/#2252; this brings the condition-position
4709
+ // emitter in line with it.
4710
+ describe('GoTemplateAdapter - #2254 `??` in condition position on a nillable prop', () => {
4711
+ test('ternary test comparing `props.label ?? "Default"` lowers to bf_nullish, not or', () => {
4712
+ const adapter = new GoTemplateAdapter()
4713
+ const result = compileJSX(`
4714
+ "use client"
4715
+ type Props = { label?: string }
4716
+ export function C(props: Props) {
4717
+ return <div>{(props.label ?? 'Default') === 'Default' ? <span>fallback</span> : <span>set</span>}</div>
4718
+ }
4719
+ `.trimStart(), 'test.tsx', { adapter })
4720
+ expect(result.errors ?? []).toEqual([])
4721
+ const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
4722
+ expect(template).toContain('bf_nullish .Label "Default"')
4723
+ expect(template).not.toContain('or .Label "Default"')
4724
+ })
4725
+
4726
+ test('end-to-end via real `go run`: a present-but-empty label does NOT take the ?? default branch', async () => {
4727
+ try {
4728
+ const html = await renderGoTemplateComponent({
4729
+ source: `
4730
+ 'use client'
4731
+ type Props = { label?: string }
4732
+ export function C(props: Props) {
4733
+ return <div>{(props.label ?? 'Default') === 'Default' ? <span>fallback</span> : <span>set</span>}</div>
4734
+ }
4735
+ `,
4736
+ adapter: new GoTemplateAdapter(),
4737
+ props: { label: '' },
4738
+ })
4739
+ // JS: '' ?? 'Default' keeps '' (present, not nullish) → '' === 'Default'
4740
+ // is false → the "set" branch renders. The pre-fix `or` lowering would
4741
+ // truthiness-fallback the empty string to 'Default' and wrongly render
4742
+ // "fallback" instead.
4743
+ expect(html).toContain('>set</span>')
4744
+ expect(html).not.toContain('>fallback</span>')
4745
+ } catch (err) {
4746
+ if (err instanceof GoNotAvailableError) {
4747
+ console.log('Skipping #2254 e2e: go command not found')
4748
+ return
4749
+ }
4750
+ throw err
4751
+ }
4752
+ })
4753
+
4754
+ test('an omitted label DOES take the ?? default branch', async () => {
4755
+ try {
4756
+ const html = await renderGoTemplateComponent({
4757
+ source: `
4758
+ 'use client'
4759
+ type Props = { label?: string }
4760
+ export function C(props: Props) {
4761
+ return <div>{(props.label ?? 'Default') === 'Default' ? <span>fallback</span> : <span>set</span>}</div>
4762
+ }
4763
+ `,
4764
+ adapter: new GoTemplateAdapter(),
4765
+ props: {},
4766
+ })
4767
+ expect(html).toContain('>fallback</span>')
4768
+ expect(html).not.toContain('>set</span>')
4769
+ } catch (err) {
4770
+ if (err instanceof GoNotAvailableError) {
4771
+ console.log('Skipping #2254 e2e: go command not found')
4772
+ return
4773
+ }
4774
+ throw err
4775
+ }
4776
+ })
4777
+ })
4778
+
4779
+ // Review finding on #2271 (the #2254 fix): `renderConditionExpr`'s `logical`
4780
+ // case wrapped operands via `needsParens` (AST-kind-based — only
4781
+ // `logical`/`unary`/`conditional`), missing any OTHER multi-token rendering
4782
+ // (`len .X`, `bf_add a b`, a call). Unwrapped, Go's `and`/`or`/`bf_nullish`
4783
+ // (all prefix builtins) parse a multi-token operand as extra sibling args
4784
+ // instead of one operand. Switched to `wrapIfMultiToken` (whitespace-based,
4785
+ // on the rendered string), matching the main `logical()` emitter.
4786
+ describe('GoTemplateAdapter - condition-position logical operand wrapping (#2271 review)', () => {
4787
+ test('a `.length` (member → "len .X") operand of `&&` inside a ternary TEST is parenthesized', () => {
4788
+ // A bare `{cond && <Elem/>}` is recognized as JSX conditional-rendering
4789
+ // shorthand and its test is extracted directly, never reaching
4790
+ // `renderConditionExpr`'s `logical` case as a literal `&&` node — so the
4791
+ // `&&` must be nested inside an explicit ternary test to exercise it.
4792
+ const adapter = new GoTemplateAdapter()
4793
+ const result = compileJSX(`
4794
+ "use client"
4795
+ type Item = { id: number }
4796
+ export function C({ items, enabled }: { items: Item[]; enabled: boolean }) {
4797
+ return <div>{(items.length && enabled) ? <span>on</span> : <span>off</span>}</div>
4798
+ }
4799
+ `.trimStart(), 'test.tsx', { adapter })
4800
+ expect(result.errors ?? []).toEqual([])
4801
+ const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
4802
+ // `and (len .Items) .Enabled` — NOT the broken 3-sibling-arg
4803
+ // `and len .Items .Enabled` a bare `needsParens` miss would produce.
4804
+ expect(template).toContain('and (len .Items) .Enabled')
4805
+ })
4806
+
4807
+ test('end-to-end via real `go run`: the multi-token `&&` ternary-test operand compiles and renders correctly', async () => {
4808
+ try {
4809
+ const html = await renderGoTemplateComponent({
4810
+ source: `
4811
+ 'use client'
4812
+ type Item = { id: number }
4813
+ export function C({ items, enabled }: { items: Item[]; enabled: boolean }) {
4814
+ return <div>{(items.length && enabled) ? <span>on</span> : <span>off</span>}</div>
4815
+ }
4816
+ `,
4817
+ adapter: new GoTemplateAdapter(),
4818
+ props: { items: [{ id: 1 }], enabled: true },
4819
+ })
4820
+ expect(html).toContain('>on</span>')
4821
+ expect(html).not.toContain('>off</span>')
4822
+ } catch (err) {
4823
+ if (err instanceof GoNotAvailableError) {
4824
+ console.log('Skipping #2271 review e2e: go command not found')
4825
+ return
4826
+ }
4827
+ throw err
4828
+ }
4829
+ })
4830
+ })
@@ -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, collectNullishConsumedPropNames, collectOmittableAttrConsumedPropNames, NULLISH_SCALAR_GO_TYPES } 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"
@@ -419,6 +419,8 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
419
419
  this.buildLocalTypeTables(ir, ir.metadata.componentName)
420
420
  this.state.nullishConsumedPropNames = collectNullishConsumedPropNames(this.emitCtx, ir)
421
421
  this.state.omittableAttrConsumedPropNames = collectOmittableAttrConsumedPropNames(this.emitCtx, ir)
422
+ this.state.textConsumedPropNames = collectTextConsumedPropNames(this.emitCtx, ir)
423
+ this.state.presenceCheckedPropNames = collectPresenceCheckedPropNames(this.emitCtx, ir)
422
424
  this.state.nillablePropNames = collectNillablePropNames(this.emitCtx, ir)
423
425
  }
424
426
 
@@ -3093,13 +3095,52 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3093
3095
  return goExpr
3094
3096
  }
3095
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
+
3096
3110
  // Mark expressions with slotId using comment nodes for client JS to find.
3097
3111
  // This includes reactive expressions AND loop-param-dependent expressions.
3098
3112
  if (expr.slotId) {
3099
- return `{{bfTextStart "${expr.slotId}"}}{{${goExpr}}}{{bfTextEnd}}`
3113
+ return `{{bfTextStart "${expr.slotId}"}}{{${finalExpr}}}{{bfTextEnd}}`
3100
3114
  }
3101
3115
 
3102
- 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
3103
3144
  }
3104
3145
 
3105
3146
  /**
@@ -3512,7 +3553,13 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3512
3553
  }
3513
3554
 
3514
3555
  const obj = emit(object)
3515
- 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)}`
3516
3563
  // A `?.`-written access (`user?.name`, #2168 optional-chaining-prop):
3517
3564
  // a plain `.Field` dot-chain panics evaluating a field on a nil
3518
3565
  // interface/pointer (`nil pointer evaluating interface {}.Name`), so
@@ -3679,13 +3726,19 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3679
3726
  let name: string | null = null
3680
3727
  if (expr.kind === 'identifier') {
3681
3728
  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
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
+ }
3689
3742
  }
3690
3743
  return name !== null &&
3691
3744
  this.state.nullishConsumedPropNames.has(name) &&
@@ -4417,11 +4470,6 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4417
4470
  return this.renderFilterExpr(pred, param, new Map(), datumField ?? undefined)
4418
4471
  }
4419
4472
 
4420
- /** Whether an expression needs parentheses when used in and/or. */
4421
- private needsParens(expr: ParsedExpr): boolean {
4422
- return expr.kind === 'logical' || expr.kind === 'unary' || expr.kind === 'conditional'
4423
- }
4424
-
4425
4473
  /**
4426
4474
  * Split a rendered template block into preamble + final expression.
4427
4475
  * The last `{{...}}` must be a variable reference (`$bf_rN` or
@@ -5100,19 +5148,26 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5100
5148
  if (expr.callee.kind === 'identifier' && expr.args.length === 0) {
5101
5149
  return plain(this.rootFieldRef(expr.callee.name))
5102
5150
  }
5103
- // `isValidElement(x)` — the framework "is this a renderable element?"
5104
- // predicate. In the Go SSR children model an element is represented by
5105
- // its already-rendered markup, so this evaluates faithfully as a
5106
- // truthiness check on the argument (an element is "valid" when there is
5107
- // something to render). Lowering it as a real, evaluatable expression —
5108
- // rather than a fabricated `.IsValidElement` field access is what lets
5109
- // 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`.
5110
5164
  if (
5111
5165
  expr.callee.kind === 'identifier' &&
5112
5166
  (identifierPath(expr.callee) ?? expr.callee.name) === 'isValidElement' &&
5113
5167
  expr.args.length === 1
5114
5168
  ) {
5115
- 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)})` }
5116
5171
  }
5117
5172
  // Any other user-defined predicate call with arguments (e.g.
5118
5173
  // `isAdmin(user)`) has no server-side evaluator and is not a registered
@@ -5252,11 +5307,27 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
5252
5307
  const leftResult = this.renderConditionExpr(expr.left)
5253
5308
  const rightResult = this.renderConditionExpr(expr.right)
5254
5309
  const preamble = leftResult.preamble + rightResult.preamble
5255
- const wrapLeft = this.needsParens(expr.left) ? `(${leftResult.expr})` : leftResult.expr
5256
- 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`.
5257
5326
  const result = expr.op === '&&'
5258
5327
  ? `and ${wrapLeft} ${wrapRight}`
5259
- : `or ${wrapLeft} ${wrapRight}`
5328
+ : expr.op === '??' && this.nillablePropNameOf(expr.left) !== null
5329
+ ? `bf_nullish ${wrapLeft} ${wrapRight}`
5330
+ : `or ${wrapLeft} ${wrapRight}`
5260
5331
  return { preamble, expr: result }
5261
5332
  }
5262
5333
 
@@ -6119,17 +6190,22 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
6119
6190
  for (const e of entries) {
6120
6191
  if (e.kind === 'expr' && !isSupported(parseExpression(e.expr)).supported) return null
6121
6192
  }
6122
- // The static CSS key + literal value are inlined into a double-quoted
6123
- // `style="..."` attribute, so HTML-attr escape them (a value like `'"'`
6124
- // would otherwise terminate the attribute / inject markup). The dynamic
6125
- // arm's `{{…}}` action is escaped by `html/template`'s attribute context.
6126
- return entries
6127
- .map(e =>
6128
- e.kind === 'literal'
6129
- ? `${this.escapeAttrText(e.cssKey)}:${this.escapeAttrText(e.value)}`
6130
- : `${this.escapeAttrText(e.cssKey)}:{{${this.convertExpressionToGo(e.expr)}}}`,
6131
- )
6132
- .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(' ')}}}`
6133
6209
  }
6134
6210
 
6135
6211
  private renderAttributes(element: IRElement): string {
@@ -164,6 +164,24 @@ export class CompileState {
164
164
  */
165
165
  omittableAttrConsumedPropNames: Set<string> = new Set()
166
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
+
167
185
  /**
168
186
  * String-typed signal getter / prop names (#2168 string-concat-plus).
169
187
  * Feeds `isStringName` for `isStringConcatBinary`, which decides whether a