state API

state

package

API reference for the state package.

F
function

reportCallbackPanic

Parameters

recovered
any
context
string
stack
[]byte
state/recovery.go:13-25
func reportCallbackPanic(recovered any, context string, stack []byte)

{
	if OnCallbackPanic == nil {
		log.Printf("[rfw] recovered panic in %s: %v\n%s", context, recovered, stack)
		return
	}
	defer func() {
		if reporterPanic := recover(); reporterPanic != nil {
			log.Printf("[rfw] callback panic reporter failed: %v", reporterPanic)
			log.Printf("[rfw] recovered panic in %s: %v\n%s", context, recovered, stack)
		}
	}()
	OnCallbackPanic(recovered, context, stack)
}
F
function

runCallback

Parameters

context
string
fn
func()

Returns

ok
bool
state/recovery.go:27-37
func runCallback(context string, fn func()) (ok bool)

{
	ok = true
	defer func() {
		if recovered := recover(); recovered != nil {
			ok = false
			reportCallbackPanic(recovered, context, debug.Stack())
		}
	}()
	fn()
	return ok
}
F
function

captureValuePanic

Parameters

fn
func() T

Returns

value
T
recovered
any
stack
[]byte
state/recovery.go:39-48
func captureValuePanic[T any](fn func() T) (value T, recovered any, stack []byte)

{
	defer func() {
		if value := recover(); value != nil {
			recovered = value
			stack = debug.Stack()
		}
	}()
	value = fn()
	return value, nil, nil
}
T
type

Context

Context is an alias of context.Context used by Actions.
This allows the API to remain stable if a custom context is needed later.

state/action.go:7-7
type Context context.Context
T
type

Action

Action represents a unit of work executed with a Context.
It returns an error if the action fails.

state/action.go:11-11
type Action func(ctx Context) error
F
function

Dispatch

Dispatch executes the given Action with the provided context.
If the action is nil it is a no-op and nil is returned.

Parameters

ctx
a

Returns

error
state/action.go:15-20
func Dispatch(ctx Context, a Action) error

{
	if a == nil {
		return nil
	}
	return a(ctx)
}
F
function

UseAction

UseAction binds an Action to a Context and returns a function
that executes the action when invoked. It can be used in places
that expect a simple callback.

Parameters

ctx
a

Returns

func()
error
state/action.go:25-29
func UseAction(ctx Context, a Action) func() error

{
	return func() error {
		return Dispatch(ctx, a)
	}
}
F
function

loadPersistedState

Parameters

string

Returns

map[string]any
state/persistence_universal.go:5-5
func loadPersistedState(string) map[string]any

{ return nil }
F
function

saveState

Parameters

string
map[string]any
state/persistence_universal.go:6-6
func saveState(string, map[string]any)

{}
F
function

TestStoreConcurrentAccess

Stores are advertised for goroutine-driven feeds (dashboards, tickers), so
concurrent Set/Get/OnChange/Snapshot/history must be data-race free under
go test -race.

Parameters

state/race_test.go:12-45
func TestStoreConcurrentAccess(_ *testing.T)

{
	s := NewStore("racestore", WithModule("race"), WithHistory(8))
	s.RegisterComputed(NewComputed("double", []string{"n"}, func(m map[string]any) any {
		if v, ok := m["n"].(int); ok {
			return v * 2
		}
		return 0
	}))
	unwatch := s.RegisterWatcher(NewWatcher([]string{"n"}, func(m map[string]any) {
		_ = m["n"]
	}))
	defer unwatch()

	var wg sync.WaitGroup
	for g := 0; g < 4; g++ {
		wg.Add(1)
		go func(g int) {
			defer wg.Done()
			for i := 0; i < 200; i++ {
				s.Set("n", i)
				s.Set(fmt.Sprintf("k%d", g), i)
				_ = s.Get("n")
				_ = s.Snapshot()
				if i%50 == 0 {
					unsub := s.OnChange("n", func(any) {})
					unsub()
					s.Undo()
					s.Redo()
				}
			}
		}(g)
	}
	wg.Wait()
}
F
function

TestSignalConcurrentAccess

Signals may be fed from background goroutines while effects and readers run
elsewhere; Get/Set/OnChange must be data-race free under go test -race.

Parameters

state/race_test.go:49-73
func TestSignalConcurrentAccess(_ *testing.T)

{
	sig := NewSignal(0)
	stop := Effect(func() func() {
		_ = sig.Get()
		return nil
	})
	defer stop()
	sub := sig.OnChange(func(int) {})
	defer sub.Stop()

	var wg sync.WaitGroup
	for g := 0; g < 4; g++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < 200; i++ {
				sig.Set(i)
				_ = sig.Get()
				_ = sig.Read()
				sig.SetFromHost(float64(i))
			}
		}()
	}
	wg.Wait()
}
F
function

TestReactiveVarInt

Parameters

state/reactive_test.go:8-19
func TestReactiveVarInt(t *testing.T)

{
	rv := NewReactiveVar(0)
	var changed int
	rv.OnChange(func(v int) { changed = v })
	rv.Set(42)
	if got := rv.Get(); got != 42 {
		t.Fatalf("expected Get to return 42, got %d", got)
	}
	if changed != 42 {
		t.Fatalf("expected OnChange to fire with 42, got %d", changed)
	}
}
S
struct

sample

state/reactive_test.go:21-24
type sample struct

Fields

Name Type Description
A int
B string
F
function

TestReactiveVarStruct

Parameters

state/reactive_test.go:26-39
func TestReactiveVarStruct(t *testing.T)

{
	initial := sample{A: 1, B: "foo"}
	rv := NewReactiveVar(initial)
	var changed sample
	rv.OnChange(func(s sample) { changed = s })
	newVal := sample{A: 2, B: "bar"}
	rv.Set(newVal)
	if got := rv.Get(); !reflect.DeepEqual(got, newVal) {
		t.Fatalf("expected Get to return %v, got %v", newVal, got)
	}
	if !reflect.DeepEqual(changed, newVal) {
		t.Fatalf("expected OnChange to receive %v, got %v", newVal, changed)
	}
}
T
type

ResourceStatus

ResourceStatus describes the current resource state.

state/resource.go:19-19
type ResourceStatus string
S
struct

resourceConfig

state/resource.go:32-36
type resourceConfig struct

Fields

Name Type Description
key string
ttl time.Duration
immediate bool
T
type

ResourceOption

ResourceOption configures a Resource.

state/resource.go:39-39
type ResourceOption func(*resourceConfig)
F
function

WithResourceKey

WithResourceKey enables request deduplication and caching for key.

Parameters

key
string

Returns

state/resource.go:42-44
func WithResourceKey(key string) ResourceOption

{
	return func(config *resourceConfig) { config.key = key }
}
F
function

WithResourceTTL

WithResourceTTL expires a keyed cache entry after ttl.

Parameters

Returns

state/resource.go:47-49
func WithResourceTTL(ttl time.Duration) ResourceOption

{
	return func(config *resourceConfig) { config.ttl = ttl }
}
F
function

WithoutImmediateLoad

WithoutImmediateLoad leaves a resource idle until Load is called.

Returns

state/resource.go:52-54
func WithoutImmediateLoad() ResourceOption

{
	return func(config *resourceConfig) { config.immediate = false }
}
S
struct

resourceCacheEntry

state/resource.go:56-59
type resourceCacheEntry struct

Fields

Name Type Description
value any
expires time.Time
S
struct

resourceFlight

state/resource.go:61-68
type resourceFlight struct

Fields

Name Type Description
done chan struct{}
cancel context.CancelFunc
value any
err error
waiters int
closed bool
F
function

ClearResourceCache

ClearResourceCache removes a shared resource cache entry.

Parameters

key
string
state/resource.go:80-84
func ClearResourceCache(key string)

