state
packageAPI reference for the state
package.
Imports
(13)reportCallbackPanic
Parameters
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)
}
runCallback
Parameters
Returns
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
}
captureValuePanic
Parameters
Returns
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
}
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.
type Context context.Context
Action
Action represents a unit of work executed with a Context.
It returns an error if the action fails.
type Action func(ctx Context) error
Dispatch
Dispatch executes the given Action with the provided context.
If the action is nil it is a no-op and nil is returned.
func Dispatch(ctx Context, a Action) error
{
if a == nil {
return nil
}
return a(ctx)
}
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.
func UseAction(ctx Context, a Action) func() error
{
return func() error {
return Dispatch(ctx, a)
}
}
loadPersistedState
Parameters
Returns
func loadPersistedState(string) map[string]any
{ return nil }
saveState
Parameters
func saveState(string, map[string]any)
{}
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
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()
}
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
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()
}
TestReactiveVarInt
Parameters
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)
}
}
sample
type sample struct
Fields
| Name | Type | Description |
|---|---|---|
| A | int | |
| B | string |
TestReactiveVarStruct
Parameters
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)
}
}
ResourceStatus
ResourceStatus describes the current resource state.
type ResourceStatus string
resourceConfig
type resourceConfig struct
Fields
| Name | Type | Description |
|---|---|---|
| key | string | |
| ttl | time.Duration | |
| immediate | bool |
ResourceOption
ResourceOption configures a Resource.
type ResourceOption func(*resourceConfig)
WithResourceKey
WithResourceKey enables request deduplication and caching for key.
Parameters
Returns
func WithResourceKey(key string) ResourceOption
{
return func(config *resourceConfig) { config.key = key }
}
WithResourceTTL
WithResourceTTL expires a keyed cache entry after ttl.
Parameters
Returns
func WithResourceTTL(ttl time.Duration) ResourceOption
{
return func(config *resourceConfig) { config.ttl = ttl }
}
WithoutImmediateLoad
WithoutImmediateLoad leaves a resource idle until Load is called.
Returns
func WithoutImmediateLoad() ResourceOption
{
return func(config *resourceConfig) { config.immediate = false }
}
resourceCacheEntry
type resourceCacheEntry struct
Fields
| Name | Type | Description |
|---|---|---|
| value | any | |
| expires | time.Time |
resourceFlight
type resourceFlight struct
Fields
| Name | Type | Description |
|---|---|---|
| done | chan struct{} | |
| cancel | context.CancelFunc | |
| value | any | |
| err | error | |
| waiters | int | |
| closed | bool |
ClearResourceCache
ClearResourceCache removes a shared resource cache entry.
Parameters
func ClearResourceCache(key string)
{
resourceShared.Lock()
delete(resourceShared.cache, key)
resourceShared.Unlock()
}
Resource
Resource wraps cancellable asynchronous data in reactive signals.
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] |
NewResource
NewResource creates a resource and starts loading by default.
Parameters
Returns
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
}
loadResourceCache
Parameters
Returns
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
}
acquireResourceFlight
Parameters
Returns
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
}
runResourceLoader
Parameters
Returns
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)
}
waitResourceFlight
Parameters
Returns
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()
}
}
effect
effect represents a reactive computation registered via Effect.
type effect struct
Methods
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)
}
}
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()
}
func (*effect) stop()
{
e.detach()
}
Fields
| Name | Type | Description |
|---|---|---|
| run | func() func() | |
| mu | sync.Mutex | |
| deps | []subscriber | |
| cleanup | func() |
Subscription
Subscription represents a cancellable listener returned by OnChange.
type Subscription struct
Methods
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 |
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.
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 |
NewSignal
NewSignal creates a new Signal with the given initial value.
Parameters
Returns
func NewSignal[T any](initial T) *Signal[T]
{
return &Signal[T]{value: initial, subs: make(map[*effect]struct{})}
}
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
Returns
func Effect(fn func() func()) func()
{
e := &effect{run: fn}
e.runEffect()
return e.stop
}
Batch
Batch defers dependent effects until fn completes and runs each effect once.
Parameters
func Batch(fn func())
{
if fn == nil {
return
}
effectScheduler.Lock()
effectScheduler.depth++
effectScheduler.Unlock()
defer flushBatch()
fn()
}
scheduleEffect
Parameters
func scheduleEffect(e *effect)
{
effectScheduler.Lock()
if effectScheduler.depth > 0 {
effectScheduler.pending[e] = struct{}{}
effectScheduler.Unlock()
return
}
effectScheduler.Unlock()
e.runEffect()
}
flushBatch
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()
}
}
Untracked
Untracked evaluates fn without subscribing the current effect.
Parameters
Returns
func Untracked[T any](fn func() T) T
{
previous := currentEffect.Swap(nil)
defer currentEffect.Store(previous)
return fn()
}
MemoValue
MemoValue is a read-only signal derived from other signals.
type MemoValue struct
Fields
| Name | Type | Description |
|---|---|---|
| signal | *Signal[T] | |
| stop | func() |
Memo
Memo creates a cached derivation and tracks every signal read by compute.
Parameters
Returns
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
}
valEqual
Parameters
Returns
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)
}
depsChanged
Parameters
Returns
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
}
Logger
Logger receives debug messages from stores.
type Logger interface
Methods
SetLogger
SetLogger replaces the logger used by stores.
Parameters
func SetLogger(l Logger)
{ logger = l }
Uses
StoreOption
StoreOption configures optional behaviour for a Store during creation.
type StoreOption func(*Store)
WithModule
WithModule namespaces a store under the provided module.
Parameters
Returns
func WithModule(module string) StoreOption
{ return func(s *Store) { s.module = module } }
Uses
WithPersistence
WithPersistence enables localStorage persistence for the store.
Returns
func WithPersistence() StoreOption
{ return func(s *Store) { s.persist = true } }
Uses
WithDevTools
WithDevTools enables logging of state mutations for development.
Returns
func WithDevTools() StoreOption
{ return func(s *Store) { s.devTools = true } }
Uses
WithHistory
WithHistory enables mutation history with the provided limit.
The limit controls how many past mutations are retained for undo/redo.
Parameters
Returns
func WithHistory(limit int) StoreOption
{
return func(s *Store) {
if limit > 0 {
s.historyLimit = limit
}
}
}
Uses
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.
type Store struct
Methods
Module reports the module namespace of the store.
Returns
func (*Store) Module() string
{ return s.module }
Name returns the store name within its module namespace.
Returns
func (*Store) Name() string
{ return s.name }
Snapshot copies the current state of the store.
Returns
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
}
Returns
func (*Store) storageKey() string
{ return s.module + ":" + s.name }
Set stores a value and notifies dependents.
Parameters
func (*Store) Set(key string, value any)
{
s.set(key, value, true)
}
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
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
Returns
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 returns the value stored under key.
Parameters
Returns
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 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 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 registers a listener and returns its unsubscribe function.
Parameters
Returns
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
Returns
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
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
Returns
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 |
mutation
type mutation struct
Fields
| Name | Type | Description |
|---|---|---|
| key | string | |
| previous | any | |
| next | any |
StoreManager
StoreManager groups stores by module and name.
type StoreManager struct
Methods
NewStore creates and registers a store in this manager.
Parameters
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 registers a store by module and name.
Parameters
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 returns a registered store or nil.
Parameters
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
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 returns a deep copy of all registered stores and their states.
Returns
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 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 |
NewStoreManager
NewStoreManager creates a standalone manager for isolating store instances.
Returns
func NewStoreManager() *StoreManager
{
return &StoreManager{modules: make(map[string]map[string]*Store)}
}
NewStore
NewStore creates a new store with the given name and optional configuration.
By default stores are registered under the “default” module.
Parameters
Returns
func NewStore(name string, opts ...StoreOption) *Store
{
return GlobalStoreManager.NewStore(name, opts...)
}
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
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)
}
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
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)
}
contains
Parameters
Returns
func contains(slice []string, item string) bool
{
for _, s := range slice {
if s == item {
return true
}
}
return false
}
pathMatches
Parameters
Returns
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
}
snapshotDeps
Parameters
Returns
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
}
TestUnregisterStore
Parameters
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")
}
}
TestDispatch
Parameters
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")
}
}
TestUseAction
Parameters
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")
}
}
loadPersistedState
loadPersistedState retrieves persisted state from localStorage.
Parameters
Returns
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
}
saveState
saveState persists the store state in localStorage.
Parameters
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))
}
TestSignalEffect
Parameters
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")
}
}
TestEffectCleanup
Parameters
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)
}
}
TestSetNotifiesAllSubscribers
Parameters
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)
}
}
TestExprEffectWithMultipleSignals
Parameters
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)
}
}
TestOnChangeBasic
Parameters
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)
}
}
TestOnChangeMultipleListeners
Parameters
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)
}
}
TestOnChangeStopsAreIdempotent
Parameters
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)
}
}
TestChannelLazyCreation
Parameters
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
}
TestChannelReceivesValues
Parameters
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")
}
}
TestChannelClosesWhenAllListenersRemoved
Parameters
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()")
}
}
TestOnChangeDoesNotFireAfterStop
Parameters
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)
}
}
TestSetFromHostConversions
Parameters
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)
}
}
TestStoreUndoRedo
Parameters
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)
}
}
TestStoreHistoryLimit
Parameters
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)
}
}
TestRedoClearedOnNewMutation
Parameters
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)
}
}
ExposeUpdateStore
ExposeUpdateStore exposes a JS function to update store values.
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
}))
}
TestExposeUpdateStoreBool
Parameters
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"))
}
}
captureCallbackPanics
Parameters
Returns
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
}
TestStoreContinuesNotificationsAfterListenerPanic
Parameters
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)
}
}
TestSignalContinuesListenersAfterPanic
Parameters
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)
}
}
TestEffectCanRunAgainAfterPanic
Parameters
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)
}
}
TestRecoveryHookPanicDoesNotEscape
Parameters
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)
}
waitResourceStatus
Parameters
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)
}
}
TestResourceLoadsAndMutates
Parameters
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)
}
}
TestResourceReportsErrors
Parameters
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)
}
}
TestKeyedResourcesDeduplicateLoads
Parameters
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())
}
}
TestResourceCloseCancelsLoad
Parameters
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")
}
}
TestResourceLoaderPanicBecomesError
Parameters
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")
}
}
TestBatchRunsDependentEffectOnce
Parameters
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)
}
}
TestUntrackedReadDoesNotBecomeDependency
Parameters
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)
}
}
TestMemoTracksDependencies
Parameters
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())
}
}
TestComputedStability
Parameters
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)
}
}
TestMapHelpers
Parameters
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)
}
}
ReactiveVar
ReactiveVar stores a value and notifies listeners when it changes.
type ReactiveVar struct
Fields
| Name | Type | Description |
|---|---|---|
| value | T | |
| listeners | []func(T) |
NewReactiveVar
NewReactiveVar creates a reactive value.
Parameters
Returns
func NewReactiveVar[T any](initial T) *ReactiveVar[T]
{
return &ReactiveVar[T]{
value: initial,
}
}
ReactiveString
ReactiveString is a convenience alias for ReactiveVar[string].
type ReactiveString ReactiveVar[string]
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.
type Computed struct
Methods
Key returns the store key associated with the computed value.
Returns
func (*Computed) Key() string
{ return c.key }
Deps returns the list of keys this computed value depends on.
Returns
func (*Computed) Deps() []string
{ return c.deps }
Evaluate executes the compute function using the provided state and returns the result.
Parameters
Returns
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 |
NewComputed
NewComputed creates a new Computed value.
Parameters
Returns
func NewComputed(key string, deps []string, compute func(map[string]any) any) *Computed
{
return &Computed{key: key, deps: deps, compute: compute}
}
Watcher
Watcher represents a callback that reacts to changes on specific store keys.
When any of the dependencies change, the associated function is triggered.
type Watcher struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| deps | []string | |
| run | func(map[string]any) | |
| deep | bool | |
| immediate | bool |
WatcherOption
WatcherOption configures optional watcher behaviour.
type WatcherOption func(*Watcher)
WatcherDeep
WatcherDeep enables deep watching of nested keys.
Returns
func WatcherDeep() WatcherOption
{ return func(w *Watcher) { w.deep = true } }
WatcherImmediate
WatcherImmediate triggers the watcher immediately after registration.
Returns
func WatcherImmediate() WatcherOption
{ return func(w *Watcher) { w.immediate = true } }
NewWatcher
NewWatcher creates a new Watcher.
Parameters
Returns
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
}