From 808d4dd0d10d65c0c7096fe5c892f4cb01395718 Mon Sep 17 00:00:00 2001 From: Joao Goncalves Date: Fri, 14 Nov 2025 17:16:09 -0100 Subject: [PATCH] fix: try solution for race conditions; use brotli included in datastar sdk; small improvements --- context.go | 116 ++++++++++++++++-------------- go.mod | 5 +- go.sum | 6 +- internal/examples/picocss/main.go | 41 ++++++++--- signal.go | 2 +- via.go | 95 ++++++++++++++++-------- 6 files changed, 166 insertions(+), 99 deletions(-) diff --git a/context.go b/context.go index 8b12f65..2d0a8a9 100644 --- a/context.go +++ b/context.go @@ -2,13 +2,13 @@ package via import ( "bytes" + "encoding/json" "fmt" "log" "reflect" "sync" "github.com/go-via/via/h" - "github.com/starfederation/datastar-go/datastar" ) // Context is the living bridge between Go and the browser. @@ -21,10 +21,10 @@ type Context struct { view func() h.H componentRegistry map[string]*Context parentPageCtx *Context - sse *datastar.ServerSentEventGenerator + patchChan chan patch actionRegistry map[string]func() - signals map[string]*signal - signalsMux sync.Mutex + signals *sync.Map + mutex sync.RWMutex } // View defines the UI rendered by this context. @@ -155,9 +155,9 @@ func (c *Context) Signal(v any) *signal { // components register signals on parent page if c.isComponent() { - c.parentPageCtx.signals[sigID] = sig + c.parentPageCtx.signals.Store(sigID, sig) } else { - c.signals[sigID] = sig + c.signals.Store(sigID, sig) } return sig @@ -168,56 +168,72 @@ func (c *Context) injectSignals(sigs map[string]any) { c.app.logErr(c, "signal injection failed: nil signals in ctx") return } - for k, v := range sigs { - if _, ok := c.signals[k]; !ok { - c.signals[k] = &signal{ - id: k, - t: reflect.TypeOf(v), - v: reflect.ValueOf(v), - } + + c.mutex.Lock() + defer c.mutex.Unlock() + + for sigID, val := range sigs { + if _, ok := c.signals.Load(sigID); !ok { + c.signals.Store(sigID, &signal{ + id: sigID, + t: reflect.TypeOf(val), + v: reflect.ValueOf(val), + }) continue } - c.signals[k].v = reflect.ValueOf(v) - c.signals[k].changed = false + item, _ := c.signals.Load(sigID) + if sig, ok := item.(*signal); ok { + sig.v = reflect.ValueOf(val) + sig.changed = false + } } } -func (c *Context) getSSE() *datastar.ServerSentEventGenerator { +func (c *Context) getSSE() chan patch { // components use parent page sse stream - var sse *datastar.ServerSentEventGenerator + var patchChan chan patch if c.isComponent() { - sse = c.parentPageCtx.sse + patchChan = c.parentPageCtx.parentPageCtx.patchChan } else { - sse = c.sse + patchChan = c.patchChan } - return sse + return patchChan +} + +func (c *Context) prepareSignalsForPatch() map[string]any { + updatedSigs := make(map[string]any) + c.signals.Range(func(sigID, value any) bool { + if sig, ok := value.(*signal); ok { + if sig.err != nil { + c.app.logWarn(c, "signal '%s' is out of sync: %v", sig.id, sig.err) + return true + } + if sig.changed { + updatedSigs[sigID.(string)] = fmt.Sprintf("%v", sig.v) + } + } + return true + }) + return updatedSigs } // Sync pushes the current view state and signal changes to the browser immediately // over the live SSE event stream. func (c *Context) Sync() { - sse := c.getSSE() - if sse == nil { - c.app.logWarn(c, "view out of sync: no sse stream") - return - } elemsPatch := bytes.NewBuffer(make([]byte, 0)) if err := c.view().Render(elemsPatch); err != nil { c.app.logErr(c, "sync view failed: %v", err) return } - _ = sse.PatchElements(elemsPatch.String()) - updatedSigs := make(map[string]any) - for id, sig := range c.signals { - if sig.err != nil { - c.app.logWarn(c, "failed to sync signal '%s': %v", sig.id, sig.err) - } - if sig.changed && sig.err == nil { - updatedSigs[id] = fmt.Sprintf("%v", sig.v) - } - } + c.patchChan <- patch{patchTypeElements, elemsPatch.String()} + + c.mutex.RLock() + defer c.mutex.RUnlock() + updatedSigs := c.prepareSignalsForPatch() + if len(updatedSigs) != 0 { - _ = sse.MarshalAndPatchSignals(updatedSigs) + outgoingSigs, _ := json.Marshal(updatedSigs) + c.patchChan <- patch{patchTypeSignals, string(outgoingSigs)} } } @@ -254,28 +270,19 @@ func (c *Context) SyncElements(elem h.H) { } b := bytes.NewBuffer(make([]byte, 0)) _ = elem.Render(b) - _ = sse.PatchElements(b.String()) + c.patchChan <- patch{patchTypeElements, b.String()} } // SyncSignals pushes the current signal changes to the browser immediately // over the live SSE event stream. func (c *Context) SyncSignals() { - sse := c.getSSE() - if sse == nil { - c.app.logWarn(c, "signals out of sync: no sse stream") - return - } - updatedSigs := make(map[string]any) - for id, sig := range c.signals { - if sig.err != nil { - c.app.logWarn(c, "signal out of sync'%s': %v", sig.id, sig.err) - } - if sig.changed && sig.err == nil { - updatedSigs[id] = fmt.Sprintf("%v", sig.v) - } - } + c.mutex.RLock() + updatedSigs := c.prepareSignalsForPatch() + defer c.mutex.RUnlock() + if len(updatedSigs) != 0 { - _ = sse.MarshalAndPatchSignals(updatedSigs) + outgoingSignals, _ := json.Marshal(updatedSigs) + c.patchChan <- patch{patchTypeSignals, string(outgoingSignals)} } } @@ -285,7 +292,7 @@ func (c *Context) ExecScript(s string) { c.app.logWarn(c, "script out of sync: no sse stream") return } - _ = sse.ExecuteScript(s) + c.patchChan <- patch{patchTypeScript, s} } func newContext(id string, route string, app *V) *Context { @@ -299,6 +306,7 @@ func newContext(id string, route string, app *V) *Context { app: app, componentRegistry: make(map[string]*Context), actionRegistry: make(map[string]func()), - signals: make(map[string]*signal), + signals: new(sync.Map), + patchChan: make(chan patch, 100), } } diff --git a/go.mod b/go.mod index f012656..c77a16f 100644 --- a/go.mod +++ b/go.mod @@ -5,20 +5,21 @@ go 1.25.4 require maragu.dev/gomponents v1.2.0 require ( - github.com/CAFxX/httpcompression v0.0.9 github.com/andybalholm/brotli v1.2.0 github.com/fsnotify/fsnotify v1.9.0 - github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 + github.com/go-via/via-plugin-picocss v0.1.0 github.com/starfederation/datastar-go v1.0.3 github.com/stretchr/testify v1.10.0 ) require ( + github.com/CAFxX/httpcompression v0.0.9 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect + golang.org/x/sys v0.13.0 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 78396f1..f5737bc 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8 h1:FRjqaCz+MbG53a8EJOJglSK/dcUx7LwR8+UuJBs5bGU= -github.com/go-via/via-plugin-picocss v0.0.0-20251112183909-4485ba2e31d8/go.mod h1:5LEnLE7q8YfYY7jtH/TLPvfquB7Qt9WZ7TbKrskUW+0= +github.com/go-via/via-plugin-picocss v0.1.0 h1:ytVtBlfYBhidos5ub4a8liYqadz1AkeHhh7e7Paz620= +github.com/go-via/via-plugin-picocss v0.1.0/go.mod h1:5LEnLE7q8YfYY7jtH/TLPvfquB7Qt9WZ7TbKrskUW+0= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= @@ -42,6 +42,8 @@ github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/examples/picocss/main.go b/internal/examples/picocss/main.go index f0ad2b9..09b96e3 100644 --- a/internal/examples/picocss/main.go +++ b/internal/examples/picocss/main.go @@ -2,29 +2,48 @@ package main import ( "github.com/go-via/via" + "github.com/go-via/via-plugin-picocss/picocss" "github.com/go-via/via/h" ) +type Counter struct{ Count int } + func main() { v := via.New() - v.AppendToHead(h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))) + v.Config(via.Options{ + DocumentTitle: "Via Counter", + // Plugin is placed here. Use picocss.WithOptions(pococss.Options) to add the plugin + // with a different color theme or to enable a classes for a wide range of colors. + Plugins: []via.Plugin{ + picocss.Default, + }, + }) v.Page("/", func(c *via.Context) { + data := Counter{Count: 0} + step := c.Signal(1) + + increment := c.Action(func() { + data.Count += step.Int() + c.Sync() + }) + c.View(func() h.H { - return h.Div( - h.H1(h.Text("Hello PicoCSS!")), - h.H2(h.Text("Hello PicoCSS!")), - h.H3(h.Text("Hello PicoCSS!")), - h.H4(h.Text("Hello PicoCSS!")), - h.H5(h.Text("Hello PicoCSS!")), - h.H6(h.Text("Hello PicoCSS!")), - h.Div(h.Class("grid"), - h.Button(h.Text("Primary")), - h.Button(h.Class("secondary"), h.Text("Secondary")), + return h.Main(h.Class("container"), h.Br(), + h.H1(h.Text("⚡ Via Counter")), h.Hr(), + h.Div( + h.H2(h.Textf("Count - %d", data.Count)), + h.H5(h.Text("Step - "), step.Text()), + h.Div(h.Role("group"), + h.Input(h.Type("number"), step.Bind()), + h.Button(h.Text("Increment"), increment.OnClick()), + ), ), ) }) }) + v.Start() } + diff --git a/signal.go b/signal.go index ef83e31..710e938 100644 --- a/signal.go +++ b/signal.go @@ -51,7 +51,7 @@ func (s *signal) Bind() h.H { // // h.Div(mysignal.Text()) func (s *signal) Text() h.H { - return h.Data("text", "$"+s.id) + return h.Span(h.Data("text", "$"+s.id)) } // SetValue updates the signal’s value and marks it for synchronization with the browser. diff --git a/via.go b/via.go index 50f0047..30663fb 100644 --- a/via.go +++ b/via.go @@ -18,7 +18,6 @@ import ( "strings" "sync" - "github.com/CAFxX/httpcompression" "github.com/go-via/via/h" "github.com/starfederation/datastar-go/datastar" ) @@ -31,9 +30,8 @@ var datastarJS []byte type V struct { cfg Options mux *http.ServeMux - handler http.Handler contextRegistry map[string]*Context - contextRegistryMutex sync.Mutex + contextRegistryMutex sync.RWMutex documentHeadIncludes []h.H documentFootIncludes []h.H devModePageInitFnMap map[string]func(*Context) @@ -149,10 +147,13 @@ func (v *V) Page(route string, initContextFn func(c *Context)) { headElements := v.documentHeadIncludes headElements = append(headElements, h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id)))) headElements = append(headElements, h.Meta(h.Data("init", `window.addEventListener('beforeunload', (evt) => { - evt.preventDefault(); evt.returnValue = ''; @post('/_session/close'); return ''; })`))) + evt.preventDefault(); evt.returnValue = ''; @get('/_session/close'); return ''; })`))) headElements = append(headElements, h.Meta(h.Data("init", "@get('/_sse')"))) bottomBodyElements := []h.H{c.view()} bottomBodyElements = append(bottomBodyElements, v.documentFootIncludes...) + if v.cfg.DevMode { + bottomBodyElements = append(bottomBodyElements, h.Script(h.Type("module"), h.Src("https://cdn.jsdelivr.net/gh/dataSPA/dataSPA-inspector@latest/dataspa-inspector.bundled.js"))) + } view := h.HTML5(h.HTML5Props{ Title: v.cfg.DocumentTitle, Head: headElements, @@ -185,8 +186,8 @@ func (v *V) unregisterCtx(id string) { } func (v *V) getCtx(id string) (*Context, error) { - v.contextRegistryMutex.Lock() - defer v.contextRegistryMutex.Unlock() + v.contextRegistryMutex.RLock() + defer v.contextRegistryMutex.RUnlock() if c, ok := v.contextRegistry[id]; ok { return c, nil } @@ -205,7 +206,7 @@ func (v *V) Start() { v.devModeRestore() } v.logInfo(nil, "via started at [%s]", v.cfg.ServerAddress) - log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.handler)) + log.Fatalf("[fatal] %v", http.ListenAndServe(v.cfg.ServerAddress, v.mux)) } func (v *V) devModePersist(c *Context) { @@ -273,22 +274,25 @@ func (v *V) devModeRestore() { os.Remove(p) } +type patchType int + +const ( + patchTypeElements = iota + patchTypeSignals + patchTypeScript +) + +type patch struct { + typ patchType + content string +} + // New creates a new *V application with default configuration. func New() *V { mux := http.NewServeMux() - compressionAdapter, err := httpcompression.DefaultAdapter( - httpcompression.MinSize(1024), - httpcompression.BrotliCompressionLevel(5), - httpcompression.ZstandardCompressor(nil), - ) - if err != nil { - log.Fatalf("failed to create compression adapter: %v", err) - } - v := &V{ mux: mux, - handler: compressionAdapter(mux), contextRegistry: make(map[string]*Context), devModePageInitFnMap: make(map[string]func(*Context)), cfg: Options{ @@ -310,20 +314,53 @@ func New() *V { cID, _ := sigs["via-ctx"].(string) c, err := v.getCtx(cID) if err != nil { - v.logErr(nil, "failed to render page: %v", err) + v.logErr(nil, "sse stream failed to start: %v", err) return } - c.sse = datastar.NewSSE(w, r) + + sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5)))) + v.logDebug(c, "SSE connection established") - if v.cfg.DevMode { - c.Sync() - } else { - c.SyncSignals() + + go func() { + if v.cfg.DevMode { + c.Sync() + } else { + c.SyncSignals() + } + }() + + for { + select { + case <-sse.Context().Done(): + v.logDebug(c, "SSE context done, exiting handler loop") + return + case patch, ok := <-c.patchChan: + if !ok { + v.logDebug(c, "patchChan closed, exiting handler loop") + return + } + switch patch.typ { + case patchTypeElements: + if err := sse.PatchElements(patch.content); err != nil { + v.logErr(c, "PatchElements failed: %v", err) + return + } + case patchTypeSignals: + if err := sse.PatchSignals([]byte(patch.content)); err != nil { + v.logErr(c, "PatchSignals failed: %v", err) + return + } + case patchTypeScript: + if err := sse.ExecuteScript(patch.content, datastar.WithExecuteScriptAutoRemove(true)); err != nil { + v.logErr(c, "ExecuteScript failed: %v", err) + return + } + } + } } - <-c.sse.Context().Done() - c.sse = nil - v.logDebug(c, "SSE connection closed") }) + v.mux.HandleFunc("GET /_action/{id}", func(w http.ResponseWriter, r *http.Request) { actionID := r.PathValue("id") var sigs map[string]any @@ -345,12 +382,12 @@ func New() *V { v.logErr(c, "action '%s' failed: %v", actionID, r) } }() - c.signalsMux.Lock() - defer c.signalsMux.Unlock() + c.injectSignals(sigs) actionFn() }) - v.mux.HandleFunc("POST /_session/close", func(w http.ResponseWriter, r *http.Request) { + + v.mux.HandleFunc("GET /_session/close", func(w http.ResponseWriter, r *http.Request) { var sigs map[string]any _ = datastar.ReadSignals(r, &sigs) cID, _ := sigs["via-ctx"].(string)