@barefootjs/go-template 0.5.1 → 0.5.3

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.
@@ -36,18 +36,6 @@ runAdapterConformanceTests({
36
36
  // template syntax (#1266).
37
37
  skipJsx: [
38
38
  'return-map',
39
- // #1665 whole-item loop conditional. The Go adapter correctly emits the
40
- // per-item `<!--bf-loop-i:KEY-->` anchor, `data-key`, and conditional
41
- // markers (verified by template-structure tests), but the array signal's
42
- // initial value is not baked into the generated `NewXxxProps` constructor
43
- // (`Items: nil`, unlike scalar signals such as `Sel: "b"`), so the Go SSR
44
- // renders an empty loop. Populating signal-array initial values into the
45
- // SSR data context is a separate pre-existing Go limitation — every other
46
- // signal-loop fixture sidesteps it with an empty initial array. The
47
- // anchored SSR shape with rendered items is covered by Hono + CSR
48
- // conformance and the runtime hydration tests. Remove this skip once the
49
- // Go limitation is fixed: https://github.com/piconic-ai/barefootjs/issues/1672
50
- 'loop-item-conditional',
51
39
  // #1297 fixed the harness-side IR emission gate (multi-component
52
40
  // sources now emit one `ir` file per component, and the harness
53
41
  // picks the entry-point IR). The remaining gap is adapter-side:
@@ -88,6 +76,16 @@ runAdapterConformanceTests({
88
76
  // option fixed on the Hono side. Separate follow-up.
89
77
  'toggle-shared',
90
78
  'props-reactivity-comparison',
79
+ // #1467 Phase 2a: first `site/ui` source-root fixture. Button
80
+ // compiles cleanly on the Go adapter (no diagnostic), but its
81
+ // variant/size class composition (`Record<…>[key]` indexed object
82
+ // literals) and `applyRestAttrs` spread haven't been validated for
83
+ // byte-parity against the Hono reference under Go's template
84
+ // semantics. Cross-adapter parity for the `site/ui` corpus is
85
+ // explicitly Phase 3 ("cross-adapter parity (Mojo/Go templates)"),
86
+ // so Button participates only in Hono SSR conformance + the
87
+ // fixture-hydrate runtime layer for now.
88
+ 'button',
91
89
  ],
92
90
  // Per-fixture build-time contracts for shapes the Go template
93
91
  // adapter intentionally refuses to lower. Lives here (not on the
@@ -530,6 +528,355 @@ export function Score(props: { value?: number }) {
530
528
  expect(floatTypes).not.toContain('value := in.Value')
531
529
  expect(floatTypes).toContain('Value: in.Value,')
532
530
  })
531
+
532
+ test('bakes typed struct array-literal initial values into NewXxxProps (#1672)', () => {
533
+ // A signal typed `Item[]` lands in a `[]Item` field whose template loop
534
+ // body reaches each element via struct field access (`.ID`). Baking the
535
+ // inline literal as a Go struct slice — capitalising keys to match the
536
+ // generated field names — lets the Go SSR render the list. Previously
537
+ // `convertInitialValue` returned `nil` for any array literal, freezing
538
+ // SSR loops to empty (the reason whole-item-conditional loop fixtures
539
+ // had to skip Go render conformance — #1665 / #1672).
540
+ const adapter = new GoTemplateAdapter()
541
+ const ir = compileToIR(`
542
+ "use client"
543
+ import { createSignal } from "@barefootjs/client"
544
+
545
+ type Item = { id: string }
546
+ export function List() {
547
+ const [items] = createSignal<Item[]>([{ id: "a" }, { id: "b" }, { id: "c" }])
548
+ return <ul>{items().map((t) => <li key={t.id}>{t.id}</li>)}</ul>
549
+ }
550
+ `)
551
+ const types = adapter.generate(ir).types!
552
+ expect(types).not.toContain('Items: nil,')
553
+ expect(types).toContain('Items: []Item{')
554
+ expect(types).toContain('Item{ID: "a"}')
555
+ expect(types).toContain('Item{ID: "b"}')
556
+ expect(types).toContain('Item{ID: "c"}')
557
+ })
558
+
559
+ test('bakes scalar array-literal initial values into NewXxxProps (#1672)', () => {
560
+ // Scalar loops render each element via `{{.}}`, so an `[]interface{}`
561
+ // (untyped) or `[]string` (typed) slice literal both render correctly.
562
+ const adapter = new GoTemplateAdapter()
563
+ const untypedIr = compileToIR(`
564
+ "use client"
565
+ import { createSignal } from "@barefootjs/client"
566
+
567
+ export function Tags() {
568
+ const [tags] = createSignal(["x", "y", "z"])
569
+ return <ul>{tags().map((t) => <li key={t}>{t}</li>)}</ul>
570
+ }
571
+ `)
572
+ expect(adapter.generate(untypedIr).types!).toContain(
573
+ 'Tags: []interface{}{"x", "y", "z"},',
574
+ )
575
+
576
+ const typedIr = compileToIR(`
577
+ "use client"
578
+ import { createSignal } from "@barefootjs/client"
579
+
580
+ export function Tags() {
581
+ const [tags] = createSignal<string[]>(["x", "y", "z"])
582
+ return <ul>{tags().map((t) => <li key={t}>{t}</li>)}</ul>
583
+ }
584
+ `)
585
+ expect(adapter.generate(typedIr).types!).toContain(
586
+ 'Tags: []string{"x", "y", "z"},',
587
+ )
588
+ })
589
+
590
+ test('synthesises a struct for an untyped object array and bakes it (#1680)', () => {
591
+ // An untyped object array has no element type to bake against. Rather
592
+ // than leave it nil (empty SSR loop), infer a struct from the literal's
593
+ // shape, emit it, type the signal field as a slice of it, and bake the
594
+ // items — so the loop body's struct field access (`.ID`) resolves.
595
+ const adapter = new GoTemplateAdapter()
596
+ const ir = compileToIR(`
597
+ "use client"
598
+ import { createSignal } from "@barefootjs/client"
599
+
600
+ export function List() {
601
+ const [items] = createSignal([{ id: "a", n: 1, ok: true }, { id: "b", n: 2, ok: false }])
602
+ return <ul>{items().map((t) => <li key={t.id}>{t.id}</li>)}</ul>
603
+ }
604
+ `)
605
+ const types = adapter.generate(ir).types!
606
+ // A struct is synthesised with one field per inferred key + Go type.
607
+ expect(types).toMatch(/type \w+ struct \{[\s\S]*ID string[\s\S]*N int[\s\S]*Ok bool[\s\S]*\}/)
608
+ // The signal field is a slice of the synthesised struct, not []interface{}.
609
+ expect(types).toMatch(/Items \[\]\w+ `json:"items"`/)
610
+ expect(types).not.toContain('Items []interface{}')
611
+ // The initial items are baked, not nil.
612
+ expect(types).not.toContain('Items: nil,')
613
+ expect(types).toMatch(/Items: \[\]\w+\{\w+\{ID: "a", N: 1, Ok: true\}, \w+\{ID: "b", N: 2, Ok: false\}\}/)
614
+ })
615
+
616
+ test('keeps nil for an untyped object array with inconsistent shapes (#1680)', () => {
617
+ // Elements must share one shape to synthesise a struct. A key missing
618
+ // from some elements (or a type that disagrees across elements) can't map
619
+ // to a single struct, so we bail to nil rather than guess.
620
+ const adapter = new GoTemplateAdapter()
621
+ const ir = compileToIR(`
622
+ "use client"
623
+ import { createSignal } from "@barefootjs/client"
624
+
625
+ export function List() {
626
+ const [items] = createSignal([{ id: "a" }, { id: "b", extra: 1 }])
627
+ return <ul>{items().map((t) => <li key={t.id}>{t.id}</li>)}</ul>
628
+ }
629
+ `)
630
+ expect(adapter.generate(ir).types!).toContain('Items: nil,')
631
+ })
632
+
633
+ test('keeps nil for an untyped object array with non-scalar values (#1680)', () => {
634
+ // A nested object/array value has no scalar Go type to infer, so the
635
+ // shape can't be synthesised — bail to nil.
636
+ const adapter = new GoTemplateAdapter()
637
+ const ir = compileToIR(`
638
+ "use client"
639
+ import { createSignal } from "@barefootjs/client"
640
+
641
+ export function List() {
642
+ const [items] = createSignal([{ id: "a", tags: ["x"] }])
643
+ return <ul>{items().map((t) => <li key={t.id}>{t.id}</li>)}</ul>
644
+ }
645
+ `)
646
+ expect(adapter.generate(ir).types!).toContain('Items: nil,')
647
+ })
648
+
649
+ test('widens mixed int/float keys to float64 and keeps negatives (#1680)', () => {
650
+ // A key seen as both an integer and a fractional literal across elements
651
+ // can't be `int`; widen it to `float64`. Negative numeric literals keep
652
+ // their sign in the baked value.
653
+ const adapter = new GoTemplateAdapter()
654
+ const ir = compileToIR(`
655
+ "use client"
656
+ import { createSignal } from "@barefootjs/client"
657
+
658
+ export function List() {
659
+ const [pts] = createSignal([{ x: 1, y: -2 }, { x: 2.5, y: -3 }])
660
+ return <ul>{pts().map((p) => <li key={p.x}>{p.x}</li>)}</ul>
661
+ }
662
+ `)
663
+ const types = adapter.generate(ir).types!
664
+ // x mixes 1 and 2.5 → float64; y stays int (both integer literals).
665
+ expect(types).toMatch(/X float64[\s\S]*Y int/)
666
+ expect(types).toContain('{X: 1, Y: -2}')
667
+ expect(types).toContain('{X: 2.5, Y: -3}')
668
+ })
669
+
670
+ test('keeps nil when the synthesised name collides with a user type (#1680)', () => {
671
+ // The struct name is `<Component><Signal>Item`. If the user already
672
+ // declares that exact type, synthesis bails rather than shadowing it.
673
+ const adapter = new GoTemplateAdapter()
674
+ const ir = compileToIR(`
675
+ "use client"
676
+ import { createSignal } from "@barefootjs/client"
677
+
678
+ type ListItemsItem = { id: string }
679
+ export function List() {
680
+ const [items] = createSignal([{ id: "a" }])
681
+ return <ul>{items().map((t) => <li key={t.id}>{t.id}</li>)}</ul>
682
+ }
683
+ `)
684
+ expect(adapter.generate(ir).types!).toContain('Items: nil,')
685
+ })
686
+
687
+ test('synthesises a distinct struct per untyped object-array signal (#1680)', () => {
688
+ // Two untyped signals get component+getter-prefixed names, so their
689
+ // synthesised structs and fields don't collide.
690
+ const adapter = new GoTemplateAdapter()
691
+ const ir = compileToIR(`
692
+ "use client"
693
+ import { createSignal } from "@barefootjs/client"
694
+
695
+ export function List() {
696
+ const [rows] = createSignal([{ id: "a" }])
697
+ const [cols] = createSignal([{ label: "x" }])
698
+ return <ul>{rows().map((r) => <li key={r.id}>{r.id}</li>)}{cols().map((c) => <li key={c.label}>{c.label}</li>)}</ul>
699
+ }
700
+ `)
701
+ const types = adapter.generate(ir).types!
702
+ expect(types).toContain('type ListRowsItem struct {')
703
+ expect(types).toContain('type ListColsItem struct {')
704
+ expect(types).toMatch(/Rows \[\]ListRowsItem/)
705
+ expect(types).toMatch(/Cols \[\]ListColsItem/)
706
+ expect(types).toContain('Rows: []ListRowsItem{ListRowsItem{ID: "a"}}')
707
+ expect(types).toContain('Cols: []ListColsItem{ListColsItem{Label: "x"}}')
708
+ })
709
+
710
+ test('keeps nil for non-literal array initial values (#1672)', () => {
711
+ // A signal whose array initial value is a function call / variable
712
+ // reference cannot be evaluated at codegen time — it must stay nil so
713
+ // the handler populates it (no behaviour change for these cases).
714
+ const adapter = new GoTemplateAdapter()
715
+ const ir = compileToIR(`
716
+ "use client"
717
+ import { createSignal } from "@barefootjs/client"
718
+
719
+ function build(): string[] { return [] }
720
+ export function Dyn() {
721
+ const [items] = createSignal(build())
722
+ return <ul>{items().map((t) => <li key={t}>{t}</li>)}</ul>
723
+ }
724
+ `)
725
+ const types = adapter.generate(ir).types!
726
+ expect(types).toContain('Items: nil,')
727
+ })
728
+
729
+ test('keeps nil for object keys that are not Go-identifier-safe (#1675 review)', () => {
730
+ // A quoted key like "data-id" capitalises to `Data-id`, which is not a
731
+ // valid Go struct field identifier — baking it would emit a keyed struct
732
+ // literal that doesn't compile, so the whole array must stay nil.
733
+ const adapter = new GoTemplateAdapter()
734
+ const ir = compileToIR(`
735
+ "use client"
736
+ import { createSignal } from "@barefootjs/client"
737
+
738
+ type Row = { "data-id": string }
739
+ export function Rows() {
740
+ const [rows] = createSignal<Row[]>([{ "data-id": "a" }])
741
+ return <ul>{rows().map((r) => <li key={r["data-id"]}>{r["data-id"]}</li>)}</ul>
742
+ }
743
+ `)
744
+ const types = adapter.generate(ir).types!
745
+ expect(types).toContain('Rows: nil,')
746
+ })
747
+
748
+ test('collapses whitespace-padded empty array literal to nil (#1675 review)', () => {
749
+ // The empty-literal fast-path must match `[ ]` too, not only the exact
750
+ // `[]`, so a padded empty initial value still defaults to nil rather than
751
+ // baking an empty slice literal.
752
+ const adapter = new GoTemplateAdapter()
753
+ const ir = compileToIR(`
754
+ "use client"
755
+ import { createSignal } from "@barefootjs/client"
756
+
757
+ export function Empty() {
758
+ const [items] = createSignal<string[]>([ ])
759
+ return <ul>{items().map((t) => <li key={t}>{t}</li>)}</ul>
760
+ }
761
+ `)
762
+ const types = adapter.generate(ir).types!
763
+ expect(types).toContain('Items: nil,')
764
+ expect(types).not.toContain('Items: []string{}')
765
+ })
766
+
767
+ test('bakes generic Array<T> / ReadonlyArray<T> initial values like T[] (#1675 review)', () => {
768
+ // `createSignal<Array<T>>` reaches the analyzer as a generic type
769
+ // reference, not a `T[]` array node. The analyzer normalises both to the
770
+ // same array TypeInfo, so baking treats them identically — element typing
771
+ // (and struct-element baking) is preserved rather than degrading to nil.
772
+ const adapter = new GoTemplateAdapter()
773
+ const scalarIr = compileToIR(`
774
+ "use client"
775
+ import { createSignal } from "@barefootjs/client"
776
+
777
+ export function Tags() {
778
+ const [tags] = createSignal<Array<string>>(["x", "y"])
779
+ return <ul>{tags().map((t) => <li key={t}>{t}</li>)}</ul>
780
+ }
781
+ `)
782
+ expect(adapter.generate(scalarIr).types!).toContain('Tags: []string{"x", "y"},')
783
+
784
+ const structIr = compileToIR(`
785
+ "use client"
786
+ import { createSignal } from "@barefootjs/client"
787
+
788
+ type Item = { id: string }
789
+ export function List() {
790
+ const [items] = createSignal<ReadonlyArray<Item>>([{ id: "a" }])
791
+ return <ul>{items().map((t) => <li key={t.id}>{t.id}</li>)}</ul>
792
+ }
793
+ `)
794
+ expect(adapter.generate(structIr).types!).toContain('Items: []Item{Item{ID: "a"}},')
795
+ })
796
+ })
797
+
798
+ describe('loop body outer-scope references (#1677)', () => {
799
+ test('references an outer signal inside a loop via $ root scope, not the element', () => {
800
+ // Inside `{{range $_, $t := .Items}}` the dot is rebound to the loop
801
+ // element, so a reference to the outer `sel` signal must reach the root
802
+ // data through Go template's `$` (`$.Sel`), not `.Sel` — which would
803
+ // resolve against the element struct (no `Sel` field → <nil>).
804
+ const adapter = new GoTemplateAdapter()
805
+ const ir = compileToIR(`
806
+ "use client"
807
+ import { createSignal } from "@barefootjs/client"
808
+
809
+ type Item = { id: string }
810
+ export function L() {
811
+ const [items] = createSignal<Item[]>([{ id: "a" }, { id: "b" }])
812
+ const [sel] = createSignal("b")
813
+ return <ul>{items().map((t) => sel() === t.id && <li key={t.id}>{t.id}</li>)}</ul>
814
+ }
815
+ `)
816
+ const template = adapter.generate(ir).template
817
+ // The loop element field stays element-scoped; the outer signal is rooted.
818
+ expect(template).toContain('eq $.Sel .ID')
819
+ expect(template).not.toContain('eq .Sel .ID')
820
+ })
821
+
822
+ test('references an outer prop inside a loop via $ root scope', () => {
823
+ const adapter = new GoTemplateAdapter()
824
+ const ir = compileToIR(`
825
+ "use client"
826
+ import { createSignal } from "@barefootjs/client"
827
+
828
+ type Item = { id: string }
829
+ export function L(props: { active: string }) {
830
+ const [items] = createSignal<Item[]>([{ id: "a" }])
831
+ return <ul>{items().map((t) => props.active === t.id && <li key={t.id}>{t.id}</li>)}</ul>
832
+ }
833
+ `)
834
+ const template = adapter.generate(ir).template
835
+ expect(template).toContain('eq $.Active .ID')
836
+ expect(template).not.toContain('eq .Active .ID')
837
+ })
838
+
839
+ test('references an outer loop variable from a nested loop via its range var, not root', () => {
840
+ // In nested `{{range}}`s the inner dot is the inner element; the outer
841
+ // loop value is in scope as the Go range variable `$group` (declared by
842
+ // the outer `{{range $_, $group := .Groups}}`). A reference to the outer
843
+ // item from the inner body must use `$group.ID`, not `$.Group.ID` (root)
844
+ // nor `.ID` (inner element).
845
+ const adapter = new GoTemplateAdapter()
846
+ const ir = compileToIR(`
847
+ "use client"
848
+ import { createSignal } from "@barefootjs/client"
849
+
850
+ type Item = { id: string }
851
+ type Group = { id: string; items: Item[] }
852
+ export function L() {
853
+ const [groups] = createSignal<Group[]>([])
854
+ return <ul>{groups().map((group) => <li key={group.id}>{group.items.map((item) => <span key={item.id}>{group.id}:{item.id}</span>)}</li>)}</ul>
855
+ }
856
+ `)
857
+ const template = adapter.generate(ir).template
858
+ // Outer item referenced from the inner loop body resolves to $group.ID.
859
+ expect(template).toContain('$group.ID')
860
+ expect(template).not.toContain('$.Group.ID')
861
+ })
862
+
863
+ test('compares an outer loop variable in a nested loop condition via its range var', () => {
864
+ const adapter = new GoTemplateAdapter()
865
+ const ir = compileToIR(`
866
+ "use client"
867
+ import { createSignal } from "@barefootjs/client"
868
+
869
+ type Item = { id: string; groupId: string }
870
+ type Group = { id: string; items: Item[] }
871
+ export function L() {
872
+ const [groups] = createSignal<Group[]>([])
873
+ return <ul>{groups().map((group) => <li key={group.id}>{group.items.map((item) => group.id === item.groupId && <span key={item.id}>{item.id}</span>)}</li>)}</ul>
874
+ }
875
+ `)
876
+ const template = adapter.generate(ir).template
877
+ expect(template).toContain('eq $group.ID .GroupId')
878
+ expect(template).not.toContain('eq $.Group.ID')
879
+ })
533
880
  })
534
881
 
535
882
  describe('JSX children forwarding (#1203)', () => {
@@ -1917,19 +2264,20 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
1917
2264
  // The catalogue in #1448 documents `/* @client */` as the universal
1918
2265
  // workaround for any Array/String method shape the template adapters
1919
2266
  // can't lower. This block pins that contract for the Go adapter: for
1920
- // every remaining unsupported entry, wrapping the expression in
1921
- // `/* @client */` must (a) clear the BF021/BF101 build error the bare
1922
- // form raises and (b) emit a client-only placeholder so the Go SSR
1923
- // pass renders valid template that the client runtime fills at
1924
- // hydration.
2267
+ // every remaining unsupported entry, the BARE form must surface a
2268
+ // BF021/BF101 build error (so the user is told to act), and wrapping
2269
+ // the expression in `/* @client */` must clear that error and emit a
2270
+ // client-only placeholder so the Go SSR pass renders valid template
2271
+ // the client runtime fills at hydration.
1925
2272
  //
1926
- // Why this matters beyond "no error": the unsupported *string* methods
1927
- // are a silent footgun. Unlike the unsupported array methods (which
1928
- // surface BF101 at build time), bare `.startsWith` / `.repeat` /
1929
- // lower to a Go method-call expression (`{{.Name.StartsWith "a"}}`)
1930
- // that passes the adapter's gate with NO diagnostic then explodes at
1931
- // `go run` time with `can't evaluate field StartsWith in type string`.
1932
- // `/* @client */` is the only escape hatch, so these tests pin it.
2273
+ // History (#1448 follow-up): the unsupported *string* methods used to
2274
+ // be a silent footgun bare `.startsWith` / `.repeat` / … lowered to
2275
+ // a Go method-call expression (`{{.Name.StartsWith "a"}}`) that passed
2276
+ // the adapter's gate with NO diagnostic, then exploded at `go run`
2277
+ // time with `can't evaluate field StartsWith in type string`. They are
2278
+ // now listed in `UNSUPPORTED_METHODS`, so `isSupported` refuses them
2279
+ // and `convertExpressionToGo` records BF101 the same treatment the
2280
+ // unsupported array methods already got. These tests pin that parity.
1933
2281
  describe('GoTemplateAdapter - #1448 @client escape hatch (unsupported methods)', () => {
1934
2282
  // Compile a single expression placed in `<div>` text position, with
1935
2283
  // and without the directive, and return both the build errors and
@@ -1970,54 +2318,61 @@ export function C() {
1970
2318
  return { errors: adapter.errors ?? [], template }
1971
2319
  }
1972
2320
 
1973
- // Tier C array methods bare form raises BF101 at build time.
1974
- const unsupportedArray: Array<[string, string]> = [
1975
- ['reduce', `items().reduce((a, b) => a + b.n, 0)`],
1976
- ['flatMap', `items().flatMap(i => i.tags)`],
1977
- ['flat', `items().flat()`],
2321
+ // Unsupported methods that surface as BF101 at build time: Tier C
2322
+ // array methods + Tier B/C string methods. `badEmit` is the invalid
2323
+ // Go fragment that must NOT survive into the template (the pre-fix
2324
+ // silent-footgun output for the string rows).
2325
+ const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
2326
+ // Tier C array methods.
2327
+ { name: 'reduce', expr: `items().reduce((a, b) => a + b.n, 0)`, badEmit: '.Reduce' },
2328
+ { name: 'flatMap', expr: `items().flatMap(i => i.tags)`, badEmit: '.FlatMap' },
2329
+ { name: 'flat', expr: `items().flat()`, badEmit: '.Flat' },
2330
+ // Tier B/C string methods — previously slipped through with no
2331
+ // diagnostic; now gated by `UNSUPPORTED_METHODS`.
2332
+ { name: 'split', expr: `name().split(",")`, badEmit: '.Name.Split' },
2333
+ { name: 'startsWith', expr: `name().startsWith("a")`, badEmit: '.Name.StartsWith' },
2334
+ { name: 'endsWith', expr: `name().endsWith("z")`, badEmit: '.Name.EndsWith' },
2335
+ { name: 'replace', expr: `name().replace("a", "b")`, badEmit: '.Name.Replace' },
2336
+ { name: 'repeat', expr: `name().repeat(3)`, badEmit: '.Name.Repeat' },
2337
+ { name: 'padStart', expr: `name().padStart(5, "0")`, badEmit: '.Name.PadStart' },
2338
+ { name: 'padEnd', expr: `name().padEnd(5, "0")`, badEmit: '.Name.PadEnd' },
2339
+ { name: 'charAt', expr: `name().charAt(0)`, badEmit: '.Name.CharAt' },
1978
2340
  ]
1979
- for (const [name, expr] of unsupportedArray) {
1980
- test(`array .${name}: bare raises BF101, @client clears it + emits client placeholder`, () => {
2341
+ for (const { name, expr, badEmit } of unsupported) {
2342
+ test(`.${name}: bare raises BF101, @client clears it + emits client placeholder`, () => {
1981
2343
  const bare = emit(expr, false)
1982
2344
  expect(bare.errors.some(e => e.code === 'BF101')).toBe(true)
2345
+ // The unlowerable method call must NOT leak into the template;
2346
+ // the adapter degrades to a safe empty slot alongside the error.
2347
+ expect(bare.template).not.toContain(badEmit)
1983
2348
 
1984
2349
  const guarded = emit(expr, true)
1985
2350
  expect(guarded.errors).toEqual([])
1986
2351
  // Client-only text slot → `{{bfComment "client:sN"}}` placeholder.
1987
2352
  expect(guarded.template).toMatch(/bfComment "client:s\d+"/)
1988
- // The unlowerable expression must NOT survive into the template.
1989
- expect(guarded.template).not.toContain('.Reduce')
1990
- expect(guarded.template).not.toContain('.FlatMap')
1991
- })
1992
- }
1993
-
1994
- // Tier B/C string methods — bare form emits an INVALID Go method
1995
- // call with NO build error (the silent footgun). `@client` is the
1996
- // only thing that prevents the render-time crash.
1997
- const unsupportedString: Array<[string, string, string]> = [
1998
- ['split', `name().split(",")`, '.Name.Split'],
1999
- ['startsWith', `name().startsWith("a")`, '.Name.StartsWith'],
2000
- ['endsWith', `name().endsWith("z")`, '.Name.EndsWith'],
2001
- ['replace', `name().replace("a", "b")`, '.Name.Replace'],
2002
- ['repeat', `name().repeat(3)`, '.Name.Repeat'],
2003
- ['padStart', `name().padStart(5, "0")`, '.Name.PadStart'],
2004
- ['padEnd', `name().padEnd(5, "0")`, '.Name.PadEnd'],
2005
- ['charAt', `name().charAt(0)`, '.Name.CharAt'],
2006
- ]
2007
- for (const [name, expr, badEmit] of unsupportedString) {
2008
- test(`string .${name}: bare emits invalid Go method call, @client emits client placeholder`, () => {
2009
- const bare = emit(expr, false)
2010
- // Documents the footgun: no BF101 guard, invalid template emitted.
2011
- expect(bare.errors.filter(e => e.code === 'BF101')).toEqual([])
2012
- expect(bare.template).toContain(badEmit)
2013
-
2014
- const guarded = emit(expr, true)
2015
- expect(guarded.errors).toEqual([])
2016
- expect(guarded.template).toMatch(/bfComment "client:s\d+"/)
2017
2353
  expect(guarded.template).not.toContain(badEmit)
2018
2354
  })
2019
2355
  }
2020
2356
 
2357
+ // Predicate-level use of an unsupported string method also fails the
2358
+ // build loudly (intended): a `.filter(t => t.name.startsWith("a"))`
2359
+ // whose predicate calls one of the gated methods now refuses the whole
2360
+ // loop with BF101 (via the shared `isSupported` predicate gate in
2361
+ // jsx-to-ir) rather than lowering to a broken `.StartsWith` inside the
2362
+ // range. Pinning this so the loud-failure contract can't silently
2363
+ // regress back to the old emit-broken-template behaviour.
2364
+ test('unsupported string method inside a .filter() predicate raises BF101', () => {
2365
+ const result = compileJSX(`
2366
+ "use client"
2367
+ import { createSignal } from "@barefootjs/client"
2368
+ export function C() {
2369
+ const [items, setItems] = createSignal<{ name: string }[]>([])
2370
+ return <ul>{items().filter(t => t.name.startsWith("a")).map(t => <li key={t.name}>{t.name}</li>)}</ul>
2371
+ }
2372
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter() })
2373
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
2374
+ })
2375
+
2021
2376
  // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
2022
2377
  const unsupportedSort: Array<[string, string]> = [
2023
2378
  ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
@@ -2043,22 +2398,25 @@ export function C() {
2043
2398
  })
2044
2399
  }
2045
2400
 
2046
- // End-to-end proof via `go run`: the bare unsupported form crashes
2047
- // the Go template execution, while the `@client` form renders a
2048
- // `<!--bf-client:sN-->` placeholder. Skipped on hosts without Go.
2049
- test('e2e: bare string method crashes go render, @client renders placeholder', async () => {
2050
- const guarded = `
2401
+ // End-to-end proof via `go run`: the `@client` form renders a
2402
+ // `<!--bf-client:sN-->` placeholder. The bare form is now caught at
2403
+ // build with BF101 and degrades to an empty, render-safe slot (no
2404
+ // more `can't evaluate field …` crash), so we assert the build error
2405
+ // rather than a render crash. Skipped on hosts without Go.
2406
+ test('e2e: @client renders placeholder; bare is caught at build with BF101', async () => {
2407
+ const bare = emit(`name().repeat(3)`, false)
2408
+ expect(bare.errors.some(e => e.code === 'BF101')).toBe(true)
2409
+
2410
+ try {
2411
+ const html = await renderGoTemplateComponent({
2412
+ source: `
2051
2413
  "use client"
2052
2414
  import { createSignal } from "@barefootjs/client"
2053
2415
  export function C() {
2054
2416
  const [name, setName] = createSignal("hello")
2055
2417
  return <div>{/* @client */ name().repeat(3)}</div>
2056
2418
  }
2057
- `
2058
- const bareSrc = guarded.replace('/* @client */ ', '')
2059
- try {
2060
- const html = await renderGoTemplateComponent({
2061
- source: guarded.trimStart(),
2419
+ `.trimStart(),
2062
2420
  adapter: new GoTemplateAdapter(),
2063
2421
  })
2064
2422
  expect(html).toContain('<!--bf-client:s0-->')
@@ -2069,12 +2427,5 @@ export function C() {
2069
2427
  }
2070
2428
  throw err
2071
2429
  }
2072
- // Bare form must fail Go template execution (no @client guard).
2073
- await expect(
2074
- renderGoTemplateComponent({
2075
- source: bareSrc.trimStart(),
2076
- adapter: new GoTemplateAdapter(),
2077
- }),
2078
- ).rejects.toThrow(/can't evaluate field Repeat in type string/)
2079
2430
  })
2080
2431
  })