2 Commits

Author SHA1 Message Date
Ryan Hamamura
5d61149fa3 fix: make embedded NATS opt-in so tests don't hang
All checks were successful
CI / Build and Test (push) Successful in 31s
Move NATS startup from New() to Start(), so tests that don't use
pubsub never block on server initialization. Add a 10s timeout to
WaitForServer() and skip NATS tests gracefully when unavailable.
2026-02-18 08:45:03 -10:00
Ryan Hamamura
08b7dbd17f feat: add WithDebounce and WithThrottle action trigger options
Unify event attribute construction through buildAttrKey() so debounce,
throttle, and window modifiers compose cleanly. OnChange no longer
hardcodes a 200ms debounce — callers opt in explicitly.
2026-02-18 08:44:58 -10:00
7 changed files with 205 additions and 41 deletions

View File

@@ -3,6 +3,7 @@ package via
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"time"
"github.com/ryanhamamura/via/h" "github.com/ryanhamamura/via/h"
) )
@@ -23,6 +24,8 @@ type triggerOpts struct {
value string value string
window bool window bool
preventDefault bool preventDefault bool
debounce time.Duration
throttle time.Duration
} }
type withSignalOpt struct { type withSignalOpt struct {
@@ -58,6 +61,41 @@ func WithPreventDefault() ActionTriggerOption {
return withPreventDefaultOpt{} return withPreventDefaultOpt{}
} }
type withDebounceOpt struct{ d time.Duration }
func (o withDebounceOpt) apply(opts *triggerOpts) { opts.debounce = o.d }
// WithDebounce adds a debounce modifier to the event trigger.
func WithDebounce(d time.Duration) ActionTriggerOption { return withDebounceOpt{d} }
type withThrottleOpt struct{ d time.Duration }
func (o withThrottleOpt) apply(opts *triggerOpts) { opts.throttle = o.d }
// WithThrottle adds a throttle modifier to the event trigger.
func WithThrottle(d time.Duration) ActionTriggerOption { return withThrottleOpt{d} }
// formatDuration renders a duration as e.g. "200ms" for Datastar modifiers.
func formatDuration(d time.Duration) string {
return fmt.Sprintf("%dms", d.Milliseconds())
}
// buildAttrKey constructs a Datastar attribute key with modifiers.
// Order: event → debounce/throttle → window.
func buildAttrKey(event string, opts *triggerOpts) string {
key := "on:" + event
if opts.debounce > 0 {
key += "__debounce." + formatDuration(opts.debounce)
}
if opts.throttle > 0 {
key += "__throttle." + formatDuration(opts.throttle)
}
if opts.window {
key += "__window"
}
return key
}
// WithSignal sets a signal value before triggering the action. // WithSignal sets a signal value before triggering the action.
func WithSignal(sig *signal, value string) ActionTriggerOption { func WithSignal(sig *signal, value string) ActionTriggerOption {
return withSignalOpt{ return withSignalOpt{
@@ -97,62 +135,62 @@ func actionURL(id string) string {
// to element nodes in a view. // to element nodes in a view.
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("click", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added // OnChange returns a via.h DOM attribute that triggers on input change. It can be added
// to element nodes in a view. // to element nodes in a view.
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("change", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnSubmit returns a via.h DOM attribute that triggers on form submit. // OnSubmit returns a via.h DOM attribute that triggers on form submit.
func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnSubmit(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:submit", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("submit", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnInput returns a via.h DOM attribute that triggers on input (without debounce). // OnInput returns a via.h DOM attribute that triggers on input (without debounce).
func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnInput(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:input", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("input", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnFocus returns a via.h DOM attribute that triggers when the element gains focus. // OnFocus returns a via.h DOM attribute that triggers when the element gains focus.
func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnFocus(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:focus", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("focus", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnBlur returns a via.h DOM attribute that triggers when the element loses focus. // OnBlur returns a via.h DOM attribute that triggers when the element loses focus.
func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnBlur(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:blur", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("blur", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element. // OnMouseEnter returns a via.h DOM attribute that triggers when the mouse enters the element.
func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnMouseEnter(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:mouseenter", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("mouseenter", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element. // OnMouseLeave returns a via.h DOM attribute that triggers when the mouse leaves the element.
func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnMouseLeave(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:mouseleave", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("mouseleave", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnScroll returns a via.h DOM attribute that triggers on scroll. // OnScroll returns a via.h DOM attribute that triggers on scroll.
func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnScroll(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:scroll", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("scroll", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnDblClick returns a via.h DOM attribute that triggers on double click. // OnDblClick returns a via.h DOM attribute that triggers on double click.
func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H { func (a *actionTrigger) OnDblClick(options ...ActionTriggerOption) h.H {
opts := applyOptions(options...) opts := applyOptions(options...)
return h.Data("on:dblclick", buildOnExpr(actionURL(a.id), &opts)) return h.Data(buildAttrKey("dblclick", &opts), buildOnExpr(actionURL(a.id), &opts))
} }
// OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed. // OnKeyDown returns a via.h DOM attribute that triggers when a key is pressed.
@@ -164,11 +202,7 @@ func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.
if key != "" { if key != "" {
condition = fmt.Sprintf("evt.key==='%s' &&", key) condition = fmt.Sprintf("evt.key==='%s' &&", key)
} }
attrName := "on:keydown" return h.Data(buildAttrKey("keydown", &opts), fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
if opts.window {
attrName = "on:keydown__window"
}
return h.Data(attrName, fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
} }
// KeyBinding pairs a key with an action and per-binding options. // KeyBinding pairs a key with an action and per-binding options.

14
nats.go
View File

@@ -56,7 +56,19 @@ func startDefaultNATS() (dn *defaultNATS, err error) {
os.RemoveAll(dataDir) os.RemoveAll(dataDir)
return nil, fmt.Errorf("start embedded nats: %w", err) return nil, fmt.Errorf("start embedded nats: %w", err)
} }
ns.WaitForServer() ready := make(chan struct{})
go func() {
ns.WaitForServer()
close(ready)
}()
select {
case <-ready:
case <-time.After(10 * time.Second):
ns.Close()
cancel()
os.RemoveAll(dataDir)
return nil, fmt.Errorf("embedded nats server did not start within 10s")
}
nc, err := ns.Client() nc, err := ns.Client()
if err != nil { if err != nil {

View File

@@ -10,9 +10,24 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestPubSub_RoundTrip(t *testing.T) { // setupNATSTest creates a *V with an embedded NATS server.
// Skips the test if NATS fails to start (e.g. port conflict in CI).
func setupNATSTest(t *testing.T) *V {
t.Helper()
v := New() v := New()
defer v.Shutdown() dn, err := getSharedNATS()
if err != nil {
v.Shutdown()
t.Skipf("embedded NATS unavailable: %v", err)
}
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
t.Cleanup(v.Shutdown)
return v
}
func TestPubSub_RoundTrip(t *testing.T) {
v := setupNATSTest(t)
var received []byte var received []byte
done := make(chan struct{}) done := make(chan struct{})
@@ -38,8 +53,7 @@ func TestPubSub_RoundTrip(t *testing.T) {
} }
func TestPubSub_MultipleSubscribers(t *testing.T) { func TestPubSub_MultipleSubscribers(t *testing.T) {
v := New() v := setupNATSTest(t)
defer v.Shutdown()
var mu sync.Mutex var mu sync.Mutex
var results []string var results []string
@@ -84,8 +98,7 @@ func TestPubSub_MultipleSubscribers(t *testing.T) {
} }
func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) { func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
v := New() v := setupNATSTest(t)
defer v.Shutdown()
c := newContext("cleanup-ctx", "/", v) c := newContext("cleanup-ctx", "/", v)
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })
@@ -100,8 +113,7 @@ func TestPubSub_SubscriptionCleanupOnDispose(t *testing.T) {
} }
func TestPubSub_ManualUnsubscribe(t *testing.T) { func TestPubSub_ManualUnsubscribe(t *testing.T) {
v := New() v := setupNATSTest(t)
defer v.Shutdown()
c := newContext("unsub-ctx", "/", v) c := newContext("unsub-ctx", "/", v)
c.View(func() h.H { return h.Div() }) c.View(func() h.H { return h.Div() })
@@ -120,8 +132,7 @@ func TestPubSub_ManualUnsubscribe(t *testing.T) {
} }
func TestPubSub_NoOpDuringPanicCheck(t *testing.T) { func TestPubSub_NoOpDuringPanicCheck(t *testing.T) {
v := New() v := setupNATSTest(t)
defer v.Shutdown()
// Panic-check context has id="" // Panic-check context has id=""
c := newContext("", "/", v) c := newContext("", "/", v)

View File

@@ -1,8 +1,9 @@
package via package via
// PubSub is an interface for publish/subscribe messaging backends. // PubSub is an interface for publish/subscribe messaging backends.
// By default, New() starts an embedded NATS server. Supply a custom // By default, Start() launches an embedded NATS server if no backend
// implementation via Config(Options{PubSub: yourBackend}) to override. // has been configured. Supply a custom implementation via
// Config(Options{PubSub: yourBackend}) to override.
type PubSub interface { type PubSub interface {
Publish(subject string, data []byte) error Publish(subject string, data []byte) error
Subscribe(subject string, handler func(data []byte)) (Subscription, error) Subscribe(subject string, handler func(data []byte)) (Subscription, error)

View File

@@ -10,8 +10,7 @@ import (
) )
func TestPublishSubscribe_RoundTrip(t *testing.T) { func TestPublishSubscribe_RoundTrip(t *testing.T) {
v := New() v := setupNATSTest(t)
defer v.Shutdown()
type event struct { type event struct {
Name string `json:"name"` Name string `json:"name"`
@@ -43,8 +42,7 @@ func TestPublishSubscribe_RoundTrip(t *testing.T) {
} }
func TestSubscribe_SkipsBadJSON(t *testing.T) { func TestSubscribe_SkipsBadJSON(t *testing.T) {
v := New() v := setupNATSTest(t)
defer v.Shutdown()
type msg struct { type msg struct {
Text string `json:"text"` Text string `json:"text"`

18
via.go
View File

@@ -358,6 +358,16 @@ func (v *V) reapOrphanedContexts(suspendAfter, ttl time.Duration) {
// Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM // Start starts the Via HTTP server and blocks until a SIGINT or SIGTERM
// signal is received, then performs a graceful shutdown. // signal is received, then performs a graceful shutdown.
func (v *V) Start() { func (v *V) Start() {
if v.pubsub == nil {
dn, err := getSharedNATS()
if err != nil {
v.logWarn(nil, "embedded NATS unavailable: %v", err)
} else {
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
}
}
handler := http.Handler(v.mux) handler := http.Handler(v.mux)
if v.sessionManager != nil { if v.sessionManager != nil {
handler = v.sessionManager.LoadAndSave(v.mux) handler = v.sessionManager.LoadAndSave(v.mux)
@@ -833,14 +843,6 @@ func New() *V {
v.cleanupCtx(c) v.cleanupCtx(c)
}) })
dn, err := getSharedNATS()
if err != nil {
v.logWarn(nil, "embedded NATS unavailable: %v", err)
} else {
v.defaultNATS = dn
v.pubsub = &natsRef{dn: dn}
}
return v return v
} }

View File

@@ -127,7 +127,7 @@ func TestAction(t *testing.T) {
v.mux.ServeHTTP(w, req) v.mux.ServeHTTP(w, req)
body := w.Body.String() body := w.Body.String()
assert.Contains(t, body, "data-on:click") assert.Contains(t, body, "data-on:click")
assert.Contains(t, body, "data-on:change__debounce.200ms") assert.Contains(t, body, "data-on:change")
assert.Contains(t, body, "data-on:keydown") assert.Contains(t, body, "data-on:keydown")
assert.Contains(t, body, "/_action/") assert.Contains(t, body, "/_action/")
} }
@@ -281,6 +281,112 @@ func TestOnKeyDownMap(t *testing.T) {
}) })
} }
func TestFormatDuration(t *testing.T) {
tests := []struct {
d time.Duration
want string
}{
{200 * time.Millisecond, "200ms"},
{1 * time.Second, "1000ms"},
{50 * time.Millisecond, "50ms"},
}
for _, tt := range tests {
assert.Equal(t, tt.want, formatDuration(tt.d))
}
}
func TestBuildAttrKey(t *testing.T) {
tests := []struct {
name string
event string
opts triggerOpts
want string
}{
{"bare event", "click", triggerOpts{}, "on:click"},
{"debounce only", "change", triggerOpts{debounce: 200 * time.Millisecond}, "on:change__debounce.200ms"},
{"throttle only", "scroll", triggerOpts{throttle: 100 * time.Millisecond}, "on:scroll__throttle.100ms"},
{"window only", "keydown", triggerOpts{window: true}, "on:keydown__window"},
{"debounce + window", "input", triggerOpts{debounce: 300 * time.Millisecond, window: true}, "on:input__debounce.300ms__window"},
{"throttle + window", "scroll", triggerOpts{throttle: 500 * time.Millisecond, window: true}, "on:scroll__throttle.500ms__window"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, buildAttrKey(tt.event, &tt.opts))
})
}
}
func TestWithDebounce(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Button(trigger.OnClick(WithDebounce(300 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:click__debounce.300ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestWithThrottle(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Div(trigger.OnScroll(WithThrottle(100 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:scroll__throttle.100ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestWithDebounceOnChange(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Input(trigger.OnChange(WithDebounce(200 * time.Millisecond)))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:change__debounce.200ms")
assert.Contains(t, body, "/_action/"+trigger.id)
}
func TestDebounceWithWindow(t *testing.T) {
var trigger *actionTrigger
v := New()
v.Page("/", func(c *Context) {
trigger = c.Action(func() {})
c.View(func() h.H {
return h.Div(trigger.OnKeyDown("Enter", WithDebounce(150*time.Millisecond), WithWindow()))
})
})
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
v.mux.ServeHTTP(w, req)
body := w.Body.String()
assert.Contains(t, body, "data-on:keydown__debounce.150ms__window")
}
func TestConfig(t *testing.T) { func TestConfig(t *testing.T) {
v := New() v := New()
v.Config(Options{DocumentTitle: "Test"}) v.Config(Options{DocumentTitle: "Test"})