@barefootjs/go-template 0.33.3 → 0.33.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/go-template",
3
- "version": "0.33.3",
3
+ "version": "0.33.6",
4
4
  "description": "Go html/template adapter for BarefootJS - generates Go template files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  "directory": "packages/adapter-go-template"
50
50
  },
51
51
  "dependencies": {
52
- "@barefootjs/shared": "0.33.3"
52
+ "@barefootjs/shared": "0.33.6"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "@barefootjs/jsx": ">=0.2.0",
@@ -67,9 +67,9 @@
67
67
  },
68
68
  "devDependencies": {
69
69
  "@barefootjs/adapter-tests": "0.1.0",
70
- "@barefootjs/client": "0.33.3",
71
- "@barefootjs/jsx": "0.33.3",
72
- "@barefootjs/vite": "0.33.3",
70
+ "@barefootjs/client": "0.33.6",
71
+ "@barefootjs/jsx": "0.33.6",
72
+ "@barefootjs/vite": "0.33.6",
73
73
  "vite": "^6.0.0"
74
74
  }
75
75
  }
@@ -18,6 +18,7 @@ import {
18
18
  } from '@barefootjs/jsx'
19
19
  import { conformancePins } from '../conformance-pins'
20
20
  import { renderDivergences } from '../render-divergences'
21
+ import { findNestedComponents } from '../adapter/analysis/component-tree.ts'
21
22
 