{
	resourceShared.Lock()
	delete(resourceShared.cache, key)
	resourceShared.Unlock()
}
S
struct

Resource

Resource wraps cancellable asynchronous data in reactive signals.

state/resource.go:87-99
type Resource struct

Fields

Name Type Description
mu sync.Mutex
loader func(context.Context) (T, error)
key string
ttl time.Duration
generation uint64
cancel context.CancelFunc
closed bool
value *Signal[T]
status *Signal[ResourceStatus]
err *Signal[error]
F
function

NewResource

NewResource creates a resource and starts loading by default.

Parameters

loader
func(context.Context) (T, error)
opts
...ResourceOption

Returns

*Resource[T]
state/resource.go:102-119
func NewResource[T any](loader func(context.Context) (T, error), opts ...ResourceOption) *Resource[T]

{
	config := resourceConfig{immediate: true}
	for _, opt := range opts {
		opt(&config)
	}
	resource := &Resource[T]{
		loader: loader,
		key:    config.key,
		ttl:    config.ttl,
		value:  NewSignal(*new(T)),
		status: NewSignal(ResourceIdle),
		err:    NewSignal[error](nil),
	}
	if config.immediate {
		resource.Load(context.Background())
	}
	return resource
}
F
function

loadResourceCache

Parameters

key
string

Returns

T
bool
state/resource.go:165-182
func loadResourceCache[T any](key string) (T, bool)

{
	var zero T
	if key == "" {
		return zero, false
	}
	resourceShared.Lock()
	defer resourceShared.Unlock()
	entry, ok := resourceShared.cache[key]
	if !ok {
		return zero, false
	}
	if !entry.expires.IsZero() && time.Now().After(entry.expires) {
		delete(resourceShared.cache, key)
		return zero, false
	}
	value, ok := entry.value.(T)
	return value, ok
}
F
function

acquireResourceFlight

Parameters

key
string
loader
func(context.Context) (T, error)

Returns

state/resource.go:184-214
func acquireResourceFlight[T any](ctx context.Context, key string, loader func(context.Context) (T, error)) *resourceFlight

{
	resourceShared.Lock()
	if key != "" {
		if flight := resourceShared.flights[key]; flight != nil {
			flight.waiters++
			resourceShared.Unlock()
			return flight
		}
	}
	base := context.WithoutCancel(ctx)
	flightCtx, cancel := context.WithCancel(base)
	flight := &resourceFlight{done: make(chan struct{}), cancel: cancel, waiters: 1}
	if key != "" {
		resourceShared.flights[key] = flight
	}
	resourceShared.Unlock()

	go func() {
		value, err := runResourceLoader(flightCtx, loader)
		resourceShared.Lock()
		flight.value = value
		flight.err = err
		flight.closed = true
		if key != "" && resourceShared.flights[key] == flight {
			delete(resourceShared.flights, key)
		}
		close(flight.done)
		resourceShared.Unlock()
	}()
	return flight
}
F
function

runResourceLoader

Parameters

loader
func(context.Context) (T, error)

Returns

value
T
err
error
state/resource.go:216-223
func runResourceLoader[T any](ctx context.Context, loader func(context.Context) (T, error)) (value T, err error)

{
	defer func() {
		if recovered := recover(); recovered != nil {
			err = fmt.Errorf("state: resource loader panic: %v", recovered)
		}
	}()
	return loader(ctx)
}
F
function

waitResourceFlight

Parameters

Returns

T
error
state/resource.go:225-248
func waitResourceFlight[T any](ctx context.Context, flight *resourceFlight) (T, error)

{
	var zero T
	select {
	case <-flight.done:
		resourceShared.Lock()
		value := flight.value
		err := flight.err
		flight.waiters--
		resourceShared.Unlock()
		typed, ok := value.(T)
		if !ok && err == nil {
			return zero, fmt.Errorf("state: resource result type mismatch")
		}
		return typed, err
	case <-ctx.Done():
		resourceShared.Lock()
		flight.waiters--
		if flight.waiters == 0 && !flight.closed {
			flight.cancel()
		}
		resourceShared.Unlock()
		return zero, ctx.Err()
	}
}
S
struct

effect

effect represents a reactive computation registered via Effect.

state/signal.go:10-16
type effect struct

Methods

detach
Method

detach runs the pending cleanup and unsubscribes the effect from all of its dependencies, leaving it ready to re-track (runEffect) or stopped for good.

func (*effect) detach()
{
	e.mu.Lock()
	cleanup := e.cleanup
	e.cleanup = nil
	deps := e.deps
	e.deps = nil
	e.mu.Unlock()
	if cleanup != nil {
		runCallback("signal effect cleanup", cleanup)
	}
	for _, dep := range deps {
		dep.remove(e)
	}
}
runEffect
Method
func (*effect) runEffect()
{
	e.detach()
	prev := currentEffect.Load()
	currentEffect.Store(e)
	cleanup, recovered, stack := captureValuePanic(e.run)
	currentEffect.Store(prev)
	if recovered != nil {
		reportCallbackPanic(recovered, "signal effect", stack)
		cleanup = nil
	}
	e.mu.Lock()
	e.cleanup = cleanup
	e.mu.Unlock()
}
stop
Method
func (*effect) stop()
{
	e.detach()
}

Fields

Name Type Description
run func() func()
mu sync.Mutex
deps []subscriber
cleanup func()
I
interface

subscriber

state/signal.go:18-20
type subscriber interface

Methods

remove
Method

Parameters

func remove(...)
S
struct

Subscription

Subscription represents a cancellable listener returned by OnChange.

state/signal.go:37-40
type Subscription struct

Methods

Stop
Method

Stop removes the listener and releases the associated channel.

func (*Subscription) Stop()
{
	s.once.Do(s.cancel)
}

Fields

Name Type Description
cancel func()
once sync.Once
S
struct

Signal

Signal holds a value of type T and tracks which effects depend on it.
Get/Set are safe for concurrent use, so background goroutines may feed a
signal; dependent effects run synchronously on the goroutine calling Set.

state/signal.go:50-59
type Signal struct

Fields

Name Type Description
mu sync.Mutex
value T
subs map[*effect]struct{}
onChangeMu sync.Mutex
onChange []func(T)
ch chan T
chCreated bool
F
function

NewSignal

NewSignal creates a new Signal with the given initial value.

Parameters

initial
T

Returns

*Signal[T]
state/signal.go:62-64
func NewSignal[T any](initial T) *Signal[T]

{
	return &Signal[T]{value: initial, subs: make(map[*effect]struct{})}
}
F
function

Effect

Effect registers a reactive computation that automatically re-runs when its
dependent signals change. The provided function may return a cleanup function
that will run before the next execution and when the effect is stopped.

Parameters

fn
func() func()

Returns

func()
state/signal.go:295-299
func Effect(fn func() func()) func()

{
	e := &effect{run: fn}
	e.runEffect()
	return e.stop
}
F
function

Batch

Batch defers dependent effects until fn completes and runs each effect once.

Parameters

fn
func()
state/signal.go:302-311
func Batch(fn func())

{
	if fn == nil {
		return
	}
	effectScheduler.Lock()
	effectScheduler.depth++
	effectScheduler.Unlock()
	defer flushBatch()
	fn()
}
F
function

scheduleEffect

Parameters

e
state/signal.go:313-322
func scheduleEffect(e *effect)

{
	effectScheduler.Lock()
	if effectScheduler.depth > 0 {
		effectScheduler.pending[e] = struct{}{}
		effectScheduler.Unlock()
		return
	}
	effectScheduler.Unlock()
	e.runEffect()
}
F
function

flushBatch

state/signal.go:324-340
func flushBatch()

{
	effectScheduler.Lock()
	effectScheduler.depth--
	if effectScheduler.depth > 0 {
		effectScheduler.Unlock()
		return
	}
	pending := make([]*effect, 0, len(effectScheduler.pending))
	for effect := range effectScheduler.pending {
		pending = append(pending, effect)
	}
	clear(effectScheduler.pending)
	effectScheduler.Unlock()
	for _, effect := range pending {
		effect.runEffect()
	}
}
F
function

