From 03b6d7453a492966149a6c4393f0e69dae88698f Mon Sep 17 00:00:00 2001 From: Ryan Hamamura <58859899+ryanhamamura@users.noreply.github.com> Date: Mon, 12 Jan 2026 00:47:52 -1000 Subject: [PATCH] feat: add Redirect and ReplaceURL methods for browser navigation Add SSE-based navigation methods to Context: - Redirect(url) / Redirectf() - navigate to new page - ReplaceURL(url) / ReplaceURLf() - update URL without navigation Update session example to demonstrate full login flow with redirects. --- context.go | 31 +++++++++++++++ internal/examples/session/main.go | 65 +++++++++++++++++++++++-------- via.go | 18 +++++++++ 3 files changed, 97 insertions(+), 17 deletions(-) diff --git a/context.go b/context.go index d558f8d..fea88a2 100644 --- a/context.go +++ b/context.go @@ -318,6 +318,37 @@ func (c *Context) ExecScript(s string) { c.sendPatch(patch{patchTypeScript, s}) } +// Redirect navigates the browser to the given URL. +// This triggers a full page navigation - the current context will be disposed +// and a new context created at the destination URL. +func (c *Context) Redirect(url string) { + if url == "" { + c.app.logWarn(c, "redirect failed: empty url") + return + } + c.sendPatch(patch{patchTypeRedirect, url}) +} + +// Redirectf navigates the browser to a URL constructed from the format string and arguments. +func (c *Context) Redirectf(format string, a ...any) { + c.Redirect(fmt.Sprintf(format, a...)) +} + +// ReplaceURL updates the browser's URL and history without triggering navigation. +// Useful for updating query params or path to reflect UI state changes. +func (c *Context) ReplaceURL(url string) { + if url == "" { + c.app.logWarn(c, "replace url failed: empty url") + return + } + c.sendPatch(patch{patchTypeReplaceURL, url}) +} + +// ReplaceURLf updates the browser's URL using a format string. +func (c *Context) ReplaceURLf(format string, a ...any) { + c.ReplaceURL(fmt.Sprintf(format, a...)) +} + // stopAllRoutines stops all go routines tied to this Context preventing goroutine leaks. func (c *Context) stopAllRoutines() { select { diff --git a/internal/examples/session/main.go b/internal/examples/session/main.go index 82e5c0a..8b7f747 100644 --- a/internal/examples/session/main.go +++ b/internal/examples/session/main.go @@ -7,11 +7,11 @@ import ( func main() { v := via.New() + v.Config(via.Options{ServerAddress: ":7331"}) - v.Page("/", func(c *via.Context) { - username := c.Session().GetString("username") + // Login page + v.Page("/login", func(c *via.Context) { flash := c.Session().PopString("flash") - usernameInput := c.Signal("") login := c.Action(func() { @@ -20,38 +20,69 @@ func main() { c.Session().Set("username", name) c.Session().Set("flash", "Welcome, "+name+"!") c.Session().RenewToken() + c.Redirect("/dashboard") } - c.Sync() - }) - - logout := c.Action(func() { - c.Session().Set("flash", "Goodbye!") - c.Session().Delete("username") - c.Sync() }) c.View(func() h.H { + // Already logged in? Redirect to dashboard + if c.Session().GetString("username") != "" { + c.Redirect("/dashboard") + return h.Div() + } + var flashMsg h.H if flash != "" { flashMsg = h.P(h.Text(flash), h.Style("color: green")) } + return h.Div( + flashMsg, + h.H1(h.Text("Login")), + h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()), + h.Button(h.Text("Login"), login.OnClick()), + ) + }) + }) + // Dashboard page (protected) + v.Page("/dashboard", func(c *via.Context) { + logout := c.Action(func() { + c.Session().Set("flash", "Goodbye!") + c.Session().Delete("username") + c.Redirect("/login") + }) + + c.View(func() h.H { + username := c.Session().GetString("username") + + // Not logged in? Redirect to login if username == "" { - return h.Div( - flashMsg, - h.H1(h.Text("Login")), - h.Input(h.Type("text"), h.Placeholder("Username"), usernameInput.Bind()), - h.Button(h.Text("Login"), login.OnClick()), - ) + c.Session().Set("flash", "Please log in first") + c.Redirect("/login") + return h.Div() + } + + flash := c.Session().PopString("flash") + var flashMsg h.H + if flash != "" { + flashMsg = h.P(h.Text(flash), h.Style("color: green")) } return h.Div( flashMsg, - h.H1(h.Textf("Hello, %s!", username)), + h.H1(h.Textf("Dashboard - Hello, %s!", username)), h.P(h.Text("Your session persists across page refreshes.")), h.Button(h.Text("Logout"), logout.OnClick()), ) }) }) + // Redirect root to login + v.Page("/", func(c *via.Context) { + c.View(func() h.H { + c.Redirect("/login") + return h.Div() + }) + }) + v.Start() } diff --git a/via.go b/via.go index 5517a8d..b8d278b 100644 --- a/via.go +++ b/via.go @@ -15,6 +15,7 @@ import ( "io" "log" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -359,6 +360,8 @@ const ( patchTypeElements = iota patchTypeSignals patchTypeScript + patchTypeRedirect + patchTypeReplaceURL ) type patch struct { @@ -453,6 +456,21 @@ func New() *V { v.logErr(c, "ExecuteScript failed: %v", err) } } + case patchTypeRedirect: + if err := sse.Redirect(patch.content); err != nil { + if sse.Context().Err() == nil { + v.logErr(c, "Redirect failed: %v", err) + } + } + case patchTypeReplaceURL: + parsedURL, err := url.Parse(patch.content) + if err != nil { + v.logErr(c, "ReplaceURL failed to parse URL: %v", err) + } else if err := sse.ReplaceURL(*parsedURL); err != nil { + if sse.Context().Err() == nil { + v.logErr(c, "ReplaceURL failed: %v", err) + } + } } } }