Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6763e1a420 |
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) {
|
||||
if sigs == nil {
|
||||
c.app.logErr(c, "signal injection failed: nil signals")
|
||||
@@ -248,7 +282,8 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
||||
defer c.mu.RUnlock()
|
||||
updatedSigs := make(map[string]any)
|
||||
c.signals.Range(func(sigID, value any) bool {
|
||||
if sig, ok := value.(*signal); ok {
|
||||
switch sig := value.(type) {
|
||||
case *signal:
|
||||
if sig.err != nil {
|
||||
c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err)
|
||||
return true
|
||||
@@ -256,6 +291,12 @@ func (c *Context) prepareSignalsForPatch() map[string]any {
|
||||
if sig.changed {
|
||||
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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user