@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.
- package/dist/adapter/go-template-adapter.d.ts +116 -0
- package/dist/adapter/go-template-adapter.d.ts.map +1 -1
- package/dist/adapter/index.js +243 -33
- package/dist/build.js +243 -33
- package/dist/index.js +243 -33
- package/package.json +2 -2
- package/src/__tests__/go-template-adapter.test.ts +532 -0
- package/src/adapter/go-template-adapter.ts +427 -41
|
@@ -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
|
+
})
|