@barefootjs/go-template 0.15.2 → 0.17.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.
Files changed (73) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +23 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +53 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/helper-inline.d.ts +31 -0
  6. package/dist/adapter/expr/helper-inline.d.ts.map +1 -0
  7. package/dist/adapter/expr/url-builder.d.ts +23 -0
  8. package/dist/adapter/expr/url-builder.d.ts.map +1 -0
  9. package/dist/adapter/go-template-adapter.d.ts +291 -1070
  10. package/dist/adapter/go-template-adapter.d.ts.map +1 -1
  11. package/dist/adapter/index.js +2859 -2618
  12. package/dist/adapter/lib/compile-state.d.ts +116 -0
  13. package/dist/adapter/lib/compile-state.d.ts.map +1 -0
  14. package/dist/adapter/lib/constants.d.ts +11 -0
  15. package/dist/adapter/lib/constants.d.ts.map +1 -0
  16. package/dist/adapter/lib/go-emit.d.ts +117 -0
  17. package/dist/adapter/lib/go-emit.d.ts.map +1 -0
  18. package/dist/adapter/lib/go-naming.d.ts +39 -0
  19. package/dist/adapter/lib/go-naming.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +13 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/types.d.ts +165 -0
  23. package/dist/adapter/lib/types.d.ts.map +1 -0
  24. package/dist/adapter/memo/ctor-lowering.d.ts +39 -0
  25. package/dist/adapter/memo/ctor-lowering.d.ts.map +1 -0
  26. package/dist/adapter/memo/memo-compute.d.ts +124 -0
  27. package/dist/adapter/memo/memo-compute.d.ts.map +1 -0
  28. package/dist/adapter/memo/memo-type.d.ts +38 -0
  29. package/dist/adapter/memo/memo-type.d.ts.map +1 -0
  30. package/dist/adapter/memo/memo-value.d.ts +64 -0
  31. package/dist/adapter/memo/memo-value.d.ts.map +1 -0
  32. package/dist/adapter/memo/template-interp.d.ts +43 -0
  33. package/dist/adapter/memo/template-interp.d.ts.map +1 -0
  34. package/dist/adapter/props/prop-types.d.ts +31 -0
  35. package/dist/adapter/props/prop-types.d.ts.map +1 -0
  36. package/dist/adapter/spread/spread-codegen.d.ts +40 -0
  37. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  38. package/dist/adapter/type/type-codegen.d.ts +25 -0
  39. package/dist/adapter/type/type-codegen.d.ts.map +1 -0
  40. package/dist/adapter/value/parsed-literal-to-go.d.ts +17 -0
  41. package/dist/adapter/value/parsed-literal-to-go.d.ts.map +1 -0
  42. package/dist/adapter/value/value-lowering.d.ts +46 -0
  43. package/dist/adapter/value/value-lowering.d.ts.map +1 -0
  44. package/dist/build.js +2857 -2616
  45. package/dist/index.js +2859 -2618
  46. package/dist/test-render.d.ts.map +1 -1
  47. package/package.json +3 -3
  48. package/src/__tests__/derived-state-memo.test.ts +155 -84
  49. package/src/__tests__/go-template-adapter.test.ts +211 -60
  50. package/src/__tests__/lowering-plugin.test.ts +109 -0
  51. package/src/__tests__/query-href.test.ts +179 -0
  52. package/src/adapter/analysis/component-tree.ts +174 -0
  53. package/src/adapter/emit-context.ts +59 -0
  54. package/src/adapter/expr/helper-inline.ts +274 -0
  55. package/src/adapter/expr/url-builder.ts +123 -0
  56. package/src/adapter/go-template-adapter.ts +2100 -5316
  57. package/src/adapter/lib/compile-state.ts +150 -0
  58. package/src/adapter/lib/constants.ts +24 -0
  59. package/src/adapter/lib/go-emit.ts +289 -0
  60. package/src/adapter/lib/go-naming.ts +84 -0
  61. package/src/adapter/lib/ir-scope.ts +31 -0
  62. package/src/adapter/lib/types.ts +182 -0
  63. package/src/adapter/memo/ctor-lowering.ts +267 -0
  64. package/src/adapter/memo/memo-compute.ts +451 -0
  65. package/src/adapter/memo/memo-type.ts +74 -0
  66. package/src/adapter/memo/memo-value.ts +197 -0
  67. package/src/adapter/memo/template-interp.ts +246 -0
  68. package/src/adapter/props/prop-types.ts +84 -0
  69. package/src/adapter/spread/spread-codegen.ts +458 -0
  70. package/src/adapter/type/type-codegen.ts +95 -0
  71. package/src/adapter/value/parsed-literal-to-go.ts +94 -0
  72. package/src/adapter/value/value-lowering.ts +162 -0
  73. package/src/test-render.ts +2 -14