Untracked

Untracked evaluates fn without subscribing the current effect.

Parameters

fn
func() T

Returns

T
state/signal.go:343-347
func Untracked[T any](fn func() T) T

{
	previous := currentEffect.Swap(nil)
	defer currentEffect.Store(previous)
	return fn()
}
S
struct

MemoValue

MemoValue is a read-only signal derived from other signals.

state/signal.go:350-353
type MemoValue struct

Fields

Name Type Description
signal *Signal[T]
stop func()
F
function

Memo

Memo creates a cached derivation and tracks every signal read by compute.

Parameters

compute
func() T

Returns

*MemoValue[T]
state/signal.go:356-365
func Memo[T any](compute func() T) *MemoValue[T]

{
	var zero T
	signal := NewSignal(zero)
	memo := &MemoValue[T]{signal: signal}
	memo.stop = Effect(func() func() {
		signal.Set(compute())
		return nil
	})
	return memo
}
F
function

valEqual

Parameters

a
any
b
any

Returns

bool
state/store.go:10-44
func valEqual(a, b any) bool

{
	if a == nil && b == nil {
		return true
	}
	if a == nil || b == nil {
		return false
	}
	switch av := a.(type) {
	case string:
		if bv, ok := b.(string); ok {
			return av == bv
		}
	case int:
		if bv, ok := b.(int); ok {
			return av == bv
		}
	case float64:
		if bv, ok := b.(float64); ok {
			return av == bv
		}
	case bool:
		if bv, ok := b.(bool); ok {
			return av == bv
		}
	case int64:
		if bv, ok := b.(int64); ok {
			return av == bv
		}
	case float32:
		if bv, ok := b.(float32); ok {
			return av == bv
		}
	}
	return reflect.DeepEqual(a, b)
}
F
function

depsChanged

Parameters

current
map[string]any
last
map[string]any

Returns

bool
state/store.go:46-56
func depsChanged(current, last map[string]any) bool

{
	if len(current) != len(last) {
		return true
	}
	for k, v := range current {
		if lv, ok := last[k]; !ok || !valEqual(v, lv) {
			return true
		}
	}
	return false
}
I
interface

Logger

Logger receives debug messages from stores.

state/store.go:59-61
type Logger interface

Methods

Debug
Method

Parameters

format string
args ...any
func Debug(...)
S
struct
Implements: Logger

defaultLogger

state/store.go:63-63
type defaultLogger struct

Methods

Debug
Method

Parameters

format string
args ...any
func (defaultLogger) Debug(format string, args ...any)
{ log.Printf(format, args...) }
F
function

SetLogger

SetLogger replaces the logger used by stores.

Parameters

l
state/store.go:70-70
func SetLogger(l Logger)

{ logger = l }
T
type

StoreOption

StoreOption configures optional behaviour for a Store during creation.

state/store.go:78-78
type StoreOption func(*Store)
F
function

WithModule

WithModule namespaces a store under the provided module.

Parameters

module
string

Returns

state/store.go:81-81
func WithModule(module string) StoreOption

{ return func(s *Store) { s.module = module } }
F
function

WithPersistence

WithPersistence enables localStorage persistence for the store.

Returns

state/store.go:84-84
func WithPersistence() StoreOption

{ return func(s *Store) { s.persist = true } }
F
function

WithDevTools

WithDevTools enables logging of state mutations for development.

Returns

state/store.go:87-87
func WithDevTools() StoreOption

{ return func(s *Store) { s.devTools = true } }
F
function

WithHistory

WithHistory enables mutation history with the provided limit.
The limit controls how many past mutations are retained for undo/redo.

Parameters

limit
int

Returns

state/store.go:91-97
func WithHistory(limit int) StoreOption

{
	return func(s *Store) {
		if limit > 0 {
			s.historyLimit = limit
		}
	}
}
S
struct

Store

Store holds keyed state with listeners, computed values, watchers and
optional history. All methods are safe for concurrent use: internal state is
mutex-protected, and listeners/watchers are invoked outside the lock (on the
goroutine that called Set), so they may call back into the store. Computed
functions run under the lock and must only read the state map they receive,
never call store methods.

state/store.go:105-120
type Store struct

Methods

Module
Method

Module reports the module namespace of the store.

Returns

string
func (*Store) Module() string
{ return s.module }
Name
Method

Name returns the store name within its module namespace.

Returns

string
func (*Store) Name() string
{ return s.name }
Snapshot
Method

Snapshot copies the current state of the store.

Returns

map[string]any
func (*Store) Snapshot() map[string]any
{
	s.mu.RLock()
	defer s.mu.RUnlock()
	snap := make(map[string]any, len(s.state))
	for k, v := range s.state {
		snap[k] = v
	}
	return snap
}
storageKey
Method

Returns

string
func (*Store) storageKey() string
{ return s.module + ":" + s.name }
Set
Method

Set stores a value and notifies dependents.

Parameters

key string
value any
func (*Store) Set(key string, value any)
{
	s.set(key, value, true)
}
set
Method

set applies a mutation under the lock, then fires listeners, watchers and persistence outside it so callbacks can safely call back into the store.

Parameters

key string
value any
recordHistory bool
func (*Store) set(key string, value any, recordHistory bool)
{
	s.mu.Lock()
	old := s.state[key]
	s.state[key] = value
	if recordHistory && s.historyLimit > 0 {
		s.history = append(s.history, &mutation{key: key, previous: old, next: value})
		if len(s.history) > s.historyLimit {
			s.history = s.history[len(s.history)-s.historyLimit:]
		}
		s.future = nil
	}
	notifs := s.listenerNotifsLocked(key, value)
	notifs = append(notifs, s.evaluateDependentsLocked(key)...)
	var persisted map[string]any
	if s.persist {
		persisted = make(map[string]any, len(s.state))
		for k, v := range s.state {
			persisted[k] = v
		}
	}
	s.mu.Unlock()

	if s.devTools {
		logger.Debug("%s/%s -> %s: %v", s.module, s.name, key, value)
	}
	if StoreHook != nil {
		runCallback("store mutation hook: "+s.module+"."+s.name+"."+key, func() {
			StoreHook(s.module, s.name, key, value)
		})
	}
	for _, fn := range notifs {
		runCallback("store notification: "+s.module+"."+s.name+"."+key, fn)
	}
	if persisted != nil {
		runCallback("store persistence: "+s.storageKey(), func() {
			saveState(s.storageKey(), persisted)
		})
	}
}

listenerNotifsLocked snapshots the listeners registered for key as notification closures. Callers must hold s.mu.

Parameters

key string
value any

Returns

[]func()
func (*Store) listenerNotifsLocked(key string, value any) []func()
{
	listeners, exists := s.listeners[key]
	if !exists {
		return nil
	}
	notifs := make([]func(), 0, len(listeners))
	for _, listener := range listeners {
		l := listener
		notifs = append(notifs, func() { l(value) })
	}
	return notifs
}
Get
Method

Get returns the value stored under key.

Parameters

key string

Returns

any
func (*Store) Get(key string) any
{
	if s.devTools {
		logger.Debug("Getting %s from %s/%s", key, s.module, s.name)
	}
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.state[key]
}
Undo
Method

Undo reverts the last mutation recorded in the store's history.

func (*Store) Undo()
{
	s.mu.Lock()
	if len(s.history) == 0 {
		s.mu.Unlock()
		return
	}
	m := s.history[len(s.history)-1]
	s.history = s.history[:len(s.history)-1]
	s.future = append(s.future, m)
	s.mu.Unlock()
	s.set(m.key, m.previous, false)
}
Redo
Method

Redo reapplies the last mutation that was undone.

