@barefootjs/go-template 0.5.0 → 0.5.2

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.
@@ -76,6 +76,16 @@ runAdapterConformanceTests({
76
76
  // option fixed on the Hono side. Separate follow-up.
77
77
  'toggle-shared',
78
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',
79
89
  ],
80
90
  // Per-fixture build-time contracts for shapes the Go template
81
91
  // adapter intentionally refuses to lower. Lives here (not on the
@@ -518,6 +528,355 @@ export function Score(props: { value?: number }) {
518
528
  expect(floatTypes).not.toContain('value := in.Value')
519
529
  expect(floatTypes).toContain('Value: in.Value,')
520
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
+ })
521
880
  })
522
881
 
523
882
  describe('JSX children forwarding (#1203)', () => {
@@ -1897,3 +2256,176 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
1897
2256
  })
1898
2257
  }
1899
2258
  })
2259
+
2260
+ // =============================================================================
2261
+ // #1448 — `/* @client */` escape hatch for STILL-UNSUPPORTED methods
2262
+ // =============================================================================
2263
+ //
2264
+ // The catalogue in #1448 documents `/* @client */` as the universal
2265
+ // workaround for any Array/String method shape the template adapters
2266
+ // can't lower. This block pins that contract for the Go adapter: for
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.
2272
+ //
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.
2281
+ describe('GoTemplateAdapter - #1448 @client escape hatch (unsupported methods)', () => {
2282
+ // Compile a single expression placed in `<div>` text position, with
2283
+ // and without the directive, and return both the build errors and
2284
+ // the emitted template.
2285
+ function emit(expr: string, client: boolean) {
2286
+ const marker = client ? '/* @client */ ' : ''
2287
+ const adapter = new GoTemplateAdapter()
2288
+ const ir = compileToIR(`
2289
+ "use client"
2290
+ import { createSignal } from "@barefootjs/client"
2291
+ export function C() {
2292
+ const [items, setItems] = createSignal<{ name: string; n: number; tags: string[] }[]>([])
2293
+ const [name, setName] = createSignal("x")
2294
+ const myCmp = (a: { n: number }, b: { n: number }) => a.n - b.n
2295
+ return <div>{${marker}${expr}}</div>
2296
+ }
2297
+ `, adapter)
2298
+ const template = adapter.generate(ir).template ?? ''
2299
+ return { errors: adapter.errors ?? [], template }
2300
+ }
2301
+
2302
+ // Same shape but the expression is a `.map()` chain that renders a
2303
+ // loop (sort follow-ups land here). The client placeholder is a
2304
+ // loop comment rather than a text comment.
2305
+ function emitLoop(chain: string, client: boolean) {
2306
+ const marker = client ? '/* @client */ ' : ''
2307
+ const adapter = new GoTemplateAdapter()
2308
+ const ir = compileToIR(`
2309
+ "use client"
2310
+ import { createSignal } from "@barefootjs/client"
2311
+ export function C() {
2312
+ const [items, setItems] = createSignal<{ name: string; n: number }[]>([])
2313
+ const myCmp = (a: { n: number }, b: { n: number }) => a.n - b.n
2314
+ return <ul>{${marker}${chain}}</ul>
2315
+ }
2316
+ `, adapter)
2317
+ const template = adapter.generate(ir).template ?? ''
2318
+ return { errors: adapter.errors ?? [], template }
2319
+ }
2320
+
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' },
2340
+ ]
2341
+ for (const { name, expr, badEmit } of unsupported) {
2342
+ test(`.${name}: bare raises BF101, @client clears it + emits client placeholder`, () => {
2343
+ const bare = emit(expr, false)
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)
2348
+
2349
+ const guarded = emit(expr, true)
2350
+ expect(guarded.errors).toEqual([])
2351
+ // Client-only text slot → `{{bfComment "client:sN"}}` placeholder.
2352
+ expect(guarded.template).toMatch(/bfComment "client:s\d+"/)
2353
+ expect(guarded.template).not.toContain(badEmit)
2354
+ })
2355
+ }
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
+
2376
+ // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
2377
+ const unsupportedSort: Array<[string, string]> = [
2378
+ ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
2379
+ ['localeCompare locale/options arg', `items().toSorted((a, b) => a.name.localeCompare(b.name, "ja", { numeric: true })).map(x => <li key={x.name}>{x.name}</li>)`],
2380
+ ]
2381
+ for (const [label, chain] of unsupportedSort) {
2382
+ test(`sort follow-up (${label}): bare raises BF021, @client clears it`, () => {
2383
+ const bare = compileJSX(`
2384
+ "use client"
2385
+ import { createSignal } from "@barefootjs/client"
2386
+ export function C() {
2387
+ const [items, setItems] = createSignal<{ name: string; n: number }[]>([])
2388
+ const myCmp = (a: { n: number }, b: { n: number }) => a.n - b.n
2389
+ return <ul>{${chain}}</ul>
2390
+ }
2391
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter() })
2392
+ expect(bare.errors?.some(e => e.code === 'BF021')).toBe(true)
2393
+
2394
+ const guarded = emitLoop(chain, true)
2395
+ expect(guarded.errors).toEqual([])
2396
+ // Client-only loop → `{{bfComment "loop:lN"}}…{{bfComment "/loop:lN"}}`.
2397
+ expect(guarded.template).toMatch(/bfComment "loop:l\d+"/)
2398
+ })
2399
+ }
2400
+
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: `
2413
+ "use client"
2414
+ import { createSignal } from "@barefootjs/client"
2415
+ export function C() {
2416
+ const [name, setName] = createSignal("hello")
2417
+ return <div>{/* @client */ name().repeat(3)}</div>
2418
+ }
2419
+ `.trimStart(),
2420
+ adapter: new GoTemplateAdapter(),
2421
+ })
2422
+ expect(html).toContain('<!--bf-client:s0-->')
2423
+ } catch (err) {
2424
+ if (err instanceof GoNotAvailableError) {
2425
+ console.log('Skipping #1448 @client e2e: go command not found')
2426
+ return
2427
+ }
2428
+ throw err
2429
+ }
2430
+ })
2431
+ })