@@ -42,12 +42,15 @@ runAdapterConformanceTests({
42
42
  // `TemplateFuncMap`). Shapes the adapter intentionally refuses at
43
43
  // build time are pinned in `expectedDiagnostics` below.
44
44
  //
45
- // `data-table` is the one remaining #1896 gap: its table body is a
46
- // keyed `.map` over a `/* @client */`-sorted MEMO whose data is a
47
- // module-const object array a memo-derived dynamic loop of imported
48
- // components, which the nested-component slice constructor can't bake
49
- // yet (`item.ID` is on the loop datum, not the child's Input). Full
50
- // coverage remains at the Hono SSR + fixture-hydrate layers.
45
+ // `data-table` the last #1896 holdout now renders to Hono byte
46
+ // parity on real Go and is fully un-skipped (run in CI). Its table
47
+ // body is a keyed `.map` over a `/* @client */`-sorted MEMO whose
48
+ // data is a module-const object array (a memo-derived dynamic loop of
49
+ // imported components); the nested-component slice constructor bakes
50
+ // it via loop-body children companion defines, a wrapper struct +
51
+ // constructor, and block-body memo folding (#1897). On Go it is
52
+ // un-skipped everywhere — including `skipMarkerConformance` below,
53
+ // since the SSR template emits the slot ids the IR declares.
51
54
  //
52
55
  // `search-params` (router v0.5) now renders on Go: `logical()` parenthesises
53
56
  // its multi-token operands so `searchParams().get(k) ?? d` lowers to
@@ -223,8 +226,9 @@ describe('GoTemplateAdapter - searchParams() env-signal lowering (#1922)', () =>
223
226
  test('lowers searchParams().get(k) to .SearchParams.Get and emits the struct binding', () => {
224
227
  const adapter = new GoTemplateAdapter()
225
228
  const ir = compileToIR(`
226
- import { searchParams } from '@barefootjs/client'
229
+ import { createSearchParams } from '@barefootjs/client'
227
230
  export function SortLabel() {
231
+ const [searchParams] = createSearchParams()
228
232
  return <p>{searchParams().get('sort') ?? 'none'}</p>
229
233
  }
230
234
  `, adapter)
@@ -234,14 +238,15 @@ export function SortLabel() {
234
238
  expect(types).toContain('SearchParams: in.SearchParams')
235
239
  })
236
240
 
237
- // An aliased import binds the env signal to a different local name; the
238
- // call `sp()` still resolves to the canonical `.SearchParams` field (the
239
- // generated struct field name is fixed, not derived from the JS alias).
240
- test('aliased import (`searchParams as sp`) resolves sp() to canonical .SearchParams', () => {
241
+ // An aliased destructured getter binds the env signal to a different local
242
+ // name; the call `sp()` still resolves to the canonical `.SearchParams` field
243
+ // (the generated struct field name is fixed, not derived from the JS name).
244
+ test('aliased env-signal getter (`const [sp] = createSearchParams()`) resolves sp() to canonical .SearchParams', () => {
241
245
  const adapter = new GoTemplateAdapter()
242
246
  const ir = compileToIR(`
243
- import { searchParams as sp } from '@barefootjs/client'
247
+ import { createSearchParams } from '@barefootjs/client'
244
248
  export function SortLabel() {
249
+ const [sp] = createSearchParams()
245
250
  return <p>{sp().get('sort') ?? 'none'}</p>
246
251
  }
247
252
  `, adapter)
@@ -264,8 +269,9 @@ describe('GoTemplateAdapter - template-literal text lowering (#1933)', () => {
264
269
  const adapter = new GoTemplateAdapter()
265
270
  const ir = compileToIR(`
266
271
  'use client'
267
- import { createMemo, searchParams } from '@barefootjs/client'
272
+ import { createMemo, createSearchParams } from '@barefootjs/client'
268
273
  export function StatusLine() {
274
+ const [searchParams] = createSearchParams()
269
275
  const tag = createMemo(() => searchParams().get('tag') ?? '')
270
276
  return (
271
277
  <div className="status">
@@ -732,6 +738,28 @@ export function Tags() {
732
738
  )
733
739
  })
734
740
 
741
+ test('bakes a numeric scalar array from the carried tree (Roadmap A-3)', () => {
742
+ // The analyzer carries `SignalInfo.parsed`, so the scalar-array bake
743
+ // reads the structured tree (`parsedLiteralToGo`) instead of re-parsing
744
+ // the value string with `ts.createSourceFile`. Each numeric element uses
745
+ // the carried raw token (= `NumericLiteral.text`), so the bake matches
746
+ // the fallback byte-for-byte — including TS's `.text` normalisation of
747
+ // `1e3` → `1000` and `0x10` → `16`.
748
+ const adapter = new GoTemplateAdapter()
749
+ const ir = compileToIR(`
750
+ "use client"
751
+ import { createSignal } from "@barefootjs/client"
752
+
753
+ export function Nums() {
754
+ const [ns] = createSignal<number[]>([1, 2, 1e3, 0x10])
755
+ return <ul>{ns().map((n) => <li key={n}>{n}</li>)}</ul>
756
+ }
757
+ `)
758
+ expect(adapter.generate(ir).types!).toContain(
759
+ 'Ns: []int{1, 2, 1000, 16},',
760
+ )
761
+ })
762
+
735
763
  test('synthesises a struct for an untyped object array and bakes it (#1680)', () => {
736
764
  // An untyped object array has no element type to bake against. Rather
737
765
  // than leave it nil (empty SSR loop), infer a struct from the literal's
@@ -1712,7 +1740,9 @@ export function TodoStatus() {
1712
1740
  return <div>{todos().every(t => t.done)}</div>
1713
1741
  }
1714
1742
  `)
1715
- expect(result.template).toContain('bf_every .Todos "Done"')
1743
+ // #2018 P2: `.every(t => t.done)` now lowers through the evaluator —
1744
+ // the predicate body travels as serialized ParsedExpr JSON.
1745
+ expect(result.template).toContain('bf_every_eval .Todos')
1716
1746
  })
1717
1747
 
1718
1748
  test('nested .filter(...).length in filter predicate lowers via len (bf_filter ...) (#1443 PR4)', () => {
@@ -1742,7 +1772,7 @@ export function TodoList() {
1742
1772
  }
1743
1773
  `, adapter)
1744
1774
  expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
1745
- expect(result.template).toContain('gt (len (bf_filter .Tags "Active" true)) 0')
1775
+ expect(result.template).toContain('gt (len (bf_filter_eval .Tags')
1746
1776
  // No degenerate fallbacks
1747
1777
  expect(result.template).not.toContain('{{if false}}')
1748
1778
  expect(result.template).not.toContain('[UNSUPPORTED-FILTER-EXPR]')
@@ -1773,7 +1803,7 @@ export function TodoList() {
1773
1803
  )
1774
1804
  }
1775
1805
  `, adapter)
1776
- expect(result.template).toContain('gt (len (bf_filter .Tags "Active" true)) 0')
1806
+ expect(result.template).toContain('gt (len (bf_filter_eval .Tags')
1777
1807
  expect(result.template).toContain('{{if not .Done}}')
1778
1808
  })
1779
1809
 
@@ -1834,7 +1864,10 @@ export function ItemChecker() {
1834
1864
  return <div>{items().find(t => t.done) ? 'Found' : 'Not found'}</div>
1835
1865
  }
1836
1866
  `)
1837
- expect(result.template).toContain('bf_find .Items "Done" true')
1867
+ // #2018 P2: `.find(t => t.done)` lowers through the evaluator; the
1868
+ // trailing `true` is the forward-search flag (find / findIndex).
1869
+ expect(result.template).toContain('bf_find_eval .Items')
1870
+ expect(result.template).toContain('true bf_env')
1838
1871
  expect(result.template).toContain('Found')
1839
1872
  })
1840
1873
  })
@@ -1852,7 +1885,10 @@ export function ItemChecker() {
1852
1885
  return <div>{items().findLast(t => t.done) ? 'Found' : 'Not found'}</div>
1853
1886
  }
1854
1887
  `)
1855
- expect(result.template).toContain('bf_find_last .Items "Done" true')
1888
+ // #2018 P2: `.findLast(t => t.done)` shares `bf_find_eval` with `.find`,
1889
+ // distinguished by the `false` (backward) forward-flag.
1890
+ expect(result.template).toContain('bf_find_eval .Items')
1891
+ expect(result.template).toContain('false bf_env')
1856
1892
  expect(result.template).toContain('Found')
1857
1893
  })
1858
1894
 
@@ -1886,10 +1922,20 @@ export function ItemChecker() {
1886
1922
  return <div>idx: {items().findLastIndex(t => t.done)}</div>
1887
1923
  }
1888
1924
  `)
1889
- expect(result.template).toContain('bf_find_last_index .Items "Done" true')
1890
- })
1891
-
1892
- test('renders findLastIndex() with complex predicate via range', () => {
1925
+ // #2018 P2: `.findLastIndex` lowers via `bf_find_index_eval` with the
1926
+ // `false` (backward) forward-flag.
1927
+ expect(result.template).toContain('bf_find_index_eval .Items')
1928
+ expect(result.template).toContain('false bf_env')
1929
+ })
1930
+
1931
+ test('renders findLastIndex() with a pure complex predicate via the evaluator', () => {
1932
+ // #2018 P2: a pure (call-free) complex predicate like
1933
+ // `t.price > 50 && t.active` is no longer confined to the
1934
+ // field-equality catalogue — it serializes to a ParsedExpr and lowers
1935
+ // through `bf_find_index_eval` (backward `false` flag), replacing the
1936
+ // old `{{range}}` $bf_r accumulator. The range fallback is now reserved
1937
+ // for predicates the evaluator can't model (e.g. a signal-getter call,
1938
+ // covered by the findLast test above).
1893
1939
  const result = compileAndGenerate(`
1894
1940
  "use client"
1895
1941
  import { createSignal } from "@barefootjs/client"
@@ -1901,10 +1947,9 @@ export function ItemFinder() {
1901
1947
  return <div>{items().findLastIndex(t => t.price > 50 && t.active)}</div>
1902
1948
  }
1903
1949
  `)
1904
- const varMatch = result.template.match(/(\$bf_r\d+) := -1/)
1905
- expect(varMatch).not.toBeNull()
1906
- expect(result.template).toContain(`${varMatch![1]} = $i`)
1907
- expect(result.template).not.toContain('{{break}}')
1950
+ expect(result.template).toContain('bf_find_index_eval .Items')
1951
+ expect(result.template).toContain('false bf_env')
1952
+ expect(result.template).not.toContain('$bf_r')
1908
1953
  })
1909
1954
 
1910
1955
  test('findLast() complex predicate in IR-level ternary works via preamble splitting', () => {
@@ -2425,7 +2470,9 @@ export function C() {
2425
2470
  return <ul>{items().filter(({done}) => done).map(t => <li key={t.id}>{t.name}</li>)}</ul>
2426
2471
  }`)
2427
2472
  expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
2428
- expect(result.template).toContain('bf_filter .Items "Done" true')
2473
+ // #2018 P2: the normalised `_t => _t.done` predicate lowers through the
2474
+ // evaluator (`bf_filter_eval`).
2475
+ expect(result.template).toContain('bf_filter_eval .Items')
2429
2476
  })
2430
2477
 
2431
2478
  test('.filter(function (x) { return x.done }).map(...) lowers cleanly', () => {
@@ -2438,7 +2485,9 @@ export function C() {
2438
2485
  return <ul>{items().filter(function (x) { return x.done }).map(t => <li key={t.id}>{t.name}</li>)}</ul>
2439
2486
  }`)
2440
2487
  expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
2441
- expect(result.template).toContain('bf_filter .Items "Done" true')
2488
+ // #2018 P2: the normalised `_t => _t.done` predicate lowers through the
2489
+ // evaluator (`bf_filter_eval`).
2490
+ expect(result.template).toContain('bf_filter_eval .Items')
2442
2491
  })
2443
2492
  })
2444
2493
 
@@ -2834,19 +2883,23 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
2834
2883
  // #1448 Tier B — string → string, padded to a target width.
2835
2884
  { fixture: stringPadStartFixture, expect: 'bf_pad_start .Value 5 "0"' },
2836
2885
  { fixture: stringPadEndFixture, expect: 'bf_pad_end .Value 5 "."' },
2837
- // #1448 Tier B — sort / toSorted. Loop-chained shapes wrap the
2838
- // iterable in `bf_sort .Items <kind> <key> <type> <dir>`;
2839
- // standalone shapes inline the helper at the call site.
2840
- { fixture: arraySortFieldAscFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "asc"' },
2841
- { fixture: arraySortFieldDescFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "desc"' },
2842
- { fixture: arraySortPrimitiveFixture, expect: 'bf_sort .Nums "self" "" "numeric" "asc"' },
2886
+ // #1448 Tier B — sort / toSorted. Both the standalone shapes and the
2887
+ // `.sort().map()` loop-hoist now lower through the evaluator (#2018 P1/P3):
2888
+ // the comparator body travels as serialized ParsedExpr to `bf_sort_eval`.
2889
+ { fixture: arraySortFieldAscFixture, expect: 'bf_sort_eval .Items' },
2890
+ { fixture: arraySortFieldDescFixture, expect: 'bf_sort_eval .Items' },
2891
+ { fixture: arraySortPrimitiveFixture, expect: 'bf_sort_eval .Nums' },
2892
+ // localeCompare can't be evaluated, so it keeps the legacy `bf_sort` path
2893
+ // (both standalone and loop-hoist fall back).
2843
2894
  { fixture: arraySortLocaleFixture, expect: 'bf_sort .Names "self" "" "string" "asc"' },
2844
- // Multi-key (`||`-chain): one 4-string group per comparison key,
2845
- // applied in priority order as tie-breakers.
2895
+ // Multi-key (`||`-chain) here ends in a `localeCompare`, so the whole
2896
+ // comparator falls back to the structured `bf_sort` (one 4-string group
2897
+ // per comparison key, applied in priority order as tie-breakers).
2846
2898
  { fixture: arraySortMultiKeyFixture, expect: 'bf_sort .Items "field" "Price" "numeric" "asc" "field" "Name" "string" "asc"' },
2847
- // Relational-ternary comparator lowers to a single `auto` key.
2848
- { fixture: arraySortTernaryFixture, expect: 'bf_sort .Items "field" "Rank" "auto" "asc"' },
2849
- { fixture: arrayToSortedFixture, expect: 'bf_sort .Nums "self" "" "numeric" "asc"' },
2899
+ // Relational-ternary comparator a pure body, so it lowers via the
2900
+ // evaluator like the other field sorts.
2901
+ { fixture: arraySortTernaryFixture, expect: 'bf_sort_eval .Items' },
2902
+ { fixture: arrayToSortedFixture, expect: 'bf_sort_eval .Nums' },
2850
2903
  // #1448 Tier B — iteration shapes. These are loop-level
2851
2904
  // patterns (range binding order), not helper function calls.
2852
2905
  { fixture: arrayEntriesFixture, expect: '{{range $i, $v := .Items}}' },
@@ -2891,14 +2944,14 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
2891
2944
  // now listed in `UNSUPPORTED_METHODS`, so `isSupported` refuses them
2892
2945
  // and `convertExpressionToGo` records BF101 — the same treatment the
2893
2946
  // unsupported array methods already got. These tests pin that parity.
2894
- describe('GoTemplateAdapter - #1448 Tier C reduce field capitalisation', () => {
2895
- // #1728 review: `bf_reduce`'s projected field name must use the same
2896
- // initialism-aware capitalisation the adapter applies when generating
2897
- // struct fields (`capitalizeFieldName`), or the runtime reflect lookup
2898
- // misses the exported field (`id` struct `ID`, not `Id`) and folds a
2899
- // zero value. Pin the emitted key so a regression to plain
2900
- // first-letter capitalisation fails here.
2901
- test('reduce over an initialism field emits the Go-initialism key (ID, not Id)', () => {
2947
+ describe('GoTemplateAdapter - #2018 reduce via the evaluator', () => {
2948
+ // A standalone `.reduce(fn, init)` now lowers through the evaluator: the
2949
+ // reducer body is serialized to ParsedExpr JSON and folded by `bf_reduce_eval`.
2950
+ // The body carries the JS field name (`x.id`); the runtime field reader
2951
+ // (`getFieldValue`) resolves it case-variantly against the Go struct field
2952
+ // (`ID`) at render time, so no compile-time capitalisation is needed (the
2953
+ // former #1728 initialism gotcha is moot on this path).
2954
+ test('reduce over a field lowers to bf_reduce_eval with the serialized body', () => {
2902
2955
  const adapter = new GoTemplateAdapter()
2903
2956
  const ir = compileToIR(`
2904
2957
  function C({ items }: { items: { id: number }[] }) {
@@ -2907,8 +2960,9 @@ function C({ items }: { items: { id: number }[] }) {
2907
2960
  export { C }
2908
2961
  `, adapter)
2909
2962
  const template = adapter.generate(ir).template ?? ''
2910
- expect(template).toContain('bf_reduce .Items "+" "field" "ID" "numeric" "0"')
2911
- expect(template).not.toContain('"Id"')
2963
+ expect(template).toContain('bf_reduce_eval .Items')
2964
+ // The seed is threaded through bf_number; the body is the serialized tree.
2965
+ expect(template).toContain('(bf_number "0")')
2912
2966
  })
2913
2967
  })
2914
2968
 
@@ -2949,20 +3003,32 @@ export { C }
2949
3003
  return adapter.generate(ir).template ?? ''
2950
3004
  }
2951
3005
 
2952
- test('.flatMap(i => i.field) emits bf_flat_map with the Go-capitalised field', () => {
2953
- expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain('bf_flat_map .Rows "field" "Tags"')
3006
+ // #2018 P3: `.flatMap(proj)` lowers through the evaluator the projection
3007
+ // body serializes to ParsedExpr JSON and `bf_flat_map_eval` flattens the
3008
+ // results one level. The raw (lowercase) field name travels in the JSON; the
3009
+ // runtime field reader resolves it case-insensitively against Go data.
3010
+ // (The serialized projection JSON is embedded in a Go-template double-quoted
3011
+ // string — its quotes are backslash-escaped — so these pins assert the helper
3012
+ // + receiver; the projection JSON itself is verified by the render
3013
+ // conformance fixtures + the runtime TestFlatMapEval.)
3014
+ test('.flatMap(i => i.field) emits bf_flat_map_eval', () => {
3015
+ expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain('bf_flat_map_eval .Rows')
2954
3016
  })
2955
3017
 
2956
- test('.flatMap(i => i) emits the self projection', () => {
2957
- expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain('bf_flat_map .Rows "self" ""')
3018
+ test('.flatMap(i => i) emits bf_flat_map_eval (self projection)', () => {
3019
+ expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain('bf_flat_map_eval .Rows')
2958
3020
  })
2959
3021
 
2960
- test('.flatMap(i => [i.a, i.b]) emits bf_flat_map_tuple with capitalised leaves', () => {
2961
- expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain('bf_flat_map_tuple .Rows "field" "A" "field" "B"')
3022
+ test('.flatMap(i => [i.a, i.b]) emits bf_flat_map_eval (array-literal projection)', () => {
3023
+ expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain('bf_flat_map_eval .Rows')
2962
3024
  })
2963
3025
 
2964
- test('tuple self + field leaves', () => {
2965
- expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain('bf_flat_map_tuple .Rows "self" "" "field" "A"')
3026
+ // (#2018 P5) A tuple with a string-literal element lowers the same way: the
3027
+ // whole array-literal body serializes and `bf_flat_map_eval` evaluates it per
3028
+ // item, so a non-member element no longer refuses (it did under the structured
3029
+ // `bf_flat_map_tuple` catalogue).
3030
+ test('.flatMap(i => [i.name, "x"]) emits bf_flat_map_eval (literal element)', () => {
3031
+ expect(emitFlatMap('rows.flatMap(i => [i.name, "x"]).join(" ")')).toContain('bf_flat_map_eval .Rows')
2966
3032
  })
2967
3033
  })
2968
3034
 
@@ -3016,9 +3082,10 @@ export function C() {
3016
3082
  // the no-initial-value form stays refused — JS throws on an empty
3017
3083
  // array there, which a template can't mirror.
3018
3084
  { name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '.Reduce' },
3019
- // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
3020
- // tuple with a non-leaf element (here a string literal) stays refused.
3021
- { name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '.FlatMap' },
3085
+ // (#2018 P5) The literal-element tuple `.flatMap(i => [i.name, "x"])` now
3086
+ // lowers through the evaluator (`bf_flat_map_eval` serializes the array-
3087
+ // literal body and flattens), so it is no longer refused pinned positively
3088
+ // in the `.flatMap` describe block alongside the `[i.a, i.b]` form.
3022
3089
  // Lowered methods whose MEANINGFUL extra argument isn't lowered yet
3023
3090
  // (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
3024
3091
  // and the variadic `.concat`. The parser refuses these (silently
@@ -3183,3 +3250,87 @@ export function C() {
3183
3250
  }
3184
3251
  })
3185
3252
  })
3253
+
3254
+ // =============================================================================
3255
+ // #1966 — `/* @client */` defers ATTRIBUTE bindings (not just child/text)
3256
+ // =============================================================================
3257
+ //
3258
+ // Before #1966 the directive was honoured for JSX child/text expressions
3259
+ // but silently ignored on attribute initializers: a Go-unsupported
3260
+ // predicate in `data-x={/* @client */ pred(x)}` still got lowered, raising
3261
+ // BF101/BF102 even though the author had explicitly opted out of SSR. That
3262
+ // made the BF102 remediation ("defer it with /* @client */") misleading for
3263
+ // attribute-only reactive state (the Calendar case in #1467 / PR #1965).
3264
+ //
3265
+ // The fix carries the existing `attr.clientOnly` flag (already set in
3266
+ // jsx-to-ir, already honoured by the client-JS reactive-attribute path —
3267
+ // CSR template omits the attr and a mount effect sets it) through to the
3268
+ // adapter: `renderAttributes` skips SSR emission for `clientOnly` attrs, so
3269
+ // the unsupported-expression lowering is never reached.
3270
+ describe('GoTemplateAdapter - #1966 @client defers attribute bindings', () => {
3271
+ function compileAttr(attrExpr: string) {
3272
+ const adapter = new GoTemplateAdapter()
3273
+ const result = compileJSX(`
3274
+ "use client"
3275
+ import { createSignal } from "@barefootjs/client"
3276
+ export function C() {
3277
+ const [sel] = createSignal(0)
3278
+ const [s] = createSignal("a")
3279
+ const pred = (n: number) => sel() === n
3280
+ return <div data-x={${attrExpr}}>hi</div>
3281
+ }
3282
+ `.trimStart(), 'test.tsx', { adapter })
3283
+ const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3284
+ return { errors: result.errors ?? [], template }
3285
+ }
3286
+
3287
+ test('Go-lowerable predicate: bare emits data-x; @client omits it from SSR', () => {
3288
+ // `sel() === 1` lowers to `{{eq .Sel 1}}`, so the bare form is valid
3289
+ // Go and emits the attribute — the directive is what removes it.
3290
+ const bare = compileAttr('pred(1)')
3291
+ expect(bare.errors).toEqual([])
3292
+ expect(bare.template).toContain('data-x=')
3293
+
3294
+ const deferred = compileAttr('/* @client */ pred(1)')
3295
+ expect(deferred.errors).toEqual([])
3296
+ // Server omits the attribute; the client patches it on hydrate.
3297
+ expect(deferred.template).not.toContain('data-x')
3298
+ })
3299
+
3300
+ test('Go-unsupported predicate: bare raises BF101; @client clears it and omits the attr', () => {
3301
+ const bare = compileAttr('/[0-9]/.test(s())')
3302
+ expect(bare.errors.some(e => e.code === 'BF101' || e.code === 'BF102')).toBe(true)
3303
+
3304
+ const deferred = compileAttr('/* @client */ /[0-9]/.test(s())')
3305
+ // No BF101/BF102 — the lowering is never reached for a deferred attr.
3306
+ expect(deferred.errors).toEqual([])
3307
+ expect(deferred.template).not.toContain('data-x')
3308
+ })
3309
+
3310
+ test('@client attribute inside a keyed .map() loop body is also deferred', () => {
3311
+ const adapter = new GoTemplateAdapter()
3312
+ const result = compileJSX(`
3313
+ "use client"
3314
+ import { createSignal } from "@barefootjs/client"
3315
+ type Day = { n: number }
3316
+ export function C() {
3317
+ const [sel] = createSignal(0)
3318
+ const pred = (d: Day) => sel() === d.n
3319
+ const days: Day[] = [{ n: 1 }, { n: 2 }]
3320
+ return (
3321
+ <div>
3322
+ {days.map((day: Day) => (
3323
+ <div key={day.n} data-x={/* @client */ pred(day)}>cell</div>
3324
+ ))}
3325
+ </div>
3326
+ )
3327
+ }
3328
+ `.trimStart(), 'test.tsx', { adapter })
3329
+ const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
3330
+ expect(result.errors ?? []).toEqual([])
3331
+ // The loop still renders (the array is a static const) but the deferred
3332
+ // attribute is absent from the emitted `<div>` cell.
3333
+ expect(template).toContain('range')
3334
+ expect(template).not.toContain('data-x')
3335
+ })
3336
+ })
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Registry → adapter end-to-end (#2057). Every lowering — first-party built-ins
3
+ * (like `queryHref`) and userland plugins alike — flows through the one
4
+ * lowering-plugin *registry*. This test guarantees that seam works all the way
5
+ * through: a SAMPLE plugin (registered via the public `registerLoweringPlugin`)
6
+ * recognises a call from an arbitrary package and returns a backend-neutral
7
+ * `guard-list` node, and the Go adapter renders it via its own `bf_query`
8
+ * mapping — no adapter code knows the sample plugin exists.
9
+ *
10
+ * The plugin reuses the `'query'` helper id so it exercises the exact same
11
+ * renderer path as the built-in `queryHref` plugin, proving that a userland
12
+ * plugin and a default-registered built-in are indistinguishable to the adapter
13
+ * (which is the whole point of the neutral-node layer).
14
+ */
15
+ import { describe, test, expect, afterEach } from 'bun:test'
16
+ import {
17
+ compileJSX,
18
+ registerLoweringPlugin,
19
+ getLoweringPlugins,
20
+ __resetLoweringPluginsForTest,
21
+ type ComponentIR,
22
+ type LoweringPlugin,
23
+ } from '@barefootjs/jsx'
24
+ import { GoTemplateAdapter } from '../adapter/go-template-adapter'
25
+
26
+ function generate(src: string) {
27
+ const adapter = new GoTemplateAdapter()
28
+ const result = compileJSX(src.trimStart(), 'T.tsx', { adapter, outputIR: true })
29
+ const irFile = result.files.find(f => f.type === 'ir')
30
+ if (!irFile) throw new Error('no IR')
31
+ const ir = JSON.parse(irFile.content) as ComponentIR
32
+ return adapter.generate(ir)
33
+ }
34
+
35
+ // A userland-style plugin: active only when the component imports `demoUrl` from
36
+ // `@sample/pkg`, lowering `demoUrl(base, { key: value })` to a neutral
37
+ // `guard-list` on the `'query'` helper. It reuses the sub-parts of the parsed
38
+ // call directly (base = first arg, one triple per object property) so no
39
+ // adapter syntax leaks into the plugin.
40
+ const samplePlugin: LoweringPlugin = {
41
+ name: 'sample-demo-url',
42
+ prepare(metadata) {
43
+ const spec = metadata.imports
44
+ .filter(i => i.source === '@sample/pkg' && !i.isTypeOnly)
45
+ .flatMap(i => i.specifiers)
46
+ .filter(s => !s.isTypeOnly && !s.isNamespace && !s.isDefault)
47
+ .find(s => s.name === 'demoUrl')
48
+ if (!spec) return null
49
+ const local = spec.alias ?? spec.name // the name it's bound under in this file
50
+ return (callee, args) => {
51
+ if (callee.kind !== 'identifier' || callee.name !== local) return null
52
+ const [base, obj] = args
53
+ if (!base || obj?.kind !== 'object-literal') return null
54
+ return {
55
+ kind: 'guard-list',
56
+ helper: 'query',
57
+ base,
58
+ triples: obj.properties.map(p => ({ guard: null, key: p.key, value: p.value })),
59
+ }
60
+ }
61
+ },
62
+ }
63
+
64
+ afterEach(() => {
65
+ __resetLoweringPluginsForTest(getLoweringPlugins().filter(p => p.name !== 'sample-demo-url'))
66
+ })
67
+
68
+ describe('lowering-plugin registry → Go adapter (#2057)', () => {
69
+ test('a registered plugin lowers its call to the neutral node the adapter renders', () => {
70
+ registerLoweringPlugin(samplePlugin)
71
+ const src = `
72
+ 'use client'
73
+ import { demoUrl } from '@sample/pkg'
74
+ export function P(props: { base: string; tag: string }) {
75
+ return <a href={demoUrl(props.base, { tag: props.tag })}>x</a>
76
+ }
77
+ `
78
+ const { template } = generate(src)
79
+ // Same render path as built-in queryHref: guard-list → bf_query.
80
+ expect(template).toContain('bf_query .Base (true) "tag" .Tag')
81
+ })
82
+
83
+ test('without the plugin registered the same call falls back to the generic lowering', () => {
84
+ // No registerLoweringPlugin call — the registry is empty for this component.
85
+ const src = `
86
+ 'use client'
87
+ import { demoUrl } from '@sample/pkg'
88
+ export function P(props: { base: string; tag: string }) {
89
+ return <a href={demoUrl(props.base, { tag: props.tag })}>x</a>
90
+ }
91
+ `
92
+ const { template } = generate(src)
93
+ expect(template).not.toContain('bf_query')
94
+ })
95
+
96
+ test('the plugin is inert when the component does not import from its package', () => {
97
+ registerLoweringPlugin(samplePlugin)
98
+ const src = `
99
+ 'use client'
100
+ import { queryHref } from '@barefootjs/client'
101
+ export function P(props: { base: string; tag: string }) {
102
+ return <a href={queryHref(props.base, { tag: props.tag })}>x</a>
103
+ }
104
+ `
105
+ const { template } = generate(src)
106
+ // Built-in queryHref still lowers; the sample plugin simply isn't active.
107
+ expect(template).toContain('bf_query .Base (true) "tag" .Tag')
108
+ })
109
+ })