func (*Store) Redo()
{
	s.mu.Lock()
	if len(s.future) == 0 {
		s.mu.Unlock()
		return
	}
	m := s.future[len(s.future)-1]
	s.future = s.future[:len(s.future)-1]
	s.history = append(s.history, m)
	if s.historyLimit > 0 && len(s.history) > s.historyLimit {
		s.history = s.history[len(s.history)-s.historyLimit:]
	}
	s.mu.Unlock()
	s.set(m.key, m.next, false)
}
OnChange
Method

OnChange registers a listener and returns its unsubscribe function.

Parameters

key string
listener func(any)

Returns

func()
func (*Store) OnChange(key string, listener func(any)) func()
{
	s.mu.Lock()
	if s.listeners[key] == nil {
		s.listeners[key] = make(map[int]func(any))
	}
	s.listenerID++
	id := s.listenerID
	s.listeners[key][id] = listener
	s.mu.Unlock()

	if s.devTools {
		logger.Debug("[rfw] store %s.%s: listener registered for key %s", s.module, s.name, key)
	}

	return func() {
		s.mu.Lock()
		delete(s.listeners[key], id)
		s.mu.Unlock()
	}
}

RegisterComputed registers a computed value on the store. The computed value is evaluated immediately and whenever one of its dependencies changes. The compute function runs under the store lock: it must only read the state map it receives and never call store methods.

Parameters

func (*Store) RegisterComputed(c *Computed)
{
	s.mu.Lock()
	s.computeds[c.Key()] = c
	val, recovered, stack := captureValuePanic(func() any {
		return c.Evaluate(s.state)
	})
	if recovered == nil {
		s.state[c.Key()] = val
		c.lastDeps = snapshotDeps(s.state, c.Deps())
	}
	s.mu.Unlock()
	if recovered != nil {
		reportCallbackPanic(recovered, "store computed: "+s.module+"."+s.name+"."+c.Key(), stack)
	}
}

RegisterWatcher registers a watcher that triggers when any of its dependencies change. If the dependency list is empty the watcher is triggered on every state update. It returns a function that removes the watcher.

Parameters

w *Watcher

Returns

func()
func (*Store) RegisterWatcher(w *Watcher) func()
{
	s.mu.Lock()
	s.watchers = append(s.watchers, w)
	var snap map[string]any
	if w.immediate {
		snap = s.snapshotLocked()
	}
	s.mu.Unlock()
	if w.immediate {
		runCallback("store watcher: "+s.module+"."+s.name, func() { w.Run(snap) })
	}

	return func() {
		s.mu.Lock()
		defer s.mu.Unlock()
		for i, watcher := range s.watchers {
			if watcher == w {
				s.watchers = append(s.watchers[:i], s.watchers[i+1:]...)
				break
			}
		}
	}
}

snapshotLocked copies the state map. Callers must hold s.mu.

Returns

map[string]any
func (*Store) snapshotLocked() map[string]any
{
	snap := make(map[string]any, len(s.state))
	for k, v := range s.state {
		snap[k] = v
	}
	return snap
}

evaluateDependentsLocked re-evaluates computed values for a given key and collects listener/watcher notifications as closures to run after the lock is released. Watchers receive a consistent snapshot of the state taken at evaluation time. Callers must hold s.mu.

Parameters

key string

Returns

[]func()
func (*Store) evaluateDependentsLocked(key string) []func()
{
	var notifs []func()
	for _, c := range s.computeds {
		if contains(c.Deps(), key) {
			current := snapshotDeps(s.state, c.Deps())
			if c.lastDeps == nil || depsChanged(current, c.lastDeps) {
				val, recovered, stack := captureValuePanic(func() any {
					return c.Evaluate(s.state)
				})
				if recovered != nil {
					context := "store computed: " + s.module + "." + s.name + "." + c.Key()
					notifs = append(notifs, func() {
						reportCallbackPanic(recovered, context, stack)
					})
					continue
				}
				s.state[c.Key()] = val
				c.lastDeps = current
				notifs = append(notifs, s.listenerNotifsLocked(c.Key(), val)...)
				// propagate to computeds/watchers depending on this key
				notifs = append(notifs, s.evaluateDependentsLocked(c.Key())...)
			}
		}
	}
	var watcherSnap map[string]any
	runWatcher := func(w *Watcher) {
		if watcherSnap == nil {
			watcherSnap = s.snapshotLocked()
		}
		snap := watcherSnap
		notifs = append(notifs, func() { w.Run(snap) })
	}
	for _, w := range s.watchers {
		deps := w.Deps()
		if len(deps) == 0 {
			runWatcher(w)
			continue
		}
		for _, dep := range deps {
			if w.deep {
				if pathMatches(key, dep) {
					runWatcher(w)
					break
				}
			} else {
				if key == dep {
					runWatcher(w)
					break
				}
			}
		}
	}
	return notifs
}

Fields

Name Type Description
mu sync.RWMutex
module string
name string
state map[string]any
listeners map[string]map[int]func(any)
listenerID int
computeds map[string]*Computed
watchers []*Watcher
persist bool
devTools bool
history []*mutation
future []*mutation
historyLimit int
S
struct

mutation

state/store.go:122-126
type mutation struct

Fields

Name Type Description
key string
previous any
next any
S
struct

StoreManager

StoreManager groups stores by module and name.

state/store.go:129-132
type StoreManager struct

Methods

NewStore
Method

NewStore creates and registers a store in this manager.

Parameters

name string
opts ...StoreOption

Returns

func (*StoreManager) NewStore(name string, opts ...StoreOption) *Store
{
	store := &Store{
		module:    "default",
		name:      name,
		state:     make(map[string]any),
		listeners: make(map[string]map[int]func(any)),
		computeds: make(map[string]*Computed),
	}
	for _, opt := range opts {
		opt(store)
	}

	sm.RegisterStore(store.module, name, store)

	if store.persist {
		if state := loadPersistedState(store.storageKey()); state != nil {
			store.state = state
		}
	}

	return store
}
RegisterStore
Method

RegisterStore registers a store by module and name.

Parameters

module string
name string
store *Store
func (*StoreManager) RegisterStore(module, name string, store *Store)
{

	sm.mu.Lock()
	defer sm.mu.Unlock()

	if sm.modules[module] == nil {
		sm.modules[module] = make(map[string]*Store)
	}
	sm.modules[module][name] = store
}
GetStore
Method

GetStore returns a registered store or nil.

Parameters

module string
name string

Returns

func (*StoreManager) GetStore(module, name string) *Store
{

	sm.mu.RLock()
	defer sm.mu.RUnlock()

	if stores, ok := sm.modules[module]; ok {
		return stores[name]
	}
	return nil
}

UnregisterStore removes the store identified by module and name. If the store or module does not exist, it is a no-op.

Parameters

module string
name string
func (*StoreManager) UnregisterStore(module, name string)
{
	sm.mu.Lock()
	defer sm.mu.Unlock()
	if stores, ok := sm.modules[module]; ok {
		delete(stores, name)
		if len(stores) == 0 {
			delete(sm.modules, module)
		}
	}
}
Snapshot
Method

Snapshot returns a deep copy of all registered stores and their states.

Returns

map[string]map[string]map[string]any
func (*StoreManager) Snapshot() map[string]map[string]map[string]any
{
	snap := make(map[string]map[string]map[string]any)

	sm.mu.RLock()
	defer sm.mu.RUnlock()

	for module, stores := range sm.modules {
		snap[module] = make(map[string]map[string]any)
		for name, store := range stores {
			snap[module][name] = store.Snapshot()
		}
	}
	return snap
}
DumpState
Method

DumpState writes every registered store to the debug logger.

func (*StoreManager) DumpState()
{
	sm.mu.RLock()
	defer sm.mu.RUnlock()
	for mod, stores := range sm.modules {
		for name, st := range stores {
			logger.Debug("[rfw] DumpState %s/%s: %v", mod, name, st.Snapshot())
		}
	}
}

