Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions ui/state_semantics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//go:build !js || !wasm

package ui_test

import (
"fmt"
"testing"

"github.com/monstercameron/GoWebComponents/internal/platform/mockdom"
"github.com/monstercameron/GoWebComponents/internal/runtime"
)

// stateHarness mounts a single stateful component and exposes its first state
// hook for driving. The component re-renders synchronously (nil scheduler).
type stateHarness struct {
adapter *mockdom.MockDOMAdapter
rt *runtime.Runtime
root runtime.DOMNode
}

func newStateHarness() *stateHarness {
adapter := mockdom.NewMockDOMAdapter()
return &stateHarness{
adapter: adapter,
rt: runtime.NewRuntime(runtime.Config{DOMAdapter: adapter, Reset: true}),
root: adapter.CreateElement("div"),
}
}

func (parseH *stateHarness) text() string {
parseKids := parseH.adapter.GetChildren(parseH.root)
if len(parseKids) == 0 {
return ""
}
parseNode, _ := parseKids[0].(*mockdom.MockDOMNode)
for parseNode != nil && parseNode.TextContent == "" && len(parseNode.Children) > 0 {
parseNode = parseNode.Children[0]
}
if parseNode == nil {
return ""
}
return parseNode.TextContent
}

// TestStateFunctionalUpdatesCompose: chained functional updaters compose, and a
// plain value can follow an updater.
func TestStateFunctionalUpdatesCompose(t *testing.T) {
parseH := newStateHarness()
var parseSet func(any)
parseH.rt.RenderInto(parseH.root, runtime.CreateElement(func() *runtime.Element {
parseGet, parseSetter := runtime.GoUseState(parseH.rt, 0)
parseSet = parseSetter
return runtime.CreateElement("span", map[string]any{}, fmt.Sprint(parseGet()))
}, map[string]any{}))

parseSet(func(parseP int) int { return parseP + 1 })
parseSet(func(parseP int) int { return parseP + 1 })
if parseH.text() != "2" {
t.Errorf("2x +1 = %q, want 2", parseH.text())
}
parseSet(10)
parseSet(func(parseP int) int { return parseP + 5 })
if parseH.text() != "15" {
t.Errorf("set(10);+5 = %q, want 15", parseH.text())
}
}

// TestStateGetterIsLiveRead documents GWC's design choice (a deliberate
// difference from React's per-render snapshot): the getter returned by GoUseState
// reads the current state live, so a getter captured at an earlier render returns
// the latest value rather than a stale snapshot. This avoids React's stale-closure
// footgun and is why functional updates / render-phase convergence read fresh
// state.
func TestStateGetterIsLiveRead(t *testing.T) {
parseH := newStateHarness()
var parseCaptured func() int
var parseSet func(any)
parseRenders := 0
parseH.rt.RenderInto(parseH.root, runtime.CreateElement(func() *runtime.Element {
parseRenders++
parseGet, parseSetter := runtime.GoUseState(parseH.rt, 0)
parseSet = parseSetter
if parseRenders == 1 {
parseCaptured = parseGet
}
return runtime.CreateElement("span", map[string]any{}, fmt.Sprint(parseGet()))
}, map[string]any{}))

if parseCaptured() != 0 {
t.Fatalf("initial captured getter = %d, want 0", parseCaptured())
}
parseSet(42)
if parseCaptured() != 42 {
t.Errorf("captured getter after set = %d; GWC getter is a live read, want 42", parseCaptured())
}
}

// TestStateReferenceVsValueDedup: slice/map state (non-comparable, reference-like)
// re-renders on a new equal-content value (like React's Object.is on a new
// object), while a comparable struct value dedups on equality (Go value
// semantics).
func TestStateReferenceVsValueDedup(t *testing.T) {
// Slice: new equal-content slice re-renders.
parseSliceH := newStateHarness()
var parseSetSlice func(any)
parseSliceRenders := 0
parseSliceH.rt.RenderInto(parseSliceH.root, runtime.CreateElement(func() *runtime.Element {
parseSliceRenders++
parseGet, parseSetter := runtime.GoUseState(parseSliceH.rt, []int{1, 2, 3})
parseSetSlice = parseSetter
return runtime.CreateElement("span", map[string]any{}, fmt.Sprint(len(parseGet())))
}, map[string]any{}))
parseSetSlice([]int{1, 2, 3})
if parseSliceRenders != 2 {
t.Errorf("slice: new equal-content value should re-render (renders=%d, want 2)", parseSliceRenders)
}

// Struct: equal value dedups (no re-render).
type parsePoint struct{ X, Y int }
parseStructH := newStateHarness()
var parseSetPt func(any)
parseStructRenders := 0
parseStructH.rt.RenderInto(parseStructH.root, runtime.CreateElement(func() *runtime.Element {
parseStructRenders++
parseGet, parseSetter := runtime.GoUseState(parseStructH.rt, parsePoint{1, 2})
parseSetPt = parseSetter
return runtime.CreateElement("span", map[string]any{}, fmt.Sprint(parseGet()))
}, map[string]any{}))
parseSetPt(parsePoint{1, 2})
if parseStructRenders != 1 {
t.Errorf("struct: equal value should dedup (renders=%d, want 1)", parseStructRenders)
}
parseSetPt(parsePoint{3, 4})
if parseStructRenders != 2 {
t.Errorf("struct: changed value should re-render (renders=%d, want 2)", parseStructRenders)
}
}

// TestStateFunctionValueStorable: a function can be stored as state — the setter
// distinguishes a functional updater (func(T) T) from a plain function value.
func TestStateFunctionValueStorable(t *testing.T) {
parseH := newStateHarness()
var parseSet func(any)
parseH.rt.RenderInto(parseH.root, runtime.CreateElement(func() *runtime.Element {
parseGet, parseSetter := runtime.GoUseState[func() string](parseH.rt, func() string { return "initial" })
parseSet = parseSetter
parseFn := parseGet()
parseLabel := "<nil>"
if parseFn != nil {
parseLabel = parseFn()
}
return runtime.CreateElement("span", map[string]any{}, parseLabel)
}, map[string]any{}))

if parseH.text() != "initial" {
t.Fatalf("mount = %q, want initial", parseH.text())
}
parseSet(func() string { return "stored" })
if parseH.text() != "stored" {
t.Errorf("after storing a function value = %q, want stored", parseH.text())
}
}