@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.
- package/dist/adapter/emit-context.d.ts +11 -5
- package/dist/adapter/emit-context.d.ts.map +1 -1
- package/dist/adapter/go-template-adapter.d.ts +37 -4
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +175 -21
- package/dist/adapter/lib/compile-state.d.ts +16 -0
- package/dist/adapter/lib/compile-state.d.ts.map +1 -1
- package/dist/adapter/lib/types.d.ts +9 -0
- package/dist/adapter/lib/types.d.ts.map +1 -1
- package/dist/adapter/props/prop-types.d.ts +45 -0
- package/dist/adapter/props/prop-types.d.ts.map +1 -1
- package/dist/build.js +175 -21
- package/dist/index.js +175 -21
- package/package.json +3 -3
- package/src/__tests__/go-template-adapter.test.ts +169 -18
- package/src/adapter/emit-context.ts +14 -5
- package/src/adapter/go-template-adapter.ts +185 -20
- package/src/adapter/lib/compile-state.ts +18 -0
- package/src/adapter/lib/types.ts +9 -0
- package/src/adapter/props/prop-types.ts +178 -1
- package/src/adapter/value/value-lowering.ts +1 -1
- package/src/test-render.ts +18 -4
|
@@ -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
|
-
//
|
|
510
|
-
|
|
511
|
-
expect(types).
|
|
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
|
-
|
|
557
|
-
|
|
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
|
|
566
|
-
// Bool-true falls through the same hoist path as int / string
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
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('
|
|
584
|
-
expect(types).
|
|
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
|
|
983
|
-
//
|
|
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
|
|
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
|
|
3635
|
-
|
|
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
|
-
/**
|
|
45
|
-
|
|
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>`
|
|
49
|
-
*
|
|
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(
|
|
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) =>
|
|
228
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
2746
|
-
|
|
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, {
|
|
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>`
|
|
2761
|
-
*
|
|
2762
|
-
*
|
|
2763
|
-
* the fallback isn't a simple literal `goPropDefault`
|
|
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(
|
|
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)
|
|
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
|
package/src/adapter/lib/types.ts
CHANGED
|
@@ -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
|
/**
|