Fields

Name Type Description
mu sync.RWMutex
modules map[string]map[string]*Store
F
function

NewStoreManager

NewStoreManager creates a standalone manager for isolating store instances.

Returns

state/store.go:140-142
func NewStoreManager() *StoreManager

{
	return &StoreManager{modules: make(map[string]map[string]*Store)}
}
F
function

NewStore

NewStore creates a new store with the given name and optional configuration.
By default stores are registered under the “default” module.

Parameters

name
string
opts
...StoreOption

Returns

state/store.go:146-148
func NewStore(name string, opts ...StoreOption) *Store

{
	return GlobalStoreManager.NewStore(name, opts...)
}
F
function

Map

Map registers a computed value derived from a single dependency using a
strongly typed mapping function. The mapping function receives the current
value of the dependency and its result is stored under the provided key. If
the dependency cannot be asserted to the expected type, the zero value of the
return type is used instead.

Parameters

s
key
string
dep
string
compute
func(T) R
state/store.go:396-405
func Map[T, R any](s *Store, key, dep string, compute func(T) R)

{
	c := NewComputed(key, []string{dep}, func(m map[string]any) any {
		if v, ok := m[dep].(T); ok {
			return compute(v)
		}
		var zero R
		return zero
	})
	s.RegisterComputed(c)
}
F
function

Map2

Map2 registers a computed value derived from two dependencies. The mapping
function receives the current values of both dependencies and its result is
stored under the provided key. If any dependency fails type assertion the
zero value of the return type is used.

Parameters

s
key
string
depA
string
depB
string
compute
func(A, B) R
state/store.go:411-422
func Map2[A, B, R any](s *Store, key, depA, depB string, compute func(A, B) R)

{
	c := NewComputed(key, []string{depA, depB}, func(m map[string]any) any {
		a, okA := m[depA].(A)
		b, okB := m[depB].(B)
		if okA && okB {
			return compute(a, b)
		}
		var zero R
		return zero
	})
	s.RegisterComputed(c)
}
F
function

contains

Parameters

slice
[]string
item
string

Returns

bool
state/store.go:519-526
func contains(slice []string, item string) bool

{
	for _, s := range slice {
		if s == item {
			return true
		}
	}
	return false
}
F
function

pathMatches

Parameters

key
string
dep
string

Returns

bool
state/store.go:528-539
func pathMatches(key, dep string) bool

{
	if key == dep {
		return true
	}
	if strings.HasPrefix(key, dep+".") {
		return true
	}
	if strings.HasPrefix(dep, key+".") {
		return true
	}
	return false
}
F
function

snapshotDeps

Parameters

state
map[string]any
deps
[]string

Returns

map[string]any
state/store.go:541-547
func snapshotDeps(state map[string]any, deps []string) map[string]any

{
	snap := make(map[string]any, len(deps))
	for _, d := range deps {
		snap[d] = state[d]
	}
	return snap
}
F
function

TestUnregisterStore

Parameters

state/store_unregister_test.go:5-16
func TestUnregisterStore(t *testing.T)

{
	sm := &StoreManager{modules: make(map[string]map[string]*Store)}
	s := NewStore("test", WithModule("mod"))
	sm.RegisterStore("mod", "test", s)
	if sm.GetStore("mod", "test") == nil {
		t.Fatalf("expected store to be registered")
	}
	sm.UnregisterStore("mod", "test")
	if sm.GetStore("mod", "test") != nil {
		t.Fatalf("expected store to be unregistered")
	}
}
F
function

TestDispatch

Parameters

state/action_test.go:8-20
func TestDispatch(t *testing.T)

{
	called := false
	a := Action(func(_ Context) error {
		called = true
		return nil
	})
	if err := Dispatch(context.Background(), a); err != nil {
		t.Fatalf("dispatch returned error: %v", err)
	}
	if !called {
		t.Fatalf("action was not executed")
	}
}
F
function

TestUseAction

Parameters

state/action_test.go:22-35
func TestUseAction(t *testing.T)

{
	called := false
	a := Action(func(_ Context) error {
		called = true
		return nil
	})
	fn := UseAction(context.Background(), a)
	if err := fn(); err != nil {
		t.Fatalf("use action returned error: %v", err)
	}
	if !called {
		t.Fatalf("action not executed via UseAction")
	}
}
F
function

loadPersistedState

loadPersistedState retrieves persisted state from localStorage.

Parameters

key
string

Returns

map[string]any
state/persistence_js.go:12-26
func loadPersistedState(key string) map[string]any

{
	ls := js.LocalStorage()
	if !ls.Truthy() {
		return nil
	}
	item := ls.Call("getItem", key)
	if item.Type() != js.TypeString {
		return nil
	}
	var state map[string]any
	if err := json.Unmarshal([]byte(item.String()), &state); err != nil {
		return nil
	}
	return state
}
F
function

saveState

saveState persists the store state in localStorage.

Parameters

key
string
state
map[string]any
state/persistence_js.go:29-39
func saveState(key string, state map[string]any)

{
	ls := js.LocalStorage()
	if !ls.Truthy() {
		return
	}
	data, err := json.Marshal(state)
	if err != nil {
		return
	}
	ls.Call("setItem", key, string(data))
}
F
function

TestSignalEffect

Parameters

state/signal_test.go:5-25
func TestSignalEffect(t *testing.T)

{
	a := NewSignal(0)
	b := NewSignal(0)

	var runs int
	stop := Effect(func() func() {
		_ = a.Get()
		runs++
		return nil
	})
	defer stop()

	b.Set(1)
	if runs != 1 {
		t.Fatalf("effect ran on unrelated signal change")
	}
	a.Set(1)
	if runs != 2 {
		t.Fatalf("effect did not rerun on dependent signal change")
	}
}
F
function

TestEffectCleanup

Parameters

state/signal_test.go:27-43
func TestEffectCleanup(t *testing.T)

{
	s := NewSignal(0)
	var cleans int
	stop := Effect(func() func() {
		_ = s.Get()
		return func() { cleans++ }
	})

	s.Set(1)
	if cleans != 1 {
		t.Fatalf("cleanup not called before rerun, got %d", cleans)
	}
	stop()
	if cleans != 2 {
		t.Fatalf("cleanup not called on stop, got %d", cleans)
	}
}
F
function

TestSetNotifiesAllSubscribers

Parameters

state/signal_test.go:45-72
func TestSetNotifiesAllSubscribers(t *testing.T)

{
	s := NewSignal(0)

	var val1, val2 int
	stop1 := Effect(func() func() {
		val1 = s.Get()
		return nil
	})
	defer stop1()

	stop2 := Effect(func() func() {
		val2 = s.Get() * 2
		return nil
	})
	defer stop2()

	if val1 != 0 || val2 != 0 {
		t.Fatalf("initial: expected 0,0 got %d,%d", val1, val2)
	}

	s.Set(5)
	if val1 != 5 {
		t.Fatalf("effect1 after Set(5): expected 5, got %d", val1)
	}
	if val2 != 10 {
		t.Fatalf("effect2 after Set(5): expected 10, got %d", val2)
	}
}
F
function

TestExprEffectWithMultipleSignals

Parameters

state/signal_test.go:74-108
func TestExprEffectWithMultipleSignals(t *testing.T)

{
	count := NewSignal(0)
	factor := NewSignal(0)

	var result int
	stop := Effect(func() func() {
		result = count.Get() * factor.Get()
		return nil
	})
	defer stop()

	if result != 0 {
		t.Fatalf("initial: expected 0, got %d", result)
	}

	factor.Set(2)
	if result != 0 {
		t.Fatalf("after factor=2: expected 0, got %d", result)
	}

	count.Set(1)
	if result != 2 {
		t.Fatalf("after count=1: expected 2, got %d", result)
	}

	count.Set(3)
	if result != 6 {
		t.Fatalf("after count=3: expected 6, got %d", result)
	}

	factor.Set(3)
	if result != 9 {
		t.Fatalf("after factor=3: expected 9, got %d", result)
	}
}
F
function

TestOnChangeBasic

Parameters

state/signal_test.go:110-130
func TestOnChangeBasic(t *testing.T)

{
	s := NewSignal("hello")
	var received []string

	sub := s.OnChange(func(v string) {
		received = append(received, v)
	})

	s.Set("world")
	s.Set("!")

	if len(received) != 2 || received[0] != "world" || received[1] != "!" {
		t.Fatalf("expected [world !], got %v", received)
	}

	sub.Stop()
	s.Set("after-stop")
	if len(received) != 2 {
		t.Fatalf("callback fired after Stop: %v", received)
	}
}
F
function

TestOnChangeMultipleListeners

Parameters

state/signal_test.go:132-155
func TestOnChangeMultipleListeners(t *testing.T)

{
	s := NewSignal(0)
	var sum int

	sub1 := s.OnChange(func(v int) { sum += v })
	sub2 := s.OnChange(func(v int) { sum += v * 10 })

	s.Set(1)
	if sum != 11 {
		t.Fatalf("expected 11, got %d", sum)
	}

	sub1.Stop()
	s.Set(2)
	if sum != 31 {
		t.Fatalf("expected 31, got %d", sum)
	}

	sub2.Stop()
	s.Set(3)
	if sum != 31 {
		t.Fatalf("expected 31 after all stopped, got %d", sum)
	}
}
F
function

TestOnChangeStopsAreIdempotent

Parameters

state/signal_test.go:157-176
func TestOnChangeStopsAreIdempotent(t *testing.T)

{
	s := NewSignal(0)
	calls := 0

	sub := s.OnChange(func(int) { calls++ })

	s.Set(1)
	if calls != 1 {
		t.Fatalf("expected 1, got %d", calls)
	}

	sub.Stop()
	sub.Stop()
	sub.Stop()

	s.Set(2)
	if calls != 1 {
		t.Fatalf("callback fired after multiple stops: %d", calls)
	}
}
F
function

TestChannelLazyCreation

Parameters

state/signal_test.go:178-197
func TestChannelLazyCreation(t *testing.T)

{
	s := NewSignal(0)

	s.onChangeMu.Lock()
	hasCh := s.chCreated
	s.onChangeMu.Unlock()
	if hasCh {
		t.Fatal("channel should not exist before Channel() call")
	}

	ch := s.Channel()

	s.onChangeMu.Lock()
	hasCh = s.chCreated
	s.onChangeMu.Unlock()
	if !hasCh {
		t.Fatal("channel should exist after Channel() call")
	}
	_ = ch
}
F
function

TestChannelReceivesValues

Parameters

state/signal_test.go:199-213
func TestChannelReceivesValues(t *testing.T)

{
	s := NewSignal(0)
	ch := s.Channel()

	s.Set(42)

	select {
	case v := <-ch:
		if v != 42 {
			t.Fatalf("expected 42, got %d", v)
		}
	default:
		t.Fatal("channel should have received value")
	}
}
F
function

TestChannelClosesWhenAllListenersRemoved

Parameters

state/signal_test.go:215-224
func TestChannelClosesWhenAllListenersRemoved(t *testing.T)

{
	s := NewSignal(0)

	s.onChangeMu.Lock()
	hasCh := s.chCreated
	s.onChangeMu.Unlock()
	if hasCh {
		t.Fatal("channel should not exist before Channel() or OnChange()")
	}
}
F
function

TestOnChangeDoesNotFireAfterStop

Parameters

state/signal_test.go:226-244
func TestOnChangeDoesNotFireAfterStop(t *testing.T)

{
	s := NewSignal(10)
	calls := 0

	sub := s.OnChange(func(int) { calls++ })

	s.Set(20)
	if calls != 1 {
		t.Fatalf("expected 1 call, got %d", calls)
	}

	sub.Stop()

	s.Set(30)
	s.Set(40)
	if calls != 1 {
		t.Fatalf("expected calls to stay 1 after Stop, got %d", calls)
	}
}
F
function

TestSetFromHostConversions

Parameters

state/signal_test.go:246-289
func TestSetFromHostConversions(t *testing.T)

{
	i64 := NewSignal[int64](0)
	i64.SetFromHost(float64(42))
	if i64.Get() != 42 {
		t.Fatalf("int64: got %d", i64.Get())
	}
	f32 := NewSignal[float32](0)
	f32.SetFromHost(float64(1.5))
	if f32.Get() != 1.5 {
		t.Fatalf("float32: got %v", f32.Get())
	}
	u := NewSignal[uint](0)
	u.SetFromHost(float64(7))
	if u.Get() != 7 {
		t.Fatalf("uint: got %d", u.Get())
	}
	type point struct {
		X int    `json:"x"`
		Y string `json:"y"`
	}
	p := NewSignal[point](point{})
	p.SetFromHost(map[string]any{"x": float64(3), "y": "hi"})
	if got := p.Get(); got.X != 3 || got.Y != "hi" {
		t.Fatalf("struct: got %+v", got)
	}
	sl := NewSignal[[]int](nil)
	sl.SetFromHost([]any{float64(1), float64(2)})
	if got := sl.Get(); len(got) != 2 || got[1] != 2 {
		t.Fatalf("slice: got %v", got)
	}
	// Incompatible payloads leave the value untouched.
	n := NewSignal[int](9)
	n.SetFromHost("not a number")
	if n.Get() != 9 {
		t.Fatalf("mismatch should be ignored, got %d", n.Get())
	}
	notify := 0
	m := NewSignal[int](0)
	m.OnChange(func(int) { notify++ })
	m.SetFromHost(float64(5))
	if m.Get() != 5 || notify != 1 {
		t.Fatalf("notify: value %d, calls %d", m.Get(), notify)
	}
}
F
function

TestStoreUndoRedo

Parameters

state/store_history_test.go:5-20
func TestStoreUndoRedo(t *testing.T)

{
	s := NewStore("hist", WithHistory(10))
	s.Set("count", 1)
	s.Set("count", 2)
	if v := s.Get("count"); v != 2 {
		t.Fatalf("expected 2, got %v", v)
	}
	s.Undo()
	if v := s.Get("count"); v != 1 {
		t.Fatalf("expected 1 after undo, got %v", v)
	}
	s.Redo()
	if v := s.Get("count"); v != 2 {
		t.Fatalf("expected 2 after redo, got %v", v)
	}
}
F
function

TestStoreHistoryLimit

Parameters

state/store_history_test.go:22-41
func TestStoreHistoryLimit(t *testing.T)

{
	s := NewStore("limit", WithHistory(2))
	s.Set("val", 1)
	s.Set("val", 2)
	s.Set("val", 3)
	s.Set("val", 4)
	// history limit 2 means only last two changes are tracked
	s.Undo() // 4 -> 3
	if v := s.Get("val"); v != 3 {
		t.Fatalf("expected 3 after first undo, got %v", v)
	}
	s.Undo() // 3 -> 2
	if v := s.Get("val"); v != 2 {
		t.Fatalf("expected 2 after second undo, got %v", v)
	}
	s.Undo() // no effect, history exhausted
	if v := s.Get("val"); v != 2 {
		t.Fatalf("expected 2 after exhausting history, got %v", v)
	}
}
F
function

TestRedoClearedOnNewMutation

Parameters

state/store_history_test.go:43-53
func TestRedoClearedOnNewMutation(t *testing.T)

{
	s := NewStore("redo", WithHistory(10))
	s.Set("a", 1)
	s.Set("a", 2)
	s.Undo()      // a ->1, future has mutation 1->2
	s.Set("a", 3) // new mutation should clear redo stack
	s.Redo()      // should do nothing
	if v := s.Get("a"); v != 3 {
		t.Fatalf("expected 3 after redo with cleared history, got %v", v)
	}
}
F
function

