3 Commits

Author SHA1 Message Date
Ryan Hamamura
1384e49e14 fix: preserve context across SSE reconnects on tab visibility change
Datastar aborts SSE on visibilitychange (tab hidden) and reconnects
when visible. The previous cleanup-on-disconnect destroyed the context
before the client could reconnect. Now SSE disconnect does a soft
teardown (mark disconnected, keep context alive) and reconnect drains
stale patches before resuming. The reaper uses disconnect time instead
of creation time so recently-disconnected contexts aren't prematurely
reaped.
2026-02-13 10:52:46 -10:00
Ryan Hamamura
785f11e52d fix: harden SPA navigation with race protection and correctness fixes
- Add navMu to serialize concurrent navigations on the same context
- Replace url.PathEscape with targeted JS string escaper (PathEscape
  mangles full paths and doesn't escape single quotes)
- Collapse syncWithViewTransition into syncView(bool) to remove duplication
- Simplify popstate ready guard in navigate.js
- Preserve URL hash during SPA navigation
2026-02-12 14:41:50 -10:00
Ryan Hamamura
2f19874c17 feat: add PubSub() accessor to V struct 2026-02-12 14:32:05 -10:00
4 changed files with 59 additions and 34 deletions

View File

@@ -5,8 +5,8 @@ import (
"context"
"encoding/json"
"fmt"
"net/url"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
@@ -31,6 +31,7 @@ type Context struct {
actionRegistry map[string]actionEntry
signals *sync.Map
mu sync.RWMutex
navMu sync.Mutex
ctxDisposedChan chan struct{}
pageStopChan chan struct{}
reqCtx context.Context
@@ -40,6 +41,7 @@ type Context struct {
disposeOnce sync.Once
createdAt time.Time
sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
}
// View defines the UI rendered by this context.
@@ -272,15 +274,22 @@ func (c *Context) sendPatch(p patch) {
// Sync pushes the current view state and signal changes to the browser immediately
// over the live SSE event stream.
func (c *Context) Sync() {
c.syncView(false)
}
func (c *Context) syncView(viewTransition bool) {
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{patchTypeElements, elemsPatch.String()})
typ := patchType(patchTypeElements)
if viewTransition {
typ = patchTypeElementsWithVT
}
c.sendPatch(patch{typ, elemsPatch.String()})
updatedSigs := c.prepareSignalsForPatch()
if len(updatedSigs) != 0 {
outgoingSigs, _ := json.Marshal(updatedSigs)
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
@@ -396,6 +405,9 @@ func (c *Context) resetPageState() {
// 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) {
c.navMu.Lock()
defer c.navMu.Unlock()
route, initFn, params := c.app.matchRoute(path)
if initFn == nil {
c.Redirect(path)
@@ -405,29 +417,12 @@ func (c *Context) Navigate(path string, popstate bool) {
c.route = route
c.injectRouteParams(params)
initFn(c)
c.syncWithViewTransition()
escaped := url.PathEscape(path)
c.syncView(true)
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
if popstate {
c.ExecScript(fmt.Sprintf("history.replaceState({},'',decodeURIComponent('%s'))", escaped))
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
} 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)})
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
}
}

View File

@@ -14,7 +14,8 @@ func DataIgnoreMorph() H {
// 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.
// during SPA navigation. If the element also needs other inline styles,
// include view-transition-name directly in the Style() call instead.
func DataViewTransition(name string) H {
return Attr("style", "view-transition-name: "+name)
}

View File

@@ -38,15 +38,14 @@
var url = new URL(href, window.location.origin);
if (url.origin !== window.location.origin) return;
e.preventDefault();
navigate(url.pathname + url.search);
navigate(url.pathname + url.search + url.hash);
} catch(_) {}
});
var ready = false;
window.addEventListener('popstate', function() {
if (!ready) { ready = true; return; }
navigate(window.location.pathname + window.location.search, true);
if (!ready) return;
navigate(window.location.pathname + window.location.search + window.location.hash, true);
});
// Mark as ready after initial load completes
setTimeout(function() { ready = true; }, 0);
})();

34
via.go
View File

@@ -318,9 +318,20 @@ func (v *V) reapOrphanedContexts(ttl time.Duration) {
v.contextRegistryMutex.RLock()
var orphans []*Context
for _, c := range v.contextRegistry {
if !c.sseConnected.Load() && now.Sub(c.createdAt) > ttl {
if c.sseConnected.Load() {
continue
}
if dc := c.sseDisconnectedAt.Load(); dc != nil {
// SSE was connected then dropped — reap if gone too long
if now.Sub(*dc) > ttl {
orphans = append(orphans, c)
}
} else {
// SSE never connected — reap based on creation time
if now.Sub(c.createdAt) > ttl {
orphans = append(orphans, c)
}
}
}
v.contextRegistryMutex.RUnlock()
@@ -419,6 +430,11 @@ func (v *V) HTTPServeMux() *http.ServeMux {
return v.mux
}
// PubSub returns the configured PubSub backend, or nil if none is set.
func (v *V) PubSub() PubSub {
return v.pubsub
}
// Static serves files from a filesystem directory at the given URL prefix.
//
// Example:
@@ -619,7 +635,19 @@ func New() *V {
// use last-event-id to tell if request is a sse reconnect
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via"))
// Drain stale patches on reconnect so the client gets fresh state
if c.sseDisconnectedAt.Load() != nil {
for {
select {
case <-c.patchChan:
default:
goto drained
}
}
drained:
}
c.sseConnected.Store(true)
c.sseDisconnectedAt.Store(nil)
v.logDebug(c, "SSE connection established")
go c.Sync()
@@ -628,7 +656,9 @@ func New() *V {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended")
v.cleanupCtx(c)
c.sseConnected.Store(false)
now := time.Now()
c.sseDisconnectedAt.Store(&now)
return
case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE")