fix: check for panics on page registration. Fix header append bug: was appending multiple ctx_id to the header; feat: handle complex signal init values as json; add tests; other small improvemnts

This commit is contained in:
Joao Goncalves
2025-11-17 16:46:33 -01:00
parent 472351d9a5
commit f5a786730a
6 changed files with 167 additions and 33 deletions

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log"
"reflect"
"sync"
"github.com/go-via/via/h"
@@ -32,8 +33,7 @@ type Context struct {
// Changes to signals or state can be pushed live with Sync().
func (c *Context) View(f func() h.H) {
if f == nil {
c.app.logErr(c, "failed to bind view to context: nil func")
return
panic("nil viewfn")
}
c.view = func() h.H { return h.Div(h.ID(c.id), f()) }
}
@@ -143,6 +143,12 @@ func (c *Context) Signal(v any) *signal {
err: fmt.Errorf("context '%s' failed to bind signal '%s': nil signal value", c.id, sigID),
}
}
switch reflect.TypeOf(v).Kind() {
case reflect.Slice, reflect.Struct:
if j, err := json.Marshal(v); err == nil {
v = string(j)
}
}
sig := &signal{
id: sigID,
val: v,
@@ -251,13 +257,13 @@ func (c *Context) Sync() {
// Then, the merge will only occur if the ID of the top level element in the patch
// matches 'my-element'.
func (c *Context) SyncElements(elem h.H) {
patchChan := c.getPatchChan()
if c.view == nil {
c.app.logErr(c, "sync element failed: viewfn is nil")
if elem == nil {
c.app.logErr(c, "sync elements failed: view func is nil")
return
}
if elem == nil {
c.app.logErr(c, "sync element failed: view func is nil")
patchChan := c.getPatchChan()
if patchChan == nil {
c.app.logWarn(c, "sync elements failed: no sse stream")
return
}
b := bytes.NewBuffer(make([]byte, 0))
@@ -302,15 +308,15 @@ func (c *Context) ExecScript(s string) {
patchChan <- patch{patchTypeScript, s}
}
func newContext(id string, route string, app *V) *Context {
if app == nil {
log.Fatalf("create context failed: app pointer is nil")
func newContext(id string, route string, v *V) *Context {
if v == nil {
log.Fatal("create context failed: app pointer is nil")
}
return &Context{
id: id,
route: route,
app: app,
app: v,
componentRegistry: make(map[string]*Context),
actionRegistry: make(map[string]func()),
signals: new(sync.Map),

2
go.mod
View File

@@ -19,7 +19,7 @@ require (
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
golang.org/x/sys v0.38.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

4
go.sum
View File

@@ -42,8 +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=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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=

64
signal_test.go Normal file
View File

@@ -0,0 +1,64 @@
package via
import (
// "net/http/httptest"
"testing"
"github.com/go-via/via/h"
"github.com/stretchr/testify/assert"
)
func TestSignalReturnAsString(t *testing.T) {
testcases := []struct {
given any
expected string
}{
{"test", "test"},
{"another", "another"},
{1, "1"},
{-99, "-99"},
{1.1, "1.1"},
{-34.345, "-34.345"},
{true, "true"},
{false, "false"},
}
for _, testcase := range testcases {
var sig *signal
v := New()
v.Page("/", func(c *Context) {
c.View(func() h.H { return nil })
sig = c.Signal(testcase.given)
})
assert.Equal(t, testcase.expected, sig.String())
}
}
func TestSignalReturnAsStringComplexTypes(t *testing.T) {
testcases := []struct {
given any
expected string
}{
{[]string{"test"}, `["test"]`},
{[]int{1, 2}, "[1, 2]"},
{struct{ Val string }{"test"}, `{"Val": "test"}`},
{struct {
Num int
IsPositive bool
}{1, true}, `{"Num": 1, "IsPositive": true}`},
}
for _, testcase := range testcases {
var sig *signal
v := New()
v.Page("/", func(c *Context) {
c.View(func() h.H { return nil })
sig = c.Signal(testcase.given)
})
assert.JSONEq(t, testcase.expected, sig.String())
}
}

94
via.go
View File

@@ -18,6 +18,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"
"github.com/go-via/via/h"
"github.com/starfederation/datastar-go/datastar"
@@ -38,6 +39,10 @@ type V struct {
devModePageInitFnMap map[string]func(*Context)
}
func (v *V) logFatal(format string, a ...any) {
log.Printf("[fatal] msg=%q", fmt.Sprintf(format, a...))
}
func (v *V) logErr(c *Context, format string, a ...any) {
cRef := ""
if c != nil && c.id != "" {
@@ -119,8 +124,8 @@ func (v *V) AppendToFoot(elements ...h.H) {
}
}
// Page registers a route and its associated page handler.
// The handler receives a *Context to define UI, signals, and actions.
// Page registers a route and its associated page handler. The handler receives a *Context
// that defines state, UI, signals, and actions.
//
// Example:
//
@@ -130,6 +135,20 @@ func (v *V) AppendToFoot(elements ...h.H) {
// })
// })
func (v *V) Page(route string, initContextFn func(c *Context)) {
// check for panics
func() {
defer func() {
if err := recover(); err != nil {
v.logFatal("failed to register page with init func that panics: %v", err)
panic(err)
}
}()
c := newContext("", "", v)
initContextFn(c)
c.view()
}()
// save page init function allows devmode to restore persisted ctx later
if v.cfg.DevMode {
v.devModePageInitFnMap[route] = initContextFn
}
@@ -145,10 +164,15 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
if v.cfg.DevMode {
v.devModePersist(c)
}
v.AppendToHead(h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))))
v.AppendToHead(h.Meta(h.Data("init", "@get('/_sse')")))
v.AppendToHead(h.Meta(h.Data("init", fmt.Sprintf(`window.addEventListener('beforeunload', (evt) => {
navigator.sendBeacon('/_session/close', '%s');});`, c.id))))
headElements := []h.H{}
headElements = append(headElements, v.documentHeadIncludes...)
headElements = append(headElements,
h.Meta(h.Data("signals", fmt.Sprintf("{'via-ctx':'%s'}", id))),
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))),
)
bodyElements := []h.H{c.view()}
bodyElements = append(bodyElements, v.documentFootIncludes...)
if v.cfg.DevMode {
@@ -158,7 +182,7 @@ func (v *V) Page(route string, initContextFn func(c *Context)) {
}
view := h.HTML5(h.HTML5Props{
Title: v.cfg.DocumentTitle,
Head: v.documentHeadIncludes,
Head: headElements,
Body: bodyElements,
HTMLAttrs: []h.H{},
})
@@ -175,11 +199,11 @@ func (v *V) registerCtx(c *Context) {
}
v.contextRegistry[c.id] = c
v.logDebug(c, "new context added to registry")
v.summarize()
v.logDebug(nil, "number of sessions in registry: %d", v.currSessionNum())
}
func (v *V) summarize() {
fmt.Println("Have", len(v.contextRegistry), "sessions")
func (v *V) currSessionNum() int {
return len(v.contextRegistry)
}
func (v *V) unregisterCtx(id string) {
@@ -190,7 +214,7 @@ func (v *V) unregisterCtx(id string) {
}
v.logDebug(nil, "ctx '%s' removed from registry", id)
delete(v.contextRegistry, id)
v.summarize()
v.currSessionNum()
}
func (v *V) getCtx(id string) (*Context, error) {
@@ -251,6 +275,37 @@ func (v *V) devModePersist(c *Context) {
v.logDebug(c, "devmode persisted ctx to file")
}
func (v *V) devModeRemovePersisted(c *Context) {
p := filepath.Join(".via", "devmode", "ctx.json")
// load persisted list from file, or empty list if file not found
file, err := os.Open(p)
ctxRegMap := make(map[string]string)
if err == nil {
json.NewDecoder(file).Decode(&ctxRegMap)
}
file.Close()
// remove ctx to persisted list
if _, ok := ctxRegMap[c.id]; !ok {
delete(ctxRegMap, c.id)
}
// write persisted list to file
file, err = os.Create(p)
if err != nil {
v.logErr(c, "devmode failed to remove percisted ctx: %v", err)
}
defer file.Close()
encoder := json.NewEncoder(file)
if err := encoder.Encode(ctxRegMap); err != nil {
v.logErr(c, "devmode failed to remove persisted ctx")
}
v.logDebug(c, "devmode removed persisted ctx from file")
}
func (v *V) devModeRestore() {
p := filepath.Join(".via", "devmode", "ctx.json")
file, err := os.Open(p)
@@ -330,18 +385,16 @@ func New() *V {
v.logDebug(c, "SSE connection established")
go func() {
if v.cfg.DevMode {
c.Sync()
} else {
c.SyncSignals()
}
}()
if v.cfg.DevMode {
c.Sync()
} else {
c.SyncSignals()
}
for {
select {
case <-sse.Context().Done():
v.logDebug(c, "SSE context done, exiting handler loop")
v.logDebug(c, "SSE connection ended")
return
case patch, ok := <-c.patchChan:
if !ok {
@@ -365,6 +418,8 @@ func New() *V {
return
}
}
default:
time.Sleep(100 * time.Microsecond)
}
}
})
@@ -410,6 +465,7 @@ func New() *V {
return
}
v.logDebug(c, "session close event triggered")
v.devModeRemovePersisted(c)
v.unregisterCtx(c.id)
})

View File

@@ -35,6 +35,7 @@ func TestDatastarJS(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "application/javascript", w.Header().Get("Content-Type"))
assert.Contains(t, w.Body.String(), "🖕JS_DS🚀")
}
func TestSignal(t *testing.T) {
@@ -106,3 +107,10 @@ func TestSyncSignals(t *testing.T) {
patch := <-ctx.patchChan
assert.Equal(t, patch.content, fmt.Sprintf(`{"%s":"updated"}`, sig.ID()))
}
func TestPage_PanicsOnNoView(t *testing.T) {
assert.Panics(t, func() {
v := New()
v.Page("/", func(c *Context) {})
})
}