ExposeUpdateStore

ExposeUpdateStore exposes a JS function to update store values.

state/expose.go:10-38
func ExposeUpdateStore()

{
	js.Set("goUpdateStore", js.SafeFuncOf(func(_ js.Value, args []js.Value) any {
		if len(args) < 4 {
			return nil
		}
		module := args[0].String()
		storeName := args[1].String()
		key := args[2].String()

		var newValue any
		switch args[3].Type() {
		case js.TypeString:
			newValue = args[3].String()
		case js.TypeBoolean:
			newValue = args[3].Bool()
		case js.TypeNumber:
			newValue = args[3].Float()
		default:
			newValue = args[3]
		}

		store := GlobalStoreManager.GetStore(module, storeName)
		if store == nil {
			store = NewStore(storeName, WithModule(module))
		}
		store.Set(key, newValue)
		return nil
	}))
}
F
function

TestExposeUpdateStoreBool

Parameters

state/expose_test.go:11-22
func TestExposeUpdateStoreBool(t *testing.T)

{
	ExposeUpdateStore()
	js.Call("goUpdateStore", "mod", "test", "flag", true)
	store := GlobalStoreManager.GetStore("mod", "test")
	if store == nil {
		t.Fatalf("store not created")
	}
	v, ok := store.Get("flag").(bool)
	if !ok || !v {
		t.Fatalf("expected true bool, got %#v", store.Get("flag"))
	}
}
F
function

captureCallbackPanics

Parameters

Returns

*[]string
state/recovery_test.go:8-20
func captureCallbackPanics(t *testing.T) *[]string

{
	t.Helper()
	previous := OnCallbackPanic
	contexts := []string{}
	OnCallbackPanic = func(_ any, context string, stack []byte) {
		if len(stack) == 0 {
			t.Error("recovered callback panic had no stack")
		}
		contexts = append(contexts, context)
	}
	t.Cleanup(func() { OnCallbackPanic = previous })
	return &contexts
}
F
function

TestStoreContinuesNotificationsAfterListenerPanic

Parameters

state/recovery_test.go:22-38
func TestStoreContinuesNotificationsAfterListenerPanic(t *testing.T)

{
	contexts := captureCallbackPanics(t)
	store := NewStore("recovery", WithModule("test"))
	called := 0
	store.OnChange("value", func(any) { panic("first listener") })
	store.OnChange("value", func(any) { called++ })

	store.Set("value", 1)
	store.Set("value", 2)

	if called != 2 {
		t.Fatalf("healthy listener calls = %d, want 2", called)
	}
	if len(*contexts) != 2 || !strings.Contains((*contexts)[0], "test.recovery.value") {
		t.Fatalf("unexpected recovery contexts: %v", *contexts)
	}
}
F
function

TestSignalContinuesListenersAfterPanic

Parameters

state/recovery_test.go:40-56
func TestSignalContinuesListenersAfterPanic(t *testing.T)

{
	contexts := captureCallbackPanics(t)
	signal := NewSignal(0)
	called := 0
	signal.OnChange(func(int) { panic("first listener") })
	signal.OnChange(func(int) { called++ })

	signal.Set(1)
	signal.Set(2)

	if called != 2 {
		t.Fatalf("healthy listener calls = %d, want 2", called)
	}
	if len(*contexts) != 2 || (*contexts)[0] != "signal change listener" {
		t.Fatalf("unexpected recovery contexts: %v", *contexts)
	}
}
F
function

TestEffectCanRunAgainAfterPanic

Parameters

state/recovery_test.go:58-81
func TestEffectCanRunAgainAfterPanic(t *testing.T)

{
	contexts := captureCallbackPanics(t)
	signal := NewSignal(0)
	runs := 0
	stop := Effect(func() func() {
		value := signal.Get()
		runs++
		if value == 1 {
			panic("effect update")
		}
		return nil
	})
	defer stop()

	signal.Set(1)
	signal.Set(2)

	if runs != 3 {
		t.Fatalf("effect runs = %d, want 3", runs)
	}
	if len(*contexts) != 1 || (*contexts)[0] != "signal effect" {
		t.Fatalf("unexpected recovery contexts: %v", *contexts)
	}
}
F
function

TestRecoveryHookPanicDoesNotEscape

Parameters

state/recovery_test.go:83-91
func TestRecoveryHookPanicDoesNotEscape(_ *testing.T)

{
	previous := OnCallbackPanic
	OnCallbackPanic = func(any, string, []byte) { panic("broken reporter") }
	defer func() { OnCallbackPanic = previous }()

	store := NewStore("broken-reporter")
	store.OnChange("value", func(any) { panic("listener") })
	store.Set("value", 1)
}
F
function

waitResourceStatus

Parameters

resource
*Resource[T]
state/resource_test.go:11-20
func waitResourceStatus[T any](t *testing.T, resource *Resource[T], status ResourceStatus)

{
	t.Helper()
	deadline := time.Now().Add(time.Second)
	for resource.Status() != status {
		if time.Now().After(deadline) {
			t.Fatalf("resource status = %q, want %q", resource.Status(), status)
		}
		time.Sleep(time.Millisecond)
	}
}
F
function

TestResourceLoadsAndMutates

Parameters

state/resource_test.go:22-36
func TestResourceLoadsAndMutates(t *testing.T)

{
	resource := NewResource(func(context.Context) (int, error) { return 7, nil })
	defer resource.Close()
	waitResourceStatus(t, resource, ResourceReady)

	value, err := resource.Read()
	if err != nil || value != 7 {
		t.Fatalf("Read() = %d, %v", value, err)
	}
	resource.Mutate(9)
	value, err = resource.Read()
	if err != nil || value != 9 {
		t.Fatalf("Read() after Mutate = %d, %v", value, err)
	}
}
F
function

TestResourceReportsErrors

Parameters

state/resource_test.go:38-47
func TestResourceReportsErrors(t *testing.T)

{
	want := errors.New("load failed")
	resource := NewResource(func(context.Context) (int, error) { return 0, want })
	defer resource.Close()
	waitResourceStatus(t, resource, ResourceError)

	if _, err := resource.Read(); !errors.Is(err, want) {
		t.Fatalf("Read() error = %v", err)
	}
}
F
function

TestKeyedResourcesDeduplicateLoads

Parameters

state/resource_test.go:49-71
func TestKeyedResourcesDeduplicateLoads(t *testing.T)

{
	const key = "resource-deduplicate"
	ClearResourceCache(key)
	t.Cleanup(func() { ClearResourceCache(key) })
	start := make(chan struct{})
	var calls atomic.Int32
	loader := func(context.Context) (int, error) {
		calls.Add(1)
		<-start
		return 11, nil
	}
	first := NewResource(loader, WithResourceKey(key))
	second := NewResource(loader, WithResourceKey(key))
	defer first.Close()
	defer second.Close()
	close(start)
	waitResourceStatus(t, first, ResourceReady)
	waitResourceStatus(t, second, ResourceReady)

	if calls.Load() != 1 {
		t.Fatalf("loader calls = %d", calls.Load())
	}
}
F
function

TestResourceCloseCancelsLoad

Parameters

state/resource_test.go:73-87
func TestResourceCloseCancelsLoad(t *testing.T)

{
	cancelled := make(chan struct{})
	resource := NewResource(func(ctx context.Context) (int, error) {
		<-ctx.Done()
		close(cancelled)
		return 0, ctx.Err()
	})
	resource.Close()

	select {
	case <-cancelled:
	case <-time.After(time.Second):
		t.Fatal("loader context was not cancelled")
	}
}
F
function

TestResourceLoaderPanicBecomesError

Parameters

