diff --git a/computed.go b/computed.go new file mode 100644 index 0000000..23ccb25 --- /dev/null +++ b/computed.go @@ -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) +} diff --git a/computed_test.go b/computed_test.go new file mode 100644 index 0000000..3faaf04 --- /dev/null +++ b/computed_test.go @@ -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()) +} diff --git a/context.go b/context.go index 536a53a..ac8e167 100644 --- a/context.go +++ b/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 })