22
23
  runAdapterConformanceTests({
23
24
  name: 'go-template',
@@ -118,12 +119,15 @@ runAdapterConformanceTests({
118
119
  'todo-app',
119
120
  // (#1897) data-table no longer skipped — loop body children + wrapper
120
121
  // struct + block-body memo baking render correctly on Go.
121
- // Real-render divergence (see `render-divergences.ts` for the full
122
- // description): Go's real SSR output drops the `header` prop's
123
- // content entirely for this shape, so its rendered template has no
124
- // `^s0` marker for the IR's conditional to match against. Paired
125
- // with the `renderDivergences` entry, not an independent gap.
126
- 'jsx-element-prop-fragment-conditional',
122
+ // `jsx-element-prop-fragment-conditional` (#2703) no longer skipped
123
+ // renders correctly since named jsx-children props got the same
124
+ // `bf_with_props`/`bf_tmpl` dynamic-delivery route reserved `children`
125
+ // already had.
126
+ // #2805: this shape refuses with BF101 at compile time (see
127
+ // `conformance-pins.ts`) — `expectedDiagnostics` covers the diagnostic
128
+ // contract, and marker comparison can't apply to a fixture that
129
+ // produces no complete template.
130
+ 'jsx-element-prop-rest-bag-dynamic',
127
131
  ]),
128
132
  skipDataPoints: new Set<string>([
129
133
  // #2743: html/template's URL-context autoescape percent-encodes the
@@ -984,7 +988,9 @@ export function List() {
984
988
 
985
989
  test('keeps nil for an untyped object array with non-scalar values (#1680)', () => {
986
990
  // A nested object/array value has no scalar Go type to infer, so the
987
- // shape can't be synthesised — bail to nil.
991
+ // shape can't be synthesised — bail to nil. `tags` is a nested array
992
+ // of SCALARS (not objects) — #2800 only teaches synthesis to recurse
993
+ // into an array of OBJECT literals, so this shape is unchanged.
988
994
  const adapter = new GoTemplateAdapter()
989
995
  const ir = compileToIR(`
990
996
  "use client"
@@ -998,6 +1004,85 @@ export function List() {
998
1004
  expect(adapter.generate(ir).types!).toContain('Items: nil,')
999
1005
  })
1000
1006
 
1007
+ // #2800: a nested array-of-objects field (`children: [{...}]`) recurses
1008
+ // into its own synthesized struct instead of aborting the whole
1009
+ // signal's synthesis.
1010
+ test('synthesises a nested struct for a nested array-of-objects field and bakes it (#2800)', () => {
1011
+ const adapter = new GoTemplateAdapter()
1012
+ const ir = compileToIR(`
1013
+ "use client"
1014
+ import { createSignal } from "@barefootjs/client"
1015
+
1016
+ export function NestedRefConst() {
1017
+ const [items] = createSignal([
1018
+ { id: 1, label: 'Alpha', children: [{ id: 10, label: 'Alpha-child' }] },
1019
+ { id: 2, label: 'Beta', children: [{ id: 20, label: 'Beta-child' }] },
1020
+ ])
1021
+ return <ul>{items().map((row) => <li key={row.id}>{row.label}</li>)}</ul>
1022
+ }
1023
+ `)
1024
+ const types = adapter.generate(ir).types!
1025
+ expect(types).toContain('type NestedRefConstItemsItemChildrenItem struct')
1026
+ expect(types).toContain('Children []NestedRefConstItemsItemChildrenItem')
1027
+ expect(types).not.toContain('Items: nil,')
1028
+ expect(types).toContain(
1029
+ 'Items: []NestedRefConstItemsItem{NestedRefConstItemsItem{ID: 1, Label: "Alpha", Children: []NestedRefConstItemsItemChildrenItem{NestedRefConstItemsItemChildrenItem{ID: 10, Label: "Alpha-child"}}}, NestedRefConstItemsItem{ID: 2, Label: "Beta", Children: []NestedRefConstItemsItemChildrenItem{NestedRefConstItemsItemChildrenItem{ID: 20, Label: "Beta-child"}}}}',
1030
+ )
1031
+ })
1032
+
1033
+ test('recurses to arbitrary depth, widening a numeric field at the innermost level (#2800)', () => {
1034
+ const adapter = new GoTemplateAdapter()
1035
+ const ir = compileToIR(`
1036
+ "use client"
1037
+ import { createSignal } from "@barefootjs/client"
1038
+
1039
+ export function Depth3() {
1040
+ const [items] = createSignal([
1041
+ { id: 1, kids: [{ id: 10, grand: [{ id: 100, n: 1 }, { id: 101, n: 2.5 }] }] },
1042
+ ])
1043
+ return <ul>{items().map((row) => <li key={row.id}>{row.id}</li>)}</ul>
1044
+ }
1045
+ `)
1046
+ const types = adapter.generate(ir).types!
1047
+ expect(types).toContain('type Depth3ItemsItemKidsItemGrandItem struct')
1048
+ expect(types).toMatch(/type Depth3ItemsItemKidsItemGrandItem struct \{\n\tID int[\s\S]*\tN float64/)
1049
+ expect(types).toContain('Kids []Depth3ItemsItemKidsItem')
1050
+ expect(types).toContain('Grand []Depth3ItemsItemKidsItemGrandItem')
1051
+ expect(types).not.toContain('Items: nil,')
1052
+ })
1053
+
1054
+ test('keeps nil when a nested struct name collides with a user type (#2800)', () => {
1055
+ const adapter = new GoTemplateAdapter()
1056
+ const ir = compileToIR(`
1057
+ "use client"
1058
+ import { createSignal } from "@barefootjs/client"
1059
+
1060
+ type ListItemsItemChildrenItem = { id: string }
1061
+ export function List() {
1062
+ const [items] = createSignal([{ id: 1, children: [{ id: 2 }] }])
1063
+ return <ul>{items().map((row) => <li key={row.id}>{row.id}</li>)}</ul>
1064
+ }
1065
+ `)
1066
+ expect(adapter.generate(ir).types!).toContain('Items: nil,')
1067
+ })
1068
+
1069
+ test('keeps nil when a key is a nested array in some rows and scalar in others (#2800)', () => {
1070
+ const adapter = new GoTemplateAdapter()
1071
+ const ir = compileToIR(`
1072
+ "use client"
1073
+ import { createSignal } from "@barefootjs/client"
1074
+
1075
+ export function List() {
1076
+ const [items] = createSignal([
1077
+ { id: 1, children: [{ id: 2 }] },
1078
+ { id: 3, children: 'not-an-array' },
1079
+ ])
1080
+ return <ul>{items().map((row) => <li key={row.id}>{row.id}</li>)}</ul>
1081
+ }
1082
+ `)
1083
+ expect(adapter.generate(ir).types!).toContain('Items: nil,')
1084
+ })
1085
+
1001
1086
  test('widens mixed int/float keys to float64 and keeps negatives (#1680)', () => {
1002
1087
  // A key seen as both an integer and a fractional literal across elements
1003
1088
  // can't be `int`; widen it to `float64`. Negative numeric literals keep
@@ -1404,6 +1489,201 @@ export function Widget(props: P) {
1404
1489
  expect(types).toContain('Classes: "a b" + " " + "c d" + " " + in.ClassName + " tail"')
1405
1490
  })
1406
1491
 
1492
+ // A signal seeded from a bare identifier referencing a module-level
1493
+ // const (`const PAYLOAD = '...'; createSignal(PAYLOAD)`) used to bake
1494
+ // to `nil` — the analyzer types it `unknown` since it never chases an
1495
+ // identifier to its declaration, so none of convertInitialValue's typed
1496
+ // branches saw it (#2794). Now resolved via the same
1497
+ // resolveModuleStringConst/resolveModuleNumericConst the adapter
1498
+ // already used for live template expressions (template-interp.ts).
1499
+ test('signal seeded from a module-level const bakes its literal value, not nil', () => {
1500
+ const adapter = new GoTemplateAdapter()
1501
+ const source = `
1502
+ "use client"
1503
+ import { createSignal } from "@barefootjs/client"
1504
+ const PAYLOAD = 'hello'
1505
+ const WIDTH = 8
1506
+ export function Demo() {
1507
+ const [value] = createSignal(PAYLOAD)
1508
+ const [width] = createSignal(WIDTH)
1509
+ return <textarea value={value()} data-w={width()} />
1510
+ }
1511
+ `
1512
+ const types = adapter.generateTypes(compileToIR(source, adapter))!
1513
+ expect(types).toContain('Value: "hello"')
1514
+ expect(types).toContain('Width: 8')
1515
+ })
1516
+
1517
+ // Same family, boolean shape (#2815 — filed and fixed in the same PR
1518
+ // as #2794 above, since it's the identical resolver-not-wired gap on
1519
+ // a third literal kind rather than a new mechanism).
1520
+ test('signal seeded from a module-level boolean const bakes true/false, not nil', () => {
1521
+ const adapter = new GoTemplateAdapter()
1522
+ const source = `
1523
+ "use client"
1524
+ import { createSignal } from "@barefootjs/client"
1525
+ const OPEN = true
1526
+ export function Demo() {
1527
+ const [open] = createSignal(OPEN)
1528
+ return <div>{open() ? 'y' : 'n'}</div>
1529
+ }
1530
+ `
1531
+ const types = adapter.generateTypes(compileToIR(source, adapter))!
1532
+ expect(types).toContain('Open: true')
1533
+ })
1534
+
1535
+ // #2700: a `derived` signal (non-empty free set, e.g. `{ ...base, done }`)
1536
+ // whose object literal the constructor-time baker can't reproduce
1537
+ // (identifier/member/call operands defer) used to silently keep the Go
1538
+ // zero value for every field the SSR template reads. Loud-ified instead
1539
+ // of taught a live-expression lowering — Go has none — since the
1540
+ // template's `.Merged.ID` / `.Merged.Done` reads would otherwise see a
1541
+ // wrong value with no diagnostic at all.
1542
+ test('signal object-literal spreading a live prop refuses with BF101 when the template reads it (#2700)', () => {
1543
+ const adapter = new GoTemplateAdapter()
1544
+ const result = compileAndGenerate(`
1545
+ "use client"
1546
+ import { createSignal } from "@barefootjs/client"
1547
+ type Item = { id: string; done: boolean }
1548
+ export function Widget({ base }: { base: Item }) {
1549
+ const [merged] = createSignal({ ...base, done: true })
1550
+ return (
1551
+ <div>
1552
+ <span>{merged().id}</span>
1553
+ <span>{merged().done ? 'yes' : 'no'}</span>
1554
+ </div>
1555
+ )
1556
+ }
1557
+ `, adapter)
1558
+ const bf101 = adapter.errors.filter(e => e.code === 'BF101')
1559
+ expect(bf101.length).toBe(1)
1560
+ expect(bf101[0].message).toContain("Signal 'merged'")
1561
+ expect(bf101[0].message).toContain('base')
1562
+ expect(bf101[0].suggestion?.escape).toEqual([{ kind: 'client-directive' }])
1563
+ expect(result.template).toBeTruthy()
1564
+ })
1565
+
1566
+ // Same refusal for an EXPLICITLY typed object signal (`createSignal<Item>`)
1567
+ // — the typed `interface` branch (`value-lowering.ts`) also defers to a
1568
+ // struct zero value (`Item{}`) for the identical reason.
1569
+ test('typed object signal spreading a live prop also refuses with BF101 (#2700)', () => {
1570
+ const adapter = new GoTemplateAdapter()
1571
+ compileAndGenerate(`
1572
+ "use client"
1573
+ import { createSignal } from "@barefootjs/client"
1574
+ type Item = { id: string; done: boolean }
1575
+ export function Widget({ base }: { base: Item }) {
1576
+ const [merged] = createSignal<Item>({ id: base.id, done: true })
1577
+ return <span>{merged().id}</span>
1578
+ }
1579
+ `, adapter)
1580
+ expect(adapter.errors.some(e => e.code === 'BF101')).toBe(true)
1581
+ })
1582
+
1583
+ // No memo-side counterpart: the analyzer deliberately never sets
1584
+ // `MemoInfo.parsed` to an `object-literal` for an object-returning memo
1585
+ // body (`analyzer.ts`, "isn't lowered from the parsed tree yet") — a
1586
+ // pre-existing, unrelated exclusion — so this refusal has no structural
1587
+ // handle to reach a memo without re-parsing `computation` as text,
1588
+ // which the repo's own convention rules out. #2700's own reproduction
1589
+ // and fixture are signal-only.
1590
+
1591
+ // The `/* @client */` escape: wrapping every SSR read defers evaluation
1592
+ // to the client, so the template never reaches `rootFieldRef` for this
1593
+ // signal — no refusal, even though the constructor still bakes `nil`.
1594
+ test('/* @client */ on every read suppresses the #2700 refusal', () => {
1595
+ const adapter = new GoTemplateAdapter()
1596
+ const result = compileAndGenerate(`
1597
+ "use client"
1598
+ import { createSignal } from "@barefootjs/client"
1599
+ type Item = { id: string; done: boolean }
1600
+ export function Widget({ base }: { base: Item }) {
1601
+ const [merged] = createSignal({ ...base, done: true })
1602
+ return (
1603
+ <div>
1604
+ <span>{/* @client */ merged().id}</span>
1605
+ <span>{/* @client */ merged().done ? 'yes' : 'no'}</span>
1606
+ </div>
1607
+ )
1608
+ }
1609
+ `, adapter)
1610
+ expect(adapter.errors.filter(e => e.code === 'BF101')).toEqual([])
1611
+ expect(result.types).toContain('Merged: nil,')
1612
+ })
1613
+
1614
+ // A signal that ONLY feeds a JSX spread (`{...merged()}`) never reaches
1615
+ // `rootFieldRef` — spread bags bake through their own `.Spread_<slot>`
1616
+ // route (`emitSpreadBagInits`) — so it must not trip the #2700 check even
1617
+ // though the constructor still can't bake the literal. (This shape DOES
1618
+ // still refuse, but with the PRE-EXISTING, unrelated "JSX spread has no
1619
+ // Go template lowering" BF101 for `{...merged()}` on an intrinsic
1620
+ // element — not a second, redundant #2700-shaped one.)
1621
+ test('a signal only spread into attrs does not double-refuse under #2700', () => {
1622
+ const adapter = new GoTemplateAdapter()
1623
+ compileAndGenerate(`
1624
+ "use client"
1625
+ import { createSignal } from "@barefootjs/client"
1626
+ type Item = { id: string; done: boolean }
1627
+ export function Widget({ base }: { base: Item }) {
1628
+ const [merged] = createSignal({ ...base, done: true })
1629
+ return <div {...merged()} />
1630
+ }
1631
+ `, adapter)
1632
+ const bf101 = adapter.errors.filter(e => e.code === 'BF101')
1633
+ expect(bf101.length).toBe(1)
1634
+ expect(bf101[0].message).not.toContain("Signal 'merged' is seeded")
1635
+ })
1636
+
1637
+ // A signal read ONLY from inside a `.filter()` predicate (never a plain
1638
+ // `{merged()}` text read) must still register in `templateReadRootFields`
1639
+ // and trip #2700's refusal — `renderFilterExprNode`'s zero-arg-call arm
1640
+ // used to build the `$.Merged` field reference by hand instead of
1641
+ // through `rootFieldRef`, so this exact shape was a false negative
1642
+ // (pullfrog review, PR #2818).
1643
+ test('a signal reachable only through a .filter() predicate still refuses with BF101 (#2700, #2818 pullfrog review)', () => {
1644
+ const adapter = new GoTemplateAdapter()
1645
+ compileAndGenerate(`
1646
+ "use client"
1647
+ import { createSignal } from "@barefootjs/client"
1648
+ type Item = { id: string; done: boolean }
1649
+ export function Widget({ base, items }: { base: Item; items: Item[] }) {
1650
+ const [merged] = createSignal({ ...base, done: true })
1651
+ return (
1652
+ <ul>
1653
+ {items().filter(i => i.id === merged().id).map(i => <li key={i.id}>{i.id}</li>)}
1654
+ </ul>
1655
+ )
1656
+ }
1657
+ `, adapter)
1658
+ const bf101 = adapter.errors.filter(e => e.code === 'BF101')
1659
+ expect(bf101.length).toBe(1)
1660
+ expect(bf101[0].message).toContain("Signal 'merged'")
1661
+ })
1662
+
1663
+ // A destructured prop sharing the module const's name must still win
1664
+ // (shadowing) — the const resolver is checked AFTER the prop lookup.
1665
+ test('a destructured prop shadows a same-named module const', () => {
1666
+ const adapter = new GoTemplateAdapter()
1667
+ const source = `
1668
+ "use client"
1669
+ import { createSignal } from "@barefootjs/client"
1670
+ const PAYLOAD = 'const-value'
1671
+ interface P { PAYLOAD?: string }
1672
+ export function Demo({ PAYLOAD }: P) {
1673
+ const [value] = createSignal(PAYLOAD)
1674
+ return <textarea value={value()} />
1675
+ }
1676
+ `
1677
+ const types = adapter.generateTypes(compileToIR(source, adapter))!
1678
+ expect(types).not.toContain('Value: "const-value"')
1679
+ // Positive pin (pullfrog review, #2816): the absence check above
1680
+ // would pass just as well if the value fell through to `nil`
1681
+ // instead of resolving to the prop — assert the actual expected
1682
+ // shadowing output too, matching the `Value: in.Value,` convention
1683
+ // used elsewhere in this file.
1684
+ expect(types).toContain('Value: in.PAYLOAD,')
1685
+ })
1686
+
1407
1687
  // A boolean ternary memo (`isChecked = ctrl() ? c() : i()`) types its
1408
1688
  // SSR field as `bool` (not `int`), so `aria-checked={isChecked()}`
1409
1689
  // matches Hono's `aria-checked="false"` shape. Since #2260, `checked`'s
@@ -5109,6 +5389,61 @@ export function Toggle({ toggleItems }: ToggleProps) {
5109
5389
  })
5110
5390
  })
5111
5391
 
5392
+ describe('GoTemplateAdapter - #2835 name collision with a whole-props type member', () => {
5393
+ // Same-file sibling components (`Item` alongside the exported `List`) —
5394
+ // compiled with `compileJSX` directly, like the #2445 describe block above,
5395
+ // since `compileToIR`'s single-component IR round trip isn't set up to
5396
+ // pick a specific sibling out of a multi-component file.
5397
+ const collidingSource = `
5398
+ type ItemProps = { label: string }
5399
+ function Item(props: ItemProps) {
5400
+ return <div>{props.label}</div>
5401
+ }
5402
+ const base = [{ label: 'z' }]
5403
+ type Props = { base: ItemProps[] }
5404
+ export function List(props: Props) {
5405
+ return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
5406
+ }
5407
+ `
5408
+ const controlSource = `
5409
+ type ItemProps = { label: string }
5410
+ function Item(props: ItemProps) {
5411
+ return <div>{props.label}</div>
5412
+ }
5413
+ const base = [{ label: 'z' }]
5414
+ type Props = { other: string }
5415
+ export function List(props: Props) {
5416
+ return <div>{base.map((item) => <Item key={item.label} label={item.label} />)}</div>
5417
+ }
5418
+ `
5419
+
5420
+ test('a module const colliding with `Props.base` is not classified prop-derived — the actual input `findNestedComponents` (component-tree.ts) feeds to Go codegen', () => {
5421
+ const ctx = analyzeComponent(collidingSource.trimStart(), 'test.tsx', 'List')
5422
+ const ir = jsxToIR(ctx)
5423
+ expect(ir).not.toBeNull()
5424
+ const item = findNestedComponents(ir!).find(c => c.name === 'Item')
5425
+ expect(item).toBeDefined()
5426
+ expect(item!.isPropDerived).toBe(false)
5427
+ expect(item!.isDynamic).toBe(false)
5428
+ })
5429
+
5430
+ test('Items stays a real hydrated field (`json:"items"`), matching the non-colliding control — misclassification previously hid it behind `json:"-"` and fabricated an unrelated `Base` prop read', () => {
5431
+ const collidingResult = compileJSX(collidingSource.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5432
+ const controlResult = compileJSX(controlSource.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter(), outputIR: false })
5433
+ const collidingTypes = collidingResult.files.find(f => f.type === 'types')!.content
5434
+ const controlTypes = controlResult.files.find(f => f.type === 'types')!.content
5435
+ // `Props.base` is a real declared member independent of the loop
5436
+ // classification, so it legitimately gets its own `Base`/`bfCallerProps`
5437
+ // entries in BOTH cases — the loop-specific signal is the `Items` field,
5438
+ // which a misclassified `isPropDerived: true` hides from JSON entirely.
5439
+ expect(collidingTypes).toContain('Items []ItemProps `json:"items"`')
5440
+ expect(collidingTypes).not.toContain('Items []ItemProps `json:"-"`')
5441
+ const itemsFieldLine = (src: string) => src.split('\n').find(l => l.includes('[]ItemInput')) ?? ''
5442
+ expect(itemsFieldLine(collidingTypes)).not.toBe('')
5443
+ expect(itemsFieldLine(collidingTypes)).toBe(itemsFieldLine(controlTypes))
5444
+ })
5445
+ })
5446
+
5112
5447
  describe('GoTemplateAdapter - collision-derivation lowering (#2683)', () => {
5113
5448
  test('a signal colliding with its own prop composes the presence-check fold with the surrounding arithmetic', () => {
5114
5449
  const result = compileJSX(`
@@ -79,4 +79,18 @@ export interface GoEmitContext {
79
79
  * outer-loop params are excluded).
80
80
  */
81
81
  resolveModuleStringConst(name: string): string | null
82
+
83
+ /**
84
+ * Inline a module numeric const by name as its Go literal text (e.g.
85
+ * `8`, `-3.5`), or null when the name is not such a const (loop vars and
86
+ * outer-loop params are excluded, same as `resolveModuleStringConst`).
87
+ */
88
+ resolveModuleNumericConst(name: string): string | null
89
+
90
+ /**
91
+ * Inline a module boolean const by name as its Go literal text
92
+ * (`true`/`false`), or null when the name is not such a const (same
93
+ * exclusions as `resolveModuleNumericConst`).
94
+ */
95
+ resolveModuleBooleanConst(name: string): string | null
82
96
  }