@barefootjs/go-template 0.18.7 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -119,6 +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>(),
122
123
  onRenderError: (err, id) => {
123
124
  if (err instanceof GoNotAvailableError) {
124
125
  console.log(`Skipping [${id}]: ${err.message}`)
@@ -506,9 +507,11 @@ export function Counter(props: { initial?: number }) {
506
507
  expect(result.types).toBeDefined()
507
508
  const types = result.types!
508
509
 
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*\}/)
510
+ // The fallback applies only when the prop is ABSENT: an explicit
511
+ // `Initial: 0` is honoured (JS `0 ?? 99` is `0`, #2248).
512
+ expect(types).toContain('Initial interface{}')
513
+ expect(types).toContain('var initial int = 99')
514
+ expect(types).toMatch(/if in\.Initial != nil \{\s*initial = bf\.ToInt\(in\.Initial\)\s*\}/)
512
515
 
513
516
  // Prop, signal, and memo all reference the hoisted variable.
514
517
  expect(types).toContain('Initial: initial,')
@@ -553,8 +556,11 @@ export function Label(props: { label?: string }) {
553
556
  `)
554
557
  const result = adapter.generate(ir)
555
558
  const types = result.types!
556
- expect(types).toContain('label := in.Label')
557
- expect(types).toMatch(/if label == ""\s*\{\s*label = "Default"\s*\}/)
559
+ // An explicit `Label: ""` input stays empty (JS `'' ?? 'Default'` is
560
+ // `''`, #2248).
561
+ expect(types).toContain('Label interface{}')
562
+ expect(types).toContain('var label string = "Default"')
563
+ expect(types).toMatch(/if in\.Label != nil \{\s*label = in\.Label\.\(string\)\s*\}/)
558
564
  expect(types).toContain('Label: label,')
559
565
  // Signal name `text` differs from prop name `label`, so the
560
566
  // signal field gets its own entry that resolves through the
@@ -562,12 +568,11 @@ export function Label(props: { label?: string }) {
562
568
  expect(types).toContain('Text: label,')
563
569
  })
564
570
 
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.
571
+ test('hoists `props.X ?? true` against nil (#1423 review, #2248)', () => {
572
+ // Bool-true falls through the same hoist path as int / string. The
573
+ // interface{} field is nil only when the caller omitted the prop, so
574
+ // an explicit `Checked: false` survives (JS `false ?? true` is
575
+ // `false`).
571
576
  const adapter = new GoTemplateAdapter()
572
577
  const ir = compileToIR(`
573
578
  "use client"
@@ -580,12 +585,124 @@ export function Check(props: { checked?: boolean }) {
580
585
  `)
581
586
  const result = adapter.generate(ir)
582
587
  const types = result.types!
583
- expect(types).toContain('checked := in.Checked')
584
- expect(types).toMatch(/if checked == false\s*\{\s*checked = true\s*\}/)
588
+ expect(types).toContain('Checked interface{}')
589
+ expect(types).toContain('var checked bool = true')
590
+ expect(types).toMatch(/if in\.Checked != nil \{\s*checked = in\.Checked\.\(bool\)\s*\}/)
585
591
  expect(types).toContain('Checked: checked,')
586
592
  expect(types).toContain('C: checked,')
587
593
  })
588
594
 
595
+ test('hoists the DESTRUCTURED `x ?? N` seed via signal.parsed (#2259)', () => {
596
+ // Destructured components have no `propsObjectName`, so the seed's
597
+ // prop reference is a bare identifier — matched structurally on the
598
+ // signal's ParsedExpr, then routed through the same nullish flip +
599
+ // hoisted-var machinery as `props.size ?? 1` (#2248/#2252 parity).
600
+ const adapter = new GoTemplateAdapter()
601
+ const ir = compileToIR(`
602
+ "use client"
603
+ import { createSignal } from "@barefootjs/client"
604
+
605
+ export function Counter({ size }: { size?: number }) {
606
+ const [count, setCount] = createSignal(size ?? 1)
607
+ return <div>{count()}</div>
608
+ }
609
+ `)
610
+ const result = adapter.generate(ir)
611
+ const types = result.types!
612
+ expect(types).toContain('Size interface{}')
613
+ expect(types).toContain('var size int = 1')
614
+ expect(types).toMatch(/if in\.Size != nil \{\s*size = bf\.ToInt\(in\.Size\)\s*\}/)
615
+ expect(types).toContain('Size: size,')
616
+ expect(types).toContain('Count: size,')
617
+ })
618
+
619
+ test('destructured `x ?? 0` stays concrete and seeds the prop directly (#2259)', () => {
620
+ // A zero-equivalent fallback earns no nillable flip (nil and the Go
621
+ // zero value land on the same output), so the field keeps its scalar
622
+ // type and the signal seeds straight off the input — NOT the literal
623
+ // `0` the pre-#2259 lowering emitted for the unrecognized identifier
624
+ // form.
625
+ const adapter = new GoTemplateAdapter()
626
+ const ir = compileToIR(`
627
+ "use client"
628
+ import { createSignal } from "@barefootjs/client"
629
+
630
+ export function Counter({ size }: { size?: number }) {
631
+ const [count, setCount] = createSignal(size ?? 0)
632
+ return <div>{count()}</div>
633
+ }
634
+ `)
635
+ const result = adapter.generate(ir)
636
+ const types = result.types!
637
+ expect(types).toContain('Size int')
638
+ expect(types).not.toContain('Size interface{}')
639
+ expect(types).toContain('Size: in.Size,')
640
+ expect(types).toContain('Count: in.Size,')
641
+ })
642
+
643
+ test('negative fallback (`?? -1`) hoists in both prop styles (#2259 review)', () => {
644
+ // `-1` parses as unary minus around a number literal, not a literal —
645
+ // the structural seed match must reconstruct the sign, in the
646
+ // props-object form (where it preempts the regex path) and the
647
+ // destructured form alike.
648
+ for (const [params, ref] of [
649
+ ['props: { size?: number }', 'props.size'],
650
+ ['{ size }: { size?: number }', 'size'],
651
+ ]) {
652
+ const adapter = new GoTemplateAdapter()
653
+ const ir = compileToIR(`
654
+ "use client"
655
+ import { createSignal } from "@barefootjs/client"
656
+
657
+ export function Counter(${params}) {
658
+ const [count, setCount] = createSignal(${ref} ?? -1)
659
+ return <div>{count()}</div>
660
+ }
661
+ `, adapter)
662
+ const types = adapter.generate(ir).types!
663
+ expect(types).toContain('Size interface{}')
664
+ expect(types).toContain('var size int = -1')
665
+ expect(types).toContain('Count: size,')
666
+ }
667
+ })
668
+
669
+ test('string fallback with quotes survives the structural match unmangled (#2259 review)', () => {
670
+ const adapter = new GoTemplateAdapter()
671
+ const ir = compileToIR(`
672
+ "use client"
673
+ import { createSignal } from "@barefootjs/client"
674
+
675
+ export function Label({ label }: { label?: string }) {
676
+ const [text, setText] = createSignal(label ?? 'say "hi"')
677
+ return <div>{text()}</div>
678
+ }
679
+ `, adapter)
680
+ const types = adapter.generate(ir).types!
681
+ expect(types).toContain('var label string = "say \\"hi\\""')
682
+ expect(types).not.toContain('\\\\')
683
+ })
684
+
685
+ test('destructure DEFAULT keeps the concrete-typed applyGoFallback baking (#2259)', () => {
686
+ // `{ size = 5 }` never sees a nullish binding in JS (the default
687
+ // already applied), so it must stay excluded from the nillable flip
688
+ // and keep the zero-check baking on the concrete field.
689
+ const adapter = new GoTemplateAdapter()
690
+ const ir = compileToIR(`
691
+ "use client"
692
+ import { createSignal } from "@barefootjs/client"
693
+
694
+ export function Counter({ size = 5 }: { size?: number }) {
695
+ const [count, setCount] = createSignal(size)
696
+ return <div>{count()}</div>
697
+ }
698
+ `)
699
+ const result = adapter.generate(ir)
700
+ const types = result.types!
701
+ expect(types).toContain('Size int')
702
+ expect(types).not.toContain('Size interface{}')
703
+ expect(types).toContain('Size: func() int { if in.Size == 0 { return 5 }; return in.Size }(),')
704
+ })
705
+
589
706
  test('skips hoist for zero-equivalent string and float fallbacks (#1423 review)', () => {
590
707
  // The skip predicate compares the Go fallback against the
591
708
  // type's zero literal — covers `?? ''` (string) and `?? 0.0`
@@ -979,10 +1096,13 @@ function Box({ describedBy }: { describedBy?: string }) {
979
1096
  // Template emission is unchanged from the proven {...props} path.
980
1097
  expect(template).toContain('{{bf_spread_attrs .Spread_0}}')
981
1098
  // 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.
1099
+ // optional prop resolves to a concrete `string` (#2259) it is
1100
+ // consumed only inside the spread condition, not as a bare attr, so
1101
+ // no nillable flip — and the field-typed condition is a faithful
1102
+ // `!= ""` truthiness test rather than reflection: JS string
1103
+ // truthiness IS non-emptiness, and an absent prop zeroes to `""`.
984
1104
  expect(types).toContain('Spread_0: func() map[string]any {')
985
- expect(types).toContain('if bf.Truthy(in.DescribedBy) {')
1105
+ expect(types).toContain('if in.DescribedBy != "" {')
986
1106
  expect(types).toContain('return map[string]any{"aria-describedby": in.DescribedBy}')
987
1107
  expect(types).toContain('return map[string]any{}')
988
1108
  })
@@ -1151,10 +1271,15 @@ export function Widget(props: P) {
1151
1271
  expect(types).toContain('Classes: "a b" + " " + "c d" + " " + in.ClassName + " tail"')
1152
1272
  })
1153
1273
 
1154
- // A boolean ternary memo (`isChecked = ctrl() ? c() : i()`) renders its
1155
- // SSR zero as `false`, not the int `0`, so `aria-checked={isChecked()}`
1156
- // matches Hono's `aria-checked="false"`.
1157
- 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', () => {
1158
1283
  const adapter = new GoTemplateAdapter()
1159
1284
  const source = `
1160
1285
  "use client"
@@ -1169,7 +1294,9 @@ export function Toggle(props: { checked?: boolean; defaultChecked?: boolean }) {
1169
1294
  `
1170
1295
  const types = adapter.generateTypes(compileToIR(source, adapter))!
1171
1296
  expect(types).toContain('IsChecked bool')
1172
- 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
+ )
1173
1300
  })
1174
1301
  })
1175
1302
 
@@ -3631,8 +3758,9 @@ export function C(props: Props) {
3631
3758
  const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3632
3759
  // The `{}` fallback lowers to the safe `""` Go string sentinel — never the
3633
3760
  // `[UNSUPPORTED: …]` marker text, which would break `text/template` parsing
3634
- // once spliced as an `or` operand.
3635
- expect(template).toContain('{{or .Config ""}}')
3761
+ // once spliced as an operand. `??` on a nillable prop lowers to the
3762
+ // nil-testing `bf_nullish` (#2248), equally valid template syntax.
3763
+ expect(template).toContain('{{bf_nullish .Config ""}}')
3636
3764
  expect(template).not.toContain('UNSUPPORTED')
3637
3765
  })
3638
3766
 
@@ -4569,3 +4697,134 @@ export function List() {
4569
4697
  }
4570
4698
  })
4571
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
+ })
@@ -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