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:
28
context.go
28
context.go
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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
64
signal_test.go
Normal 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())
|
||||
|
||||
}
|
||||
}
|
||||
84
via.go
84
via.go
@@ -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()
|
||||
}
|
||||
}()
|
||||
|
||||
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)
|
||||
|
||||
})
|
||||
|
||||
@@ -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) {})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user