state/resource_test.go:89-99
func TestResourceLoaderPanicBecomesError(t *testing.T)

{
	resource := NewResource(func(context.Context) (int, error) {
		panic("loader")
	})
	defer resource.Close()

	waitResourceStatus(t, resource, ResourceError)
	if resource.Error() == nil {
		t.Fatal("resource panic did not become an error")
	}
}
F
function

TestBatchRunsDependentEffectOnce

Parameters

state/scheduler_test.go:5-24
func TestBatchRunsDependentEffectOnce(t *testing.T)

{
	first := NewSignal(1)
	second := NewSignal(2)
	runs := 0
	stop := Effect(func() func() {
		_ = first.Get() + second.Get()
		runs++
		return nil
	})
	defer stop()

	Batch(func() {
		first.Set(3)
		second.Set(4)
	})

	if runs != 2 {
		t.Fatalf("effect runs = %d", runs)
	}
}
F
function

TestUntrackedReadDoesNotBecomeDependency

Parameters

state/scheduler_test.go:26-46
func TestUntrackedReadDoesNotBecomeDependency(t *testing.T)

{
	tracked := NewSignal(1)
	ignored := NewSignal(2)
	runs := 0
	stop := Effect(func() func() {
		_ = tracked.Get()
		_ = Untracked(ignored.Get)
		runs++
		return nil
	})
	defer stop()

	ignored.Set(3)
	if runs != 1 {
		t.Fatalf("effect tracked untracked signal: runs = %d", runs)
	}
	tracked.Set(2)
	if runs != 2 {
		t.Fatalf("tracked signal did not rerun effect: runs = %d", runs)
	}
}
F
function

TestMemoTracksDependencies

Parameters

state/scheduler_test.go:48-64
func TestMemoTracksDependencies(t *testing.T)

{
	first := NewSignal(2)
	second := NewSignal(3)
	total := Memo(func() int { return first.Get() + second.Get() })
	defer total.Stop()

	if total.Get() != 5 {
		t.Fatalf("initial memo = %d", total.Get())
	}
	Batch(func() {
		first.Set(4)
		second.Set(5)
	})
	if total.Get() != 9 {
		t.Fatalf("updated memo = %d", total.Get())
	}
}
F
function

TestComputedStability

Parameters

state/computed_test.go:8-46
func TestComputedStability(t *testing.T)

{
	s := NewStore("test")
	s.Set("a", 1)

	evalCount := 0
	c := NewComputed("double", []string{"a"}, func(m map[string]any) any {
		evalCount++
		return m["a"].(int) * 2
	})
	s.RegisterComputed(c)

	if evalCount != 1 {
		t.Fatalf("expected 1 evaluation, got %d", evalCount)
	}
	if v := s.Get("double"); v != 2 {
		t.Fatalf("expected computed value 2, got %v", v)
	}

	// Setting dependency to same value should not re-evaluate
	s.Set("a", 1)
	if evalCount != 1 {
		t.Fatalf("computed re-evaluated without dependency change")
	}

	// Setting unrelated key should not re-evaluate
	s.Set("b", 5)
	if evalCount != 1 {
		t.Fatalf("computed re-evaluated for unrelated key")
	}

	// Changing dependency should trigger re-evaluation
	s.Set("a", 3)
	if evalCount != 2 {
		t.Fatalf("expected second evaluation after dependency change, got %d", evalCount)
	}
	if v := s.Get("double"); v != 6 {
		t.Fatalf("expected computed value 6, got %v", v)
	}
}
F
function

TestMapHelpers

Parameters

state/computed_test.go:48-78
func TestMapHelpers(t *testing.T)

{
	s := NewStore("test")
	s.Set("count", 2)

	Map(s, "double", "count", func(v int) int { return v * 2 })

	if v := s.Get("double"); v != 4 {
		t.Fatalf("expected 4, got %v", v)
	}

	s.Set("count", 3)
	if v := s.Get("double"); v != 6 {
		t.Fatalf("expected 6 after update, got %v", v)
	}

	s.Set("first", "Ada")
	s.Set("last", "Lovelace")

	Map2(s, "fullName", "first", "last", func(f, l string) string {
		return strings.TrimSpace(f + " " + l)
	})

	if v := s.Get("fullName"); v != "Ada Lovelace" {
		t.Fatalf("expected full name Ada Lovelace, got %v", v)
	}

	s.Set("last", "Hopper")
	if v := s.Get("fullName"); v != "Ada Hopper" {
		t.Fatalf("expected full name Ada Hopper, got %v", v)
	}
}
S
struct

ReactiveVar

ReactiveVar stores a value and notifies listeners when it changes.

state/reactive.go:4-7
type ReactiveVar struct

Fields

Name Type Description
value T
listeners []func(T)
F
function

NewReactiveVar

NewReactiveVar creates a reactive value.

Parameters

initial
T

Returns

*ReactiveVar[T]
state/reactive.go:10-14
func NewReactiveVar[T any](initial T) *ReactiveVar[T]

{
	return &ReactiveVar[T]{
		value: initial,
	}
}
T
type

ReactiveString

ReactiveString is a convenience alias for ReactiveVar[string].

state/reactive.go:35-35
type ReactiveString ReactiveVar[string]
S
struct

Computed

Computed represents a derived state value based on other store keys.
It holds the target key for the computed value, the list of dependencies
and the function used to calculate the value.

state/reactive.go:40-45
type Computed struct

Methods

Key
Method

Key returns the store key associated with the computed value.

Returns

string
func (*Computed) Key() string
{ return c.key }
Deps
Method

Deps returns the list of keys this computed value depends on.

Returns

[]string
func (*Computed) Deps() []string
{ return c.deps }
Evaluate
Method

Evaluate executes the compute function using the provided state and returns the result.

Parameters

state map[string]any

Returns

any
func (*Computed) Evaluate(state map[string]any) any
{
	return c.compute(state)
}

Fields

Name Type Description
key string
deps []string
compute func(map[string]any) any
lastDeps map[string]any
F
function

NewComputed

NewComputed creates a new Computed value.

Parameters

key
string
deps
[]string
compute
func(map[string]any) any

Returns

state/reactive.go:48-50
func NewComputed(key string, deps []string, compute func(map[string]any) any) *Computed

{
	return &Computed{key: key, deps: deps, compute: compute}
}
S
struct

Watcher

Watcher represents a callback that reacts to changes on specific store keys.
When any of the dependencies change, the associated function is triggered.

state/reactive.go:66-71
type Watcher struct

Methods

Deps
Method

Deps returns the list of keys the watcher observes.

Returns

[]string
func (*Watcher) Deps() []string
{ return w.deps }
Run
Method

Run triggers the watcher with the provided state.

Parameters

state map[string]any
func (*Watcher) Run(state map[string]any)
{ w.run(state) }

Fields

Name Type Description
deps []string
run func(map[string]any)
deep bool
immediate bool
T
type

WatcherOption

WatcherOption configures optional watcher behaviour.

state/reactive.go:74-74
type WatcherOption func(*Watcher)
F
function

WatcherDeep

WatcherDeep enables deep watching of nested keys.

Returns

state/reactive.go:77-77
func WatcherDeep() WatcherOption

{ return func(w *Watcher) { w.deep = true } }
F
function

WatcherImmediate

WatcherImmediate triggers the watcher immediately after registration.

Returns

state/reactive.go:80-80
func WatcherImmediate() WatcherOption

{ return func(w *Watcher) { w.immediate = true } }
F
function

NewWatcher

NewWatcher creates a new Watcher.

Parameters

deps
[]string
run
func(map[string]any)
opts
...WatcherOption

Returns

state/reactive.go:83-89
func NewWatcher(deps []string, run func(map[string]any), opts ...WatcherOption) *Watcher

{
	w := &Watcher{deps: deps, run: run}
	for _, opt := range opts {
		opt(w)
	}
	return w
}