From 27b8540b71a348326ad84f5f5f03558dd3b10abb Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:52:47 -1000 Subject: [PATCH] feat: add SPA navigation with view transitions Swap page content over the existing SSE connection without full page loads. A persistent Context resets its page-specific state (signals, actions, intervals, subscriptions) on navigate while preserving the SSE stream, CSRF token, and session. - c.Navigate(path) for programmatic SPA navigation from actions - Injected JS intercepts same-origin clicks (opt out with data-via-no-boost) and handles popstate for back/forward - v.Layout() wraps pages in a shared shell for DRY nav/chrome - View Transition API integration via WithViewTransitions() on PatchElements and h.DataViewTransition() helper - POST /_navigate endpoint with CSRF validation and rate limiting - pageStopChan cancels page-level OnInterval goroutines on navigate - Includes SPA example with layout, counter, and live clock pages --- context.go | 77 ++++++++++++++++++++++++++--- h/datastar.go | 7 +++ internal/examples/spa/main.go | 91 +++++++++++++++++++++++++++++++++++ navigate.js | 52 ++++++++++++++++++++ routine.go | 4 +- via.go | 66 ++++++++++++++++++++++++- 6 files changed, 288 insertions(+), 9 deletions(-) create mode 100644 internal/examples/spa/main.go create mode 100644 navigate.js 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 +}