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.
This commit is contained in:
Ryan Hamamura
2026-02-13 10:52:46 -10:00
parent 785f11e52d
commit 1384e49e14
2 changed files with 31 additions and 5 deletions

View File

@@ -39,8 +39,9 @@ type Context struct {
subscriptions []Subscription subscriptions []Subscription
subsMu sync.Mutex subsMu sync.Mutex
disposeOnce sync.Once disposeOnce sync.Once
createdAt time.Time createdAt time.Time
sseConnected atomic.Bool sseConnected atomic.Bool
sseDisconnectedAt atomic.Pointer[time.Time]
} }
// View defines the UI rendered by this context. // View defines the UI rendered by this context.

31
via.go
View File

@@ -318,8 +318,19 @@ func (v *V) reapOrphanedContexts(ttl time.Duration) {
v.contextRegistryMutex.RLock() v.contextRegistryMutex.RLock()
var orphans []*Context var orphans []*Context
for _, c := range v.contextRegistry { for _, c := range v.contextRegistry {
if !c.sseConnected.Load() && now.Sub(c.createdAt) > ttl { if c.sseConnected.Load() {
orphans = append(orphans, c) 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() v.contextRegistryMutex.RUnlock()
@@ -624,7 +635,19 @@ func New() *V {
// use last-event-id to tell if request is a sse reconnect // use last-event-id to tell if request is a sse reconnect
sse.Send(datastar.EventTypePatchElements, []string{}, datastar.WithSSEEventId("via")) 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.sseConnected.Store(true)
c.sseDisconnectedAt.Store(nil)
v.logDebug(c, "SSE connection established") v.logDebug(c, "SSE connection established")
go c.Sync() go c.Sync()
@@ -633,7 +656,9 @@ func New() *V {
select { select {
case <-sse.Context().Done(): case <-sse.Context().Done():
v.logDebug(c, "SSE connection ended") v.logDebug(c, "SSE connection ended")
v.cleanupCtx(c) c.sseConnected.Store(false)
now := time.Now()
c.sseDisconnectedAt.Store(&now)
return return
case <-c.ctxDisposedChan: case <-c.ctxDisposedChan:
v.logDebug(c, "context disposed, closing SSE") v.logDebug(c, "context disposed, closing SSE")