feat: add computed signals for derived reactive values
All checks were successful
CI / Build and Test (push) Successful in 33s
All checks were successful
CI / Build and Test (push) Successful in 33s
Read-only signals whose value is a function of other signals, recomputed automatically at sync time. Supports String, Int, Bool, and Text methods. Components store computed signals on the parent page context like regular signals.
This commit is contained in:
55
computed.go
Normal file
55
computed.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
// computedSignal is a read-only signal whose value is derived from other signals.
|
||||||
|
// It recomputes on every read and is included in patches only when the value changes.
|
||||||
|
type computedSignal struct {
|
||||||
|
id string
|
||||||
|
compute func() string
|
||||||
|
lastVal string
|
||||||
|
changed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) ID() string {
|
||||||
|
return s.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) String() string {
|
||||||
|
return s.compute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) Int() int {
|
||||||
|
if n, err := strconv.Atoi(s.String()); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) Bool() bool {
|
||||||
|
val := strings.ToLower(s.String())
|
||||||
|
return val == "true" || val == "1" || val == "yes" || val == "on"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) Text() h.H {
|
||||||
|
return h.Span(h.Data("text", "$"+s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// recompute calls the compute function and sets changed if the value differs from lastVal.
|
||||||
|
func (s *computedSignal) recompute() {
|
||||||
|
val := s.compute()
|
||||||
|
if val != s.lastVal {
|
||||||
|
s.lastVal = val
|
||||||
|
s.changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *computedSignal) patchValue() string {
|
||||||
|
return fmt.Sprintf("%v", s.lastVal)
|
||||||
|
}
|
||||||
190
computed_test.go
Normal file
190
computed_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package via
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ryanhamamura/via/h"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComputedBasic(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig1 := c.Signal("hello")
|
||||||
|
sig2 := c.Signal("world")
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return sig1.String() + " " + sig2.String()
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.Equal(t, "hello world", cs.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedReactivity(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
var sig1 *signal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig1 = c.Signal("a")
|
||||||
|
sig2 := c.Signal("b")
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return sig1.String() + sig2.String()
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.Equal(t, "ab", cs.String())
|
||||||
|
sig1.SetValue("x")
|
||||||
|
assert.Equal(t, "xb", cs.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedInt(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig := c.Signal(21)
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return fmt.Sprintf("%d", sig.Int()*2)
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.Equal(t, 42, cs.Int())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedBool(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
sig := c.Signal("true")
|
||||||
|
cs = c.Computed(func() string {
|
||||||
|
return sig.String()
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
assert.True(t, cs.Bool())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedText(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
cs = c.Computed(func() string { return "hi" })
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := cs.Text().Render(&buf)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, buf.String(), `data-text="$`+cs.ID()+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedChangeDetection(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var ctx *Context
|
||||||
|
var sig *signal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
ctx = c
|
||||||
|
sig = c.Signal("a")
|
||||||
|
c.Computed(func() string {
|
||||||
|
return sig.String() + "!"
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// First patch includes computed (changed=true from init)
|
||||||
|
patch1 := ctx.prepareSignalsForPatch()
|
||||||
|
assert.NotEmpty(t, patch1)
|
||||||
|
|
||||||
|
// Second patch: nothing changed, computed should not be included
|
||||||
|
patch2 := ctx.prepareSignalsForPatch()
|
||||||
|
// Regular signal still has changed=true (not reset in prepareSignalsForPatch),
|
||||||
|
// but computed should not appear since its value didn't change.
|
||||||
|
hasComputed := false
|
||||||
|
ctx.signals.Range(func(_, value any) bool {
|
||||||
|
if cs, ok := value.(*computedSignal); ok {
|
||||||
|
_, inPatch := patch2[cs.ID()]
|
||||||
|
hasComputed = inPatch
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.False(t, hasComputed)
|
||||||
|
|
||||||
|
// After changing dependency, computed should reappear
|
||||||
|
sig.SetValue("b")
|
||||||
|
patch3 := ctx.prepareSignalsForPatch()
|
||||||
|
found := false
|
||||||
|
ctx.signals.Range(func(_, value any) bool {
|
||||||
|
if cs, ok := value.(*computedSignal); ok {
|
||||||
|
if v, ok := patch3[cs.ID()]; ok {
|
||||||
|
assert.Equal(t, "b!", v)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.True(t, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedInComponent(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var cs *computedSignal
|
||||||
|
var parentCtx *Context
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
parentCtx = c
|
||||||
|
c.Component(func(comp *Context) {
|
||||||
|
sig := comp.Signal("via")
|
||||||
|
cs = comp.Computed(func() string {
|
||||||
|
return "hello " + sig.String()
|
||||||
|
})
|
||||||
|
comp.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "hello via", cs.String())
|
||||||
|
// Verify it's stored on the parent page context
|
||||||
|
found := false
|
||||||
|
parentCtx.signals.Range(func(_, value any) bool {
|
||||||
|
if stored, ok := value.(*computedSignal); ok && stored.ID() == cs.ID() {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
assert.True(t, found)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedIsReadOnly(t *testing.T) {
|
||||||
|
// Compile-time guarantee: *computedSignal has no Bind() or SetValue() methods.
|
||||||
|
// This test exists as documentation — if someone adds those methods, the
|
||||||
|
// interface assertion below will need updating and serve as a reminder.
|
||||||
|
var cs interface{} = &computedSignal{}
|
||||||
|
type writable interface {
|
||||||
|
SetValue(any)
|
||||||
|
}
|
||||||
|
type bindable interface {
|
||||||
|
Bind() h.H
|
||||||
|
}
|
||||||
|
_, isWritable := cs.(writable)
|
||||||
|
_, isBindable := cs.(bindable)
|
||||||
|
assert.False(t, isWritable, "computedSignal must not have SetValue")
|
||||||
|
assert.False(t, isBindable, "computedSignal must not have Bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputedInjectSignalsSkips(t *testing.T) {
|
||||||
|
v := New()
|
||||||
|
var ctx *Context
|
||||||
|
var cs *computedSignal
|
||||||
|
v.Page("/", func(c *Context) {
|
||||||
|
ctx = c
|
||||||
|
cs = c.Computed(func() string { return "fixed" })
|
||||||
|
c.View(func() h.H { return h.Div() })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate browser sending back a value for the computed signal — should be ignored
|
||||||
|
ctx.injectSignals(map[string]any{
|
||||||
|
cs.ID(): "injected",
|
||||||
|
})
|
||||||
|
assert.Equal(t, "fixed", cs.String())
|
||||||
|
}
|
||||||
43
context.go
43
context.go
@@ -207,6 +207,40 @@ func (c *Context) Signal(v any) *signal {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Computed creates a read-only signal whose value is derived from the given function.
|
||||||
|
// The function is called on every read (String/Int/Bool) for fresh values,
|
||||||
|
// and during sync to detect changes for browser patches.
|
||||||
|
//
|
||||||
|
// Computed signals cannot be bound to inputs or set manually.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// full := c.Computed(func() string {
|
||||||
|
// return first.String() + " " + last.String()
|
||||||
|
// })
|
||||||
|
// c.View(func() h.H {
|
||||||
|
// return h.Span(full.Text())
|
||||||
|
// })
|
||||||
|
func (c *Context) Computed(fn func() string) *computedSignal {
|
||||||
|
sigID := genRandID()
|
||||||
|
initial := fn()
|
||||||
|
cs := &computedSignal{
|
||||||
|
id: sigID,
|
||||||
|
compute: fn,
|
||||||
|
lastVal: initial,
|
||||||
|
changed: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.isComponent() {
|
||||||
|
c.parentPageCtx.signals.Store(sigID, cs)
|
||||||
|
} else {
|
||||||
|
c.signals.Store(sigID, cs)
|
||||||
|
}
|
||||||
|
return cs
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Context) injectSignals(sigs map[string]any) {
|
func (c *Context) injectSignals(sigs map[string]any) {
|
||||||
if sigs == nil {
|
if sigs == nil {
|
||||||
c.app.logErr(c, "signal injection failed: nil signals")
|
c.app.logErr(c, "signal injection failed: nil signals")
|
||||||
@@ -248,7 +282,8 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
|||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
updatedSigs := make(map[string]any)
|
updatedSigs := make(map[string]any)
|
||||||
c.signals.Range(func(sigID, value any) bool {
|
c.signals.Range(func(sigID, value any) bool {
|
||||||
if sig, ok := value.(*signal); ok {
|
switch sig := value.(type) {
|
||||||
|
case *signal:
|
||||||
if sig.err != nil {
|
if sig.err != nil {
|
||||||
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
||||||
return true
|
return true
|
||||||
@@ -256,6 +291,12 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
|||||||
if sig.changed {
|
if sig.changed {
|
||||||
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
|
updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.val)
|
||||||
}
|
}
|
||||||
|
case *computedSignal:
|
||||||
|
sig.recompute()
|
||||||
|
if sig.changed {
|
||||||
|
updatedSigs[sigID.(string)] = sig.patchValue()
|
||||||
|
sig.changed = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user