diff --git a/context.go b/context.go
index f7cd299..5d3882a 100644
--- a/context.go
+++ b/context.go
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "net/url"
"reflect"
"sync"
"sync/atomic"
@@ -31,6 +32,7 @@ type Context struct {
signals *sync.Map
mu sync.RWMutex
ctxDisposedChan chan struct{}
+ pageStopChan chan struct{}
reqCtx context.Context
fields []*Field
subscriptions []Subscription
@@ -48,7 +50,11 @@ func (c *Context) View(f func() h.H) {
if f == nil {
panic("nil viewfn")
}
- c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
+ if c.app.layout != nil {
+ c.view = func() h.H { return h.Div(h.ID(c.id), c.app.layout(f)) }
+ } else {
+ c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
+ }
}
// Component registers a subcontext that has self contained data, actions and signals.
@@ -135,13 +141,15 @@ func (c *Context) getAction(id string) (actionEntry, error) {
// The goroutine is tied to the context lifecycle and will stop when the context is disposed.
// Returns a func() that stops the interval when called.
func (c *Context) OnInterval(duration time.Duration, handler func()) func() {
- var cn chan struct{}
- if c.isComponent() { // components use the chan on the parent page ctx
- cn = c.parentPageCtx.ctxDisposedChan
+ var disposeCh, pageCh chan struct{}
+ if c.isComponent() {
+ disposeCh = c.parentPageCtx.ctxDisposedChan
+ pageCh = c.parentPageCtx.pageStopChan
} else {
- cn = c.ctxDisposedChan
+ disposeCh = c.ctxDisposedChan
+ pageCh = c.pageStopChan
}
- return newOnInterval(cn, duration, handler)
+ return newOnInterval(disposeCh, pageCh, duration, handler)
}
// Signal creates a reactive signal and initializes it with the given value.
@@ -369,6 +377,60 @@ func (c *Context) ReplaceURLf(format string, a ...any) {
c.ReplaceURL(fmt.Sprintf(format, a...))
}
+// resetPageState tears down page-specific state (intervals, subscriptions,
+// actions, signals, fields) without disposing the context itself. The SSE
+// connection and context lifetime are unaffected.
+func (c *Context) resetPageState() {
+ close(c.pageStopChan)
+ c.unsubscribeAll()
+ c.mu.Lock()
+ c.actionRegistry = make(map[string]actionEntry)
+ c.signals = new(sync.Map)
+ c.fields = nil
+ c.pageStopChan = make(chan struct{})
+ c.mu.Unlock()
+}
+
+// Navigate performs an SPA navigation to the given path. It resets page state,
+// runs the target page's init function (with middleware), and pushes the new
+// view over the existing SSE connection with a view transition animation.
+// If popstate is true, replaceState is used instead of pushState.
+func (c *Context) Navigate(path string, popstate bool) {
+ route, initFn, params := c.app.matchRoute(path)
+ if initFn == nil {
+ c.Redirect(path)
+ return
+ }
+ c.resetPageState()
+ c.route = route
+ c.injectRouteParams(params)
+ initFn(c)
+ c.syncWithViewTransition()
+ escaped := url.PathEscape(path)
+ if popstate {
+ c.ExecScript(fmt.Sprintf("history.replaceState({},'',decodeURIComponent('%s'))", escaped))
+ } else {
+ c.ExecScript(fmt.Sprintf("history.pushState({},'',decodeURIComponent('%s'))", escaped))
+ }
+}
+
+// syncWithViewTransition renders the view and sends it as a PatchElements
+// with the view transition flag, plus any changed signals.
+func (c *Context) syncWithViewTransition() {
+ elemsPatch := new(bytes.Buffer)
+ if err := c.view().Render(elemsPatch); err != nil {
+ c.app.logErr(c, "sync view failed: %v", err)
+ return
+ }
+ c.sendPatch(patch{patchTypeElementsWithVT, elemsPatch.String()})
+
+ updatedSigs := c.prepareSignalsForPatch()
+ if len(updatedSigs) != 0 {
+ outgoingSigs, _ := json.Marshal(updatedSigs)
+ c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
+ }
+}
+
// dispose idempotently tears down this context: unsubscribes all pubsub
// subscriptions and closes ctxDisposedChan to stop routines and exit the SSE loop.
func (c *Context) dispose() {
@@ -539,8 +601,9 @@ func newContext(id string, route string, v *V) *Context {
actionLimiter: newLimiter(v.actionRateLimit, defaultActionRate, defaultActionBurst),
actionRegistry: make(map[string]actionEntry),
signals: new(sync.Map),
- patchChan: make(chan patch, 1),
+ patchChan: make(chan patch, 8),
ctxDisposedChan: make(chan struct{}, 1),
+ pageStopChan: make(chan struct{}),
createdAt: time.Now(),
}
}
diff --git a/h/datastar.go b/h/datastar.go
index 55db744..cd907d8 100644
--- a/h/datastar.go
+++ b/h/datastar.go
@@ -11,3 +11,10 @@ func DataEffect(expression string) H {
func DataIgnoreMorph() H {
return Attr("data-ignore-morph")
}
+
+// DataViewTransition sets the view-transition-name CSS property on an element
+// via an inline style. Elements with matching names animate between pages
+// during SPA navigation.
+func DataViewTransition(name string) H {
+ return Attr("style", "view-transition-name: "+name)
+}
diff --git a/internal/examples/spa/main.go b/internal/examples/spa/main.go
new file mode 100644
index 0000000..807e350
--- /dev/null
+++ b/internal/examples/spa/main.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/ryanhamamura/via"
+ . "github.com/ryanhamamura/via/h"
+)
+
+func main() {
+ v := via.New()
+ v.Config(via.Options{
+ DocumentTitle: "SPA Navigation",
+ ServerAddress: ":7331",
+ })
+
+ v.AppendToHead(
+ Raw(``),
+ Raw(``),
+ Raw(``),
+ Raw(``),
+ )
+
+ v.Layout(func(content func() H) H {
+ return Div(
+ Nav(
+ Style("display:flex;gap:1rem;padding:1rem;background:#222;"),
+ A(Href("/"), Text("Home"), Style("color:#fff")),
+ A(Href("/counter"), Text("Counter"), Style("color:#fff")),
+ A(Href("/clock"), Text("Clock"), Style("color:#fff")),
+ A(Href("https://github.com"), Text("GitHub (external)"), Style("color:#888")),
+ A(Href("/"), Text("Full Reload"), Attr("data-via-no-boost"), Style("color:#f88")),
+ ),
+ Main(Style("padding:1rem"), content()),
+ )
+ })
+
+ // Home page
+ v.Page("/", func(c *via.Context) {
+ goCounter := c.Action(func() { c.Navigate("/counter", false) })
+
+ c.View(func() H {
+ return Div(
+ H1(Text("Home"), DataViewTransition("page-title")),
+ P(Text("Click the nav links above — no page reload, no white flash.")),
+ P(Text("Or navigate programmatically:")),
+ Button(Text("Go to Counter"), goCounter.OnClick()),
+ )
+ })
+ })
+
+ // Counter page — demonstrates signals and actions survive within a page,
+ // but reset on navigate away and back.
+ v.Page("/counter", func(c *via.Context) {
+ count := 0
+ increment := c.Action(func() { count++; c.Sync() })
+ goHome := c.Action(func() { c.Navigate("/", false) })
+
+ c.View(func() H {
+ return Div(
+ H1(Text("Counter"), DataViewTransition("page-title")),
+ P(Textf("Count: %d", count)),
+ Button(Text("+1"), increment.OnClick()),
+ Button(Text("Go Home"), goHome.OnClick(), Style("margin-left:0.5rem")),
+ )
+ })
+ })
+
+ // Clock page — demonstrates OnInterval cleanup on navigate.
+ v.Page("/clock", func(c *via.Context) {
+ now := time.Now().Format("15:04:05")
+ c.OnInterval(time.Second, func() {
+ now = time.Now().Format("15:04:05")
+ c.Sync()
+ })
+
+ c.View(func() H {
+ return Div(
+ H1(Text("Clock"), DataViewTransition("page-title")),
+ P(Text("This page has an OnInterval that ticks every second.")),
+ P(Textf("Current time: %s", now)),
+ P(Text("Navigate away and back — the old interval stops, a new one starts.")),
+ P(Textf("Proof this is a fresh page init: random = %d", time.Now().UnixNano()%1000)),
+ )
+ })
+ })
+
+ fmt.Println("SPA example running at http://localhost:7331")
+ v.Start()
+}
diff --git a/navigate.js b/navigate.js
new file mode 100644
index 0000000..9735775
--- /dev/null
+++ b/navigate.js
@@ -0,0 +1,52 @@
+(function() {
+ const meta = document.querySelector('meta[data-signals]');
+ if (!meta) return;
+ const raw = meta.getAttribute('data-signals');
+ const parsed = JSON.parse(raw.replace(/'/g, '"'));
+ const ctxID = parsed['via-ctx'];
+ const csrf = parsed['via-csrf'];
+ if (!ctxID || !csrf) return;
+
+ function navigate(url, popstate) {
+ const params = new URLSearchParams({
+ 'via-ctx': ctxID,
+ 'via-csrf': csrf,
+ 'url': url,
+ });
+ if (popstate) params.set('popstate', '1');
+ fetch('/_navigate', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'},
+ body: params.toString()
+ }).then(function(res) {
+ if (!res.ok) window.location.href = url;
+ }).catch(function() {
+ window.location.href = url;
+ });
+ }
+
+ document.addEventListener('click', function(e) {
+ var el = e.target;
+ while (el && el.tagName !== 'A') el = el.parentElement;
+ if (!el) return;
+ if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
+ if (el.hasAttribute('target')) return;
+ if (el.hasAttribute('data-via-no-boost')) return;
+ var href = el.getAttribute('href');
+ if (!href || href.startsWith('#')) return;
+ try {
+ var url = new URL(href, window.location.origin);
+ if (url.origin !== window.location.origin) return;
+ e.preventDefault();
+ navigate(url.pathname + url.search);
+ } catch(_) {}
+ });
+
+ var ready = false;
+ window.addEventListener('popstate', function() {
+ if (!ready) { ready = true; return; }
+ navigate(window.location.pathname + window.location.search, true);
+ });
+ // Mark as ready after initial load completes
+ setTimeout(function() { ready = true; }, 0);
+})();
diff --git a/routine.go b/routine.go
index 8ec0b9c..6de70c9 100644
--- a/routine.go
+++ b/routine.go
@@ -5,7 +5,7 @@ import (
"time"
)
-func newOnInterval(ctxDisposedChan chan struct{}, duration time.Duration, handler func()) func() {
+func newOnInterval(ctxDisposedChan, pageStopChan chan struct{}, duration time.Duration, handler func()) func() {
localInterrupt := make(chan struct{})
var stopped atomic.Bool
@@ -16,6 +16,8 @@ func newOnInterval(ctxDisposedChan chan struct{}, duration time.Duration, handle
select {
case <-ctxDisposedChan:
return
+ case <-pageStopChan:
+ return
case <-localInterrupt:
return
case <-tkr.C:
diff --git a/via.go b/via.go
index c2c37ed..3b3bf41 100644
--- a/via.go
+++ b/via.go
@@ -35,6 +35,9 @@ import (
//go:embed datastar.js
var datastarJS []byte
+//go:embed navigate.js
+var navigateJS []byte
+
// V is the root application.
// It manages page routing, user sessions, and SSE connections for live updates.
type V struct {
@@ -47,6 +50,7 @@ type V struct {
documentHeadIncludes []h.H
documentFootIncludes []h.H
devModePageInitFnMap map[string]func(*Context)
+ pageRegistry map[string]func(*Context)
sessionManager *scs.SessionManager
pubsub PubSub
defaultNATS *defaultNATS
@@ -56,6 +60,7 @@ type V struct {
datastarOnce sync.Once
reaperStop chan struct{}
middleware []Middleware
+ layout func(func() h.H) h.H
}
func (v *V) logEvent(evt *zerolog.Event, c *Context) *zerolog.Event {
@@ -196,6 +201,7 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
c.stopAllRoutines()
}()
+ v.pageRegistry[route] = wrapped
if v.cfg.DevMode {
v.devModePageInitFnMap[route] = wrapped
}
@@ -223,6 +229,8 @@ func (v *V) page(route string, raw, wrapped func(*Context)) {
h.Meta(h.Data("init", "@get('/_sse')")),
h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
navigator.sendBeacon('/_session/close', '%s');});`, c.id))),
+ h.Meta(h.Attr("name", "view-transition"), h.Attr("content", "same-origin")),
+ h.Script(h.Raw(string(navigateJS))),
)
bodyElements := []h.H{c.view()}
@@ -557,6 +565,7 @@ type patchType int
const (
patchTypeElements = iota
+ patchTypeElementsWithVT
patchTypeSignals
patchTypeScript
patchTypeRedirect
@@ -577,6 +586,7 @@ func New() *V {
logger: newConsoleLogger(zerolog.InfoLevel),
contextRegistry: make(map[string]*Context),
devModePageInitFnMap: make(map[string]func(*Context)),
+ pageRegistry: make(map[string]func(*Context)),
sessionManager: scs.New(),
datastarPath: "/_datastar.js",
datastarContent: datastarJS,
@@ -627,11 +637,16 @@ func New() *V {
switch patch.typ {
case patchTypeElements:
if err := sse.PatchElements(patch.content); err != nil {
- // Only log if connection wasn't closed (avoids noise during shutdown/tests)
if sse.Context().Err() == nil {
v.logErr(c, "PatchElements failed: %v", err)
}
}
+ case patchTypeElementsWithVT:
+ if err := sse.PatchElements(patch.content, datastar.WithViewTransitions()); err != nil {
+ if sse.Context().Err() == nil {
+ v.logErr(c, "PatchElements (view transition) failed: %v", err)
+ }
+ }
case patchTypeSignals:
if err := sse.PatchSignals([]byte(patch.content)); err != nil {
if sse.Context().Err() == nil {
@@ -711,6 +726,39 @@ func New() *V {
}
})
+ v.mux.HandleFunc("POST /_navigate", func(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ cID := r.FormValue("via-ctx")
+ csrfToken := r.FormValue("via-csrf")
+ navURL := r.FormValue("url")
+ popstate := r.FormValue("popstate") == "1"
+
+ if cID == "" || navURL == "" || !strings.HasPrefix(navURL, "/") {
+ http.Error(w, "missing or invalid parameters", http.StatusBadRequest)
+ return
+ }
+ c, err := v.getCtx(cID)
+ if err != nil {
+ v.logErr(nil, "navigate failed: %v", err)
+ http.Error(w, "context not found", http.StatusNotFound)
+ return
+ }
+ if subtle.ConstantTimeCompare([]byte(csrfToken), []byte(c.csrfToken)) != 1 {
+ v.logWarn(c, "navigate rejected: invalid CSRF token")
+ http.Error(w, "invalid CSRF token", http.StatusForbidden)
+ return
+ }
+ if c.actionLimiter != nil && !c.actionLimiter.Allow() {
+ v.logWarn(c, "navigate rate limited")
+ http.Error(w, "rate limited", http.StatusTooManyRequests)
+ return
+ }
+ c.reqCtx = r.Context()
+ v.logDebug(c, "SPA navigate to %s", navURL)
+ c.Navigate(navURL, popstate)
+ w.WriteHeader(http.StatusOK)
+ })
+
v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
@@ -769,3 +817,19 @@ func extractParams(pattern, path string) map[string]string {
}
return params
}
+
+// matchRoute finds the registered page init function and extracted params for the given path.
+func (v *V) matchRoute(path string) (route string, initFn func(*Context), params map[string]string) {
+ for pattern, fn := range v.pageRegistry {
+ if p := extractParams(pattern, path); p != nil {
+ return pattern, fn, p
+ }
+ }
+ return "", nil, nil
+}
+
+// Layout sets a layout function that wraps every page's view.
+// The layout receives the page content as a function and returns the full view.
+func (v *V) Layout(f func(func() h.H) h.H) {
+ v.layout = f
+}