Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1384e49e14 | ||
|
|
785f11e52d | ||
|
|
2f19874c17 |
47
context.go
47
context.go
@@ -5,8 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -31,6 +31,7 @@ type Context struct {
|
|||||||
actionRegistry map[string]actionEntry
|
actionRegistry map[string]actionEntry
|
||||||
signals *sync.Map
|
signals *sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
navMu sync.Mutex
|
||||||
ctxDisposedChan chan struct{}
|
ctxDisposedChan chan struct{}
|
||||||
pageStopChan chan struct{}
|
pageStopChan chan struct{}
|
||||||
reqCtx context.Context
|
reqCtx context.Context
|
||||||
@@ -38,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.
|
||||||
@@ -272,15 +274,22 @@ func (c *Context) sendPatch(p patch) {
|
|||||||
// Sync pushes the current view state and signal changes to the browser immediately
|
// Sync pushes the current view state and signal changes to the browser immediately
|
||||||
// over the live SSE event stream.
|
// over the live SSE event stream.
|
||||||
func (c *Context) Sync() {
|
func (c *Context) Sync() {
|
||||||
|
c.syncView(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) syncView(viewTransition bool) {
|
||||||
elemsPatch := new(bytes.Buffer)
|
elemsPatch := new(bytes.Buffer)
|
||||||
if err := c.view().Render(elemsPatch); err != nil {
|
if err := c.view().Render(elemsPatch); err != nil {
|
||||||
c.app.logErr(c, "sync view failed: %v", err)
|
c.app.logErr(c, "sync view failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.sendPatch(patch{patchTypeElements, elemsPatch.String()})
|
typ := patchType(patchTypeElements)
|
||||||
|
if viewTransition {
|
||||||
|
typ = patchTypeElementsWithVT
|
||||||
|
}
|
||||||
|
c.sendPatch(patch{typ, elemsPatch.String()})
|
||||||
|
|
||||||
updatedSigs := c.prepareSignalsForPatch()
|
updatedSigs := c.prepareSignalsForPatch()
|
||||||
|
|
||||||
if len(updatedSigs) != 0 {
|
if len(updatedSigs) != 0 {
|
||||||
outgoingSigs, _ := json.Marshal(updatedSigs)
|
outgoingSigs, _ := json.Marshal(updatedSigs)
|
||||||
c.sendPatch(patch{patchTypeSignals, string(outgoingSigs)})
|
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.
|
// view over the existing SSE connection with a view transition animation.
|
||||||
// If popstate is true, replaceState is used instead of pushState.
|
// If popstate is true, replaceState is used instead of pushState.
|
||||||
func (c *Context) Navigate(path string, popstate bool) {
|
func (c *Context) Navigate(path string, popstate bool) {
|
||||||
|
c.navMu.Lock()
|
||||||
|
defer c.navMu.Unlock()
|
||||||
|
|
||||||
route, initFn, params := c.app.matchRoute(path)
|
route, initFn, params := c.app.matchRoute(path)
|
||||||
if initFn == nil {
|
if initFn == nil {
|
||||||
c.Redirect(path)
|
c.Redirect(path)
|
||||||
@@ -405,29 +417,12 @@ func (c *Context) Navigate(path string, popstate bool) {
|
|||||||
c.route = route
|
c.route = route
|
||||||
c.injectRouteParams(params)
|
c.injectRouteParams(params)
|
||||||
initFn(c)
|
initFn(c)
|
||||||
c.syncWithViewTransition()
|
c.syncView(true)
|
||||||
escaped := url.PathEscape(path)
|
safe := strings.NewReplacer(`\`, `\\`, `'`, `\'`).Replace(path)
|
||||||
if popstate {
|
if popstate {
|
||||||
c.ExecScript(fmt.Sprintf("history.replaceState({},'',decodeURIComponent('%s'))", escaped))
|
c.ExecScript(fmt.Sprintf("history.replaceState({},'','%s')", safe))
|
||||||
} else {
|
} else {
|
||||||
c.ExecScript(fmt.Sprintf("history.pushState({},'',decodeURIComponent('%s'))", escaped))
|
c.ExecScript(fmt.Sprintf("history.pushState({},'','%s')", safe))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ func DataIgnoreMorph() H {
|
|||||||
|
|
||||||
// DataViewTransition sets the view-transition-name CSS property on an element
|
// DataViewTransition sets the view-transition-name CSS property on an element
|
||||||
// via an inline style. Elements with matching names animate between pages
|
// 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 {
|
func DataViewTransition(name string) H {
|
||||||
return Attr("style", "view-transition-name: "+name)
|
return Attr("style", "view-transition-name: "+name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,15 +38,14 @@
|
|||||||
var url = new URL(href, window.location.origin);
|
var url = new URL(href, window.location.origin);
|
||||||
if (url.origin !== window.location.origin) return;
|
if (url.origin !== window.location.origin) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(url.pathname + url.search);
|
navigate(url.pathname + url.search + url.hash);
|
||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
var ready = false;
|
var ready = false;
|
||||||
window.addEventListener('popstate', function() {
|
window.addEventListener('popstate', function() {
|
||||||
if (!ready) { ready = true; return; }
|
if (!ready) return;
|
||||||
navigate(window.location.pathname + window.location.search, true);
|
navigate(window.location.pathname + window.location.search + window.location.hash, true);
|
||||||
});
|
});
|
||||||
// Mark as ready after initial load completes
|
|
||||||
setTimeout(function() { ready = true; }, 0);
|
setTimeout(function() { ready = true; }, 0);
|
||||||
})();
|
})();
|
||||||
|
|||||||
36
via.go
36
via.go
@@ -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()
|
||||||
@@ -419,6 +430,11 @@ func (v *V) HTTPServeMux() *http.ServeMux {
|
|||||||
return v.mux
|
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.
|
// Static serves files from a filesystem directory at the given URL prefix.
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
@@ -619,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()
|
||||||
@@ -628,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")
|
||||||
|
|||||||
Reference in New Issue
Block a user