router
packageAPI reference for the router
package.
Imports
(16)context
STD
errors
STD
net/url
STD
sort
STD
strings
STD
sync
INT
github.com/rfwlab/rfw/v2/state
STD
testing
INT
github.com/rfwlab/rfw/v2/core
INT
github.com/rfwlab/rfw/v2/dom
INT
github.com/rfwlab/rfw/v2/js
STD
time
STD
regexp
INT
github.com/rfwlab/rfw/v2/events
INT
github.com/rfwlab/rfw/v2/types
STD
reflect
LoadContext
LoadContext describes the destination passed to a route loader.
type LoadContext struct
Fields
| Name | Type | Description |
|---|---|---|
| Path | string | |
| Params | map[string]string | |
| Query | url.Values |
Loader
Loader resolves data before a route component is committed.
type Loader func(context.Context, LoadContext) (any, error)
RouteDataReceiver
RouteDataReceiver accepts data returned by a route Loader.
type RouteDataReceiver interface
Methods
Status
Status returns the reactive navigation status.
Returns
func Status() *state.Signal[NavigationStatus]
{
return navigationStatus
}
Error
Error returns the latest reactive loader error.
Returns
func Error() *state.Signal[error]
{
return navigationError
}
Data
Data returns the current route’s reactive loader data.
Returns
func Data() *state.Signal[any]
{
return currentRouteData
}
Meta
Meta returns the current route’s reactive metadata.
Returns
func Meta() *state.Signal[map[string]any]
{
return currentRouteMeta
}
commitRouteState
Parameters
func commitRouteState(data any, meta map[string]any)
{
state.Batch(func() {
currentRouteData.Set(data)
currentRouteMeta.Set(cloneMeta(meta))
navigationError.Set(nil)
navigationStatus.Set(NavigationReady)
})
}
cloneMeta
Parameters
Returns
func cloneMeta(meta map[string]any) map[string]any
{
if meta == nil {
return map[string]any{}
}
clone := make(map[string]any, len(meta))
for key, value := range meta {
clone[key] = value
}
return clone
}
cloneStringMap
Parameters
Returns
func cloneStringMap(values map[string]string) map[string]string
{
clone := make(map[string]string, len(values))
for key, value := range values {
clone[key] = value
}
return clone
}
URL
URL builds a URL for a named route.
Parameters
Returns
func URL(name string, params map[string]string, query url.Values) (string, error)
{
template, ok := namedRoutePath(routes, name)
if !ok {
return "", errors.New("router: named route not found")
}
segments := strings.Split(template, "/")
for index, segment := range segments {
if !strings.HasPrefix(segment, ":") {
continue
}
key := strings.TrimPrefix(segment, ":")
value, exists := params[key]
if !exists || value == "" {
return "", errors.New("router: missing route parameter " + key)
}
segments[index] = url.PathEscape(value)
}
path := strings.Join(segments, "/")
if encoded := query.Encode(); encoded != "" {
path += "?" + encoded
}
return path, nil
}
MustURL
MustURL builds a named URL and panics if it is invalid.
Parameters
Returns
func MustURL(name string, params map[string]string, query url.Values) string
{
path, err := URL(name, params, query)
if err != nil {
panic(err)
}
return path
}
namedRoutePath
Parameters
Returns
func namedRoutePath(list []route, name string) (string, bool)
{
for index := range list {
if list[index].name == name {
return list[index].fullPath, true
}
if path, ok := namedRoutePath(list[index].children, name); ok {
return path, true
}
}
return "", false
}
routeQuery
Parameters
Returns
func routeQuery(raw string) (map[string]string, url.Values)
{
values, _ := url.ParseQuery(raw)
params := make(map[string]string, len(values))
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if entries := values[key]; len(entries) > 0 {
params[key] = entries[0]
}
}
return params, values
}
redirectDepthKey
type redirectDepthKey struct
nextRedirectContext
Parameters
Returns
func nextRedirectContext(parent context.Context) (context.Context, error)
{
depth, _ := parent.Value(redirectDepthKey{}).(int)
if depth >= 16 {
return nil, ErrRedirectLoop
}
return context.WithValue(parent, redirectDepthKey{}, depth+1), nil
}
redirectPath
Parameters
Returns
func redirectPath(template string, params map[string]string) (string, error)
{
segments := strings.Split(template, "/")
for index, segment := range segments {
if !strings.HasPrefix(segment, ":") {
continue
}
key := strings.TrimPrefix(segment, ":")
value, ok := params[key]
if !ok || value == "" {
return "", errors.New("router: missing redirect parameter " + key)
}
segments[index] = url.PathEscape(value)
}
return strings.Join(segments, "/"), nil
}
testComponent
testComponent implements core.Component and routeParamReceiver for testing.
type testComponent struct
Methods
func (*testComponent) Mount()
{}
func (*testComponent) Unmount()
{}
func (*testComponent) OnMount()
{}
func (*testComponent) OnUnmount()
{}
Parameters
func (*testComponent) OnParams(p map[string]string)
{ c.params = p }
Parameters
func (*testComponent) SetRouteParams(p map[string]string)
{ c.params = p }
Fields
| Name | Type | Description |
|---|---|---|
| params | map[string]string |
TestRegisteredRoutes
Parameters
func TestRegisteredRoutes(t *testing.T)
{
Reset()
RegisterRoute(Route{Path: "/static", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{
Path: "/users",
Component: func() core.Component { return routeComponent{} },
Children: []Route{
{
Path: ":id",
Component: func() core.Component { return routeComponent{} },
},
{
Path: ":id/profile",
Component: func() core.Component { return routeComponent{} },
},
},
})
defs := RegisteredRoutes()
if len(defs) != 2 {
t.Fatalf("expected 2 top level routes, got %d", len(defs))
}
if defs[0].Path != "/static" || len(defs[0].Params) != 0 {
t.Fatalf("unexpected static route: %+v", defs[0])
}
users := defs[1]
if users.Path != "/users" {
t.Fatalf("expected /users path, got %s", users.Path)
}
if len(users.Children) != 2 {
t.Fatalf("expected two children, got %d", len(users.Children))
}
child := users.Children[0]
if child.Path != "/users/:id" {
t.Fatalf("expected /users/:id full path, got %s", child.Path)
}
if len(child.Params) != 1 || child.Params[0] != "id" {
t.Fatalf("expected id param, got %+v", child.Params)
}
profile := users.Children[1]
if profile.Path != "/users/:id/profile" {
t.Fatalf("expected /users/:id/profile path, got %s", profile.Path)
}
}
trailingComponent
type trailingComponent struct
Methods
func (*trailingComponent) Mount()
{}
func (*trailingComponent) Unmount()
{}
func (*trailingComponent) OnMount()
{}
func (*trailingComponent) OnUnmount()
{}
Parameters
func (*trailingComponent) OnParams(map[string]string)
{}
Outlet
Outlet is a plain component that marks where routed components render.
Include one anywhere in your tree (typically inside an app shell mounted
with MountRoot) and navigation swaps only its subtree: everything around it
keeps its DOM, delegated handlers, and state. With no live outlet the
router falls back to replacing #app wholesale, the pre-outlet behavior.
type Outlet struct
Methods
repaint re-renders the routed component when its markup is no longer inside the outlet.
func (*Outlet) repaint()
{
if repainting || currentComponent == nil {
return
}
root := dom.ComponentRoot(o.GetID())
if root.IsNull() || root.IsUndefined() {
return
}
if child := root.Query("[data-component-id='" + currentComponent.GetID() + "']"); !child.IsNull() && !child.IsUndefined() {
return
}
repainting = true
defer func() { repainting = false }()
o.renderChild(currentComponent)
currentComponent.Mount()
}
OnMount registers this outlet as the live navigation target. If a route resolved before the outlet appeared (root mounted after InitRouter), the pending component renders immediately.
func (*Outlet) OnMount()
{
liveOutlet = o
o.HTMLComponent.OnMount()
if currentComponent != nil {
o.renderChild(currentComponent)
currentComponent.Mount()
}
}
OnUnmount clears the live outlet (the shell around it is going away).
func (*Outlet) OnUnmount()
{
if liveOutlet == o {
liveOutlet = nil
}
o.HTMLComponent.OnUnmount()
}
renderChild replaces the outlet subtree with the routed component's render. Route swaps replace wholesale on purpose: positionally diffing two different component trees leaves stale nodes behind. The marker div stays in place as the anchor, so a re-render of the shell around it can recognise the subtree as the router's and leave it alone.
Parameters
func (*Outlet) renderChild(c core.Component)
{
root := dom.ComponentRoot(o.GetID())
if root.IsNull() || root.IsUndefined() {
dom.UpdateDOM(c.GetID(), core.TryRender(c))
return
}
target := root.Query("[data-router-outlet]")
if target.IsNull() || target.IsUndefined() {
target = root
}
dom.UpdateDOMIn(target, c.GetID(), core.TryRender(c))
}
NewOutlet
NewOutlet builds the outlet component; mount it via a dependency include.
Returns
func NewOutlet() *Outlet
{
c := &Outlet{HTMLComponent: core.NewHTMLComponent("RouterOutlet", outletTpl, nil)}
c.SetComponent(c)
c.Init(nil)
installOutletRepaint()
return c
}
installOutletRepaint
installOutletRepaint keeps the routed subtree alive across re-renders of the
shell around it. A persistent root bound to a store re-renders whenever that
store changes, and its fresh markup carries an empty outlet: without this
the routed page would disappear on the first store write after a navigation.
Registered once, when the first outlet is built.
func installOutletRepaint()
{
if outletRepaintInstalled {
return
}
outletRepaintInstalled = true
core.OnTemplate(func(componentID, _ string) {
if liveOutlet == nil || currentComponent == nil {
return
}
if componentID == currentComponent.GetID() {
return
}
liveOutlet.repaint()
})
}
MountRoot
MountRoot renders a persistent root component into #app and mounts it. The
root lives outside the navigation lifecycle: the router only ever touches
the outlet inside it. Call it before InitRouter.
Parameters
func MountRoot(c core.Component)
{
mountedRoot = c
dom.UpdateDOM(mountedRoot.GetID(), core.TryRender(mountedRoot))
mountedRoot.Mount()
core.TriggerMount(mountedRoot)
}
TestReplaceKeepsHistoryLength
Parameters
func TestReplaceKeepsHistoryLength(t *testing.T)
{
Reset()
originalPath := js.Location().Get("pathname").String() +
js.Location().Get("search").String() +
js.Location().Get("hash").String()
t.Cleanup(func() {
js.History().Call("replaceState", nil, "", originalPath)
Reset()
})
RegisterRoute(Route{
Path: "/replace-history",
Component: func() core.Component { return routeComponent{} },
})
before := js.History().Get("length").Int()
Replace("/replace-history")
if got := js.Location().Get("pathname").String(); got != "/replace-history" {
t.Fatalf("expected replaced path, got %q", got)
}
if got := js.History().Get("length").Int(); got != before {
t.Fatalf("history length changed from %d to %d", before, got)
}
}
TestHistoryNoneGuardFallbackDoesNotPush
Parameters
func TestHistoryNoneGuardFallbackDoesNotPush(t *testing.T)
{
Reset()
t.Cleanup(Reset)
RegisterRoute(Route{
Path: "/",
Component: func() core.Component { return routeComponent{} },
})
RegisterRoute(Route{
Path: "/protected",
Component: func() core.Component { return routeComponent{} },
Guards: []Guard{func(map[string]string) bool { return false }},
})
before := js.History().Get("length").Int()
navigate("/protected", historyNone)
if got := js.History().Get("length").Int(); got != before {
t.Fatalf("history length changed from %d to %d", before, got)
}
}
TestShellRerenderKeepsPageBuiltDOM
A page that fills part of itself after mount (a card injected with SetHTML,
the common pattern for markup built from fetched data) must keep that DOM
when the shell around the outlet re-renders.
Parameters
func TestShellRerenderKeepsPageBuiltDOM(t *testing.T)
{
Reset()
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
shellStore := state.NewStore("shellpreserve", state.WithModule("app"))
shellStore.Set("nav", []any{map[string]any{"label": "one"}})
defer state.GlobalStoreManager.UnregisterStore("app", "shellpreserve")
shell := core.NewHTMLComponent("PreserveShell", []byte(`<root>
@for:it in store:app.shellpreserve.nav
<span class="nav">@prop:it.label</span>
@endfor
@include:outlet
</root>`), nil)
shell.SetComponent(shell)
shell.AddDependency("outlet", NewOutlet())
shell.Init(nil)
page := core.NewHTMLComponent("PreservePage", []byte(`<root><div data-card></div></root>`), nil)
page.SetComponent(page)
page.Init(nil)
Page("/preserve-test", page)
MountRoot(shell)
Navigate("/preserve-test")
// the page fills its card after mount, the way a fetch callback would
dom.Query("[data-card]").SetHTML(`<b id="page-built">built</b>`)
if !strings.Contains(dom.ByID("app").HTML(), "page-built") {
t.Fatal("card was not injected")
}
shellStore.Set("nav", []any{map[string]any{"label": "one"}, map[string]any{"label": "two"}})
waitForRouterRender()
html := dom.ByID("app").HTML()
if !strings.Contains(html, "two") {
t.Fatalf("shell did not re-render: %s", html)
}
if !strings.Contains(html, "page-built") {
t.Fatalf("shell re-render wiped the DOM the page had built: %s", html)
}
}
waitForRouterRender
func waitForRouterRender()
{
time.Sleep(20 * time.Millisecond)
}
TestOutletSurvivesShellRerender
A shell bound to a store re-renders on every write, and its fresh markup
carries an empty outlet. The routed page has to survive that.
Parameters
func TestOutletSurvivesShellRerender(t *testing.T)
{
Reset()
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
shellStore := state.NewStore("shellrepaint", state.WithModule("app"))
shellStore.Set("title", "first")
shellStore.Set("nav", []any{map[string]any{"label": "one"}})
defer state.GlobalStoreManager.UnregisterStore("app", "shellrepaint")
// a @for over a store list is the shape that re-renders the whole shell
shell := core.NewHTMLComponent("RepaintShell", []byte(`<root>
<header>@store:app.shellrepaint.title</header>
@for:it in store:app.shellrepaint.nav
<span class="nav">@prop:it.label</span>
@endfor
@include:outlet
</root>`), nil)
shell.SetComponent(shell)
shell.AddDependency("outlet", NewOutlet())
shell.Init(nil)
page := core.NewHTMLComponent("RepaintPage", []byte(`<root><main id="routed-page">page</main></root>`), nil)
page.SetComponent(page)
page.Init(nil)
Page("/repaint-test", page)
MountRoot(shell)
Navigate("/repaint-test")
if html := dom.ByID("app").HTML(); !strings.Contains(html, "routed-page") {
t.Fatalf("page did not render into the outlet: %s", html)
}
shellStore.Set("nav", []any{map[string]any{"label": "one"}, map[string]any{"label": "two"}})
waitForRouterRender()
html := dom.ByID("app").HTML()
if !strings.Contains(html, "two") {
t.Fatalf("shell did not re-render: %s", html)
}
if !strings.Contains(html, "routed-page") {
t.Fatalf("shell re-render dropped the routed page: %s", html)
}
}
Guard
Guard is a function that determines whether navigation to a route is
permitted based on the provided parameters.
type Guard func(map[string]string) bool
Route
Route describes a routing rule that maps a path to a component and optional
guards or child routes.
Component accepts three forms:
- A *types.View instance: reused every navigation (singleton).
- A func() *types.View: called each navigation to create a fresh instance.
- A func() core.Component: called each navigation (legacy).
type Route struct
Fields
Uses
Singleton
Singleton wraps a pre-created View into a Route.Component value.
Every navigation returns the same instance, no re-creation.
Parameters
Returns
func Singleton(v *types.View) any
{
return v
}
route
type route struct
Fields
| Name | Type | Description |
|---|---|---|
| pattern | string | |
| fullPath | string | |
| name | string | |
| regex | *regexp.Regexp | |
| paramNames | []string | |
| matchNames | []string | |
| component | core.Component | |
| loader | func() core.Component | |
| singleton | bool | |
| children | []route | |
| guards | []Guard | |
| dataLoader | Loader | |
| redirect | string | |
| meta | map[string]any |
Uses
RegisteredRoute
RegisteredRoute describes a registered route in a navigable tree form.
type RegisteredRoute struct
Fields
| Name | Type | Description |
|---|---|---|
| Template | string | json:"template" |
| Path | string | json:"path" |
| Name | string | json:"name,omitempty" |
| Params | []string | json:"params" |
| Children | []RegisteredRoute | json:"children" |
| Meta | map[string]any | json:"meta,omitempty" |
Reset
Reset clears the router’s registered routes and current component.
It is primarily intended for use in tests to ensure a clean state.
func Reset()
{
routes = nil
currentComponent = nil
NotFoundComponent = nil
NotFoundCallback = nil
activePathSig.Set("/")
resetNavigation()
scrollPositions = map[string][2]int{}
}
RegisterRoute
RegisterRoute adds a new Route to the router’s configuration.
Parameters
func RegisterRoute(r Route)
{
routes = append(routes, buildRoute(r))
}
Uses
buildRoute
func buildRoute(r Route) route
{
return buildRouteAt(r, "")
}
buildRouteAt
func buildRouteAt(r Route, parent string) route
{
fullPath := resolveRoutePath(parent, r.Path)
segments := strings.Split(strings.Trim(fullPath, "/"), "/")
regexParts := make([]string, len(segments))
matchNames := []string{}
for i, segment := range segments {
if strings.HasPrefix(segment, ":") {
name := strings.TrimPrefix(segment, ":")
matchNames = append(matchNames, name)
regexParts[i] = "([^/]+)"
} else {
regexParts[i] = regexp.QuoteMeta(segment)
}
}
paramNames := []string{}
for _, segment := range strings.Split(strings.Trim(r.Path, "/"), "/") {
if strings.HasPrefix(segment, ":") {
paramNames = append(paramNames, strings.TrimPrefix(segment, ":"))
}
}
pathRegex := strings.Join(regexParts, "/")
suffix := "/?$"
if len(r.Children) > 0 {
suffix = "(?:/|$)"
}
if pathRegex == "" {
if len(r.Children) > 0 {
suffix = ""
} else {
suffix = "$"
}
}
pattern := "^/" + pathRegex + suffix
var loader func() core.Component
var singleton bool
switch c := r.Component.(type) {
case *types.View:
comp := c
loader = func() core.Component { return comp }
singleton = true
case func() *types.View:
loader = func() core.Component { return c() }
case func() core.Component:
loader = c
case core.Component:
comp := c
loader = func() core.Component { return comp }
singleton = true
}
rt := route{
pattern: r.Path,
fullPath: fullPath,
name: r.Name,
regex: regexp.MustCompile(pattern),
paramNames: paramNames,
matchNames: matchNames,
loader: loader,
singleton: singleton,
guards: r.Guards,
dataLoader: r.Loader,
redirect: r.Redirect,
meta: cloneMeta(r.Meta),
}
for _, child := range r.Children {
rt.children = append(rt.children, buildRouteAt(child, fullPath))
}
return rt
}
RegisteredRoutes
RegisteredRoutes returns the registered routes including nested children and
resolved full paths. The data can be used for tooling and diagnostics.
Returns
func RegisteredRoutes() []RegisteredRoute
{
out := make([]RegisteredRoute, 0, len(routes))
for i := range routes {
out = append(out, snapshotRoute(&routes[i], ""))
}
return out
}
snapshotRoute
Parameters
Returns
func snapshotRoute(r *route, parent string) RegisteredRoute
{
params := make([]string, len(r.paramNames))
copy(params, r.paramNames)
full := resolveRoutePath(parent, r.pattern)
children := make([]RegisteredRoute, len(r.children))
for i := range r.children {
children[i] = snapshotRoute(&r.children[i], full)
}
return RegisteredRoute{
Template: r.pattern,
Path: full,
Name: r.name,
Params: params,
Children: children,
Meta: cloneMeta(r.meta),
}
}
resolveRoutePath
Parameters
Returns
func resolveRoutePath(parent, path string) string
{
if path == "" {
if parent == "" {
return "/"
}
return parent
}
if strings.HasPrefix(path, "/") {
return path
}
trimmed := strings.TrimPrefix(path, "/")
if parent == "" || parent == "/" {
return "/" + trimmed
}
if strings.HasSuffix(parent, "/") {
return parent + trimmed
}
return parent + "/" + trimmed
}
routeParamReceiver
type routeParamReceiver interface
Methods
matchRoute
func matchRoute(routes []route, path string) (*route, []Guard, map[string]string)
{
for i := range routes {
r := &routes[i]
matches := r.regex.FindStringSubmatch(path)
if matches == nil {
if child, guards, params := matchRoute(r.children, path); child != nil {
return child, append(r.guards, guards...), params
}
continue
}
params := map[string]string{}
for i, name := range r.matchNames {
if i+1 < len(matches) {
params[name] = decodeRouteParam(matches[i+1])
}
}
if child, guards, childParams := matchRoute(r.children, path); child != nil {
return child, append(r.guards, guards...), childParams
}
matchedPath := strings.TrimSuffix(matches[0], "/")
if (r.loader != nil || r.redirect != "") && matchedPath == strings.TrimSuffix(path, "/") {
return r, r.guards, params
}
}
return nil, nil, nil
}
decodeRouteParam
Parameters
Returns
func decodeRouteParam(value string) string
{
decoded, err := url.PathUnescape(value)
if err != nil {
return value
}
return decoded
}
historyMode
type historyMode uint8
Replace
Replace navigates without adding a new browser history entry.
Parameters
func Replace(fullPath string)
{
navigate(fullPath, historyReplace)
}
updateHistory
Parameters
func updateHistory(mode historyMode, path string)
{
switch mode {
case historyPush:
js.History().Call("pushState", nil, "", path)
case historyReplace:
js.History().Call("replaceState", nil, "", path)
}
}
Uses
SetScrollRestoration
SetScrollRestoration enables or disables router-managed scroll positions.
Parameters
func SetScrollRestoration(enabled bool)
{
scrollEnabled = enabled
}
saveScroll
func saveScroll()
{
if !scrollEnabled || currentComponent == nil {
return
}
path := activePathSig.Get()
scrollPositions[path] = [2]int{
js.Window().Get("scrollX").Int(),
js.Window().Get("scrollY").Int(),
}
}
restoreScroll
Parameters
func restoreScroll(path string, history historyMode, meta map[string]any)
{
if !scrollEnabled {
return
}
if preserve, _ := meta["preserveScroll"].(bool); preserve {
return
}
position := [2]int{}
if history == historyNone {
position = scrollPositions[path]
}
js.Window().Call("scrollTo", position[0], position[1])
}
Uses
Page
Page registers a route with path, component and optional guards.
Parameters
func Page(path string, component any, guards ...Guard)
{
RegisterRoute(Route{
Path: path,
Component: component,
Guards: guards,
})
}
Group
Group creates nested routes under a common path prefix
and registers them. Returns the parent Route for chaining.
Parameters
func Group(prefix string, fn func(*GroupBuilder))
{
b := &GroupBuilder{prefix: prefix}
fn(b)
RegisterRoute(Route{
Path: prefix,
Children: b.children,
})
}
GroupBuilder
GroupBuilder collects child routes within a Group callback.
type GroupBuilder struct
Methods
Page adds a child route within a Group.
Parameters
func (*GroupBuilder) Page(path string, component any, guards ...Guard)
{
g.children = append(g.children, Route{
Path: path,
Component: component,
Guards: guards,
})
}
Page adds a route to the group.
Parameters
func (*GroupBuilder) Page(path string, component any, guards ...Guard)
{
g.children = append(g.children, Route{
Path: path,
Component: component,
Guards: guards,
})
}
Fields
| Name | Type | Description |
|---|---|---|
| prefix | string | |
| children | []Route |
InitRouter
InitRouter initializes the router and begins listening for navigation
events.
func InitRouter()
{
ExposeNavigate()
// The popstate listener lives for the whole app lifetime; the stop
// function is intentionally discarded.
ch, _ := events.Listen("popstate", js.Window())
go func() {
for range ch {
path := js.Location().Get("pathname").String() + js.Location().Get("search").String()
core.TryNavigate(path, func() { navigate(path, historyNone) })
}
}()
currentPath := js.Location().Get("pathname").String() + js.Location().Get("search").String()
navigate(currentPath, historyNone)
}
RouterData
RouterData returns the values exposed to component templates.
Returns
func RouterData() map[string]any
{
return map[string]any{
"ActivePath": activePathSig,
"NavItems": NavItemsMap(),
}
}
TemplateData
TemplateData returns the values exposed to component templates.
Returns
func TemplateData() map[string]any
{ return RouterData() }
ActivePath
ActivePath returns the reactive signal holding the current route path.
Returns
func ActivePath() *state.Signal[string]
{
return activePathSig
}
TestSameInstanceRouteUpdatesInPlace
Registering one component instance for a base path and its “:id” variant must
update it in place across navigation (and browser back/forward) instead of
unmounting and remounting an unchanged view. A remount rebuilds the
component’s DOM and wipes anything it injected after mount (e.g. a detail
panel a fetch callback filled in), so the injected marker surviving the
navigation is the proof it stayed mounted.
Parameters
func TestSameInstanceRouteUpdatesInPlace(t *testing.T)
{
Reset()
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
shell := core.NewHTMLComponent("InplaceShell", []byte(`<root>@include:outlet</root>`), nil)
shell.SetComponent(shell)
shell.AddDependency("outlet", NewOutlet())
shell.Init(nil)
page := core.NewHTMLComponent("InplacePage", []byte(`<root><div data-inplace></div></root>`), nil)
page.SetComponent(page)
page.Init(nil)
// The same instance backs both the base path and its :id variant.
Page("/inplace", page)
Page("/inplace/:id", page)
MountRoot(shell)
Navigate("/inplace")
// The page fills itself after mount, the way a fetch callback would.
dom.Query("[data-inplace]").SetHTML(`<b id="inplace-built">built</b>`)
if !strings.Contains(dom.ByID("app").HTML(), "inplace-built") {
t.Fatal("marker was not injected")
}
// Navigate to the :id variant of the same instance.
Navigate("/inplace/42")
if got := ActivePath().Get(); got != "/inplace/42" {
t.Fatalf("active path not updated: %s", got)
}
if !strings.Contains(dom.ByID("app").HTML(), "inplace-built") {
t.Fatalf("navigating to the same-instance :id route remounted the view (marker wiped): %s", dom.ByID("app").HTML())
}
// Back to the base path stays in place too.
Navigate("/inplace")
if got := ActivePath().Get(); got != "/inplace" {
t.Fatalf("active path not restored: %s", got)
}
if !strings.Contains(dom.ByID("app").HTML(), "inplace-built") {
t.Fatalf("navigating back remounted the view (marker wiped): %s", dom.ByID("app").HTML())
}
}
Guard
Guard decides whether a route can be entered.
type Guard func(map[string]string) bool
Route
Route describes a route and its optional children, loader, and metadata.
type Route struct
Fields
Uses
Singleton
Singleton marks a view instance for reuse between navigations.
Parameters
Returns
func Singleton(v *types.View) any
{
return v
}
route
type route struct
Fields
| Name | Type | Description |
|---|---|---|
| pattern | string | |
| fullPath | string | |
| name | string | |
| regex | *regexp.Regexp | |
| paramNames | []string | |
| matchNames | []string | |
| component | core.Component | |
| loader | func() core.Component | |
| singleton | bool | |
| children | []route | |
| guards | []Guard | |
| dataLoader | Loader | |
| redirect | string | |
| meta | map[string]any |
Uses
RegisteredRoute
RegisteredRoute is a serializable snapshot of a route.
type RegisteredRoute struct
Fields
| Name | Type | Description |
|---|---|---|
| Template | string | json:"template" |
| Path | string | json:"path" |
| Name | string | json:"name,omitempty" |
| Params | []string | json:"params" |
| Children | []RegisteredRoute | json:"children" |
| Meta | map[string]any | json:"meta,omitempty" |
Reset
Reset clears registered routes and navigation state.
func Reset()
{
routes = nil
currentComponent = nil
NotFoundComponent = nil
NotFoundCallback = nil
activePathSig.Set("/")
resetNavigation()
}
RegisterRoute
RegisterRoute adds a route to the router.
Parameters
func RegisterRoute(r Route)
{
routes = append(routes, buildRoute(r))
}
Uses
buildRoute
func buildRoute(r Route) route
{
return buildRouteAt(r, "")
}
buildRouteAt
func buildRouteAt(r Route, parent string) route
{
fullPath := resolveRoutePath(parent, r.Path)
segments := strings.Split(strings.Trim(fullPath, "/"), "/")
regexParts := make([]string, len(segments))
matchNames := []string{}
for i, segment := range segments {
if strings.HasPrefix(segment, ":") {
name := strings.TrimPrefix(segment, ":")
matchNames = append(matchNames, name)
regexParts[i] = "([^/]+)"
} else {
regexParts[i] = regexp.QuoteMeta(segment)
}
}
paramNames := []string{}
for _, segment := range strings.Split(strings.Trim(r.Path, "/"), "/") {
if strings.HasPrefix(segment, ":") {
paramNames = append(paramNames, strings.TrimPrefix(segment, ":"))
}
}
pathRegex := strings.Join(regexParts, "/")
suffix := "/?$"
if len(r.Children) > 0 {
suffix = "(?:/|$)"
}
if pathRegex == "" {
if len(r.Children) > 0 {
suffix = ""
} else {
suffix = "$"
}
}
pattern := "^/" + pathRegex + suffix
var loader func() core.Component
var singleton bool
switch c := r.Component.(type) {
case *types.View:
comp := c
loader = func() core.Component { return comp }
singleton = true
case func() *types.View:
loader = func() core.Component { return c() }
case func() core.Component:
loader = c
case core.Component:
comp := c
loader = func() core.Component { return comp }
singleton = true
}
rt := route{
pattern: r.Path,
fullPath: fullPath,
name: r.Name,
regex: regexp.MustCompile(pattern),
paramNames: paramNames,
matchNames: matchNames,
loader: loader,
singleton: singleton,
guards: r.Guards,
dataLoader: r.Loader,
redirect: r.Redirect,
meta: cloneMeta(r.Meta),
}
for _, child := range r.Children {
rt.children = append(rt.children, buildRouteAt(child, fullPath))
}
return rt
}
RegisteredRoutes
RegisteredRoutes returns snapshots of all registered routes.
Returns
func RegisteredRoutes() []RegisteredRoute
{
out := make([]RegisteredRoute, 0, len(routes))
for i := range routes {
out = append(out, snapshotRoute(&routes[i], ""))
}
return out
}
snapshotRoute
Parameters
Returns
func snapshotRoute(r *route, parent string) RegisteredRoute
{
params := make([]string, len(r.paramNames))
copy(params, r.paramNames)
full := resolveRoutePath(parent, r.pattern)
children := make([]RegisteredRoute, len(r.children))
for i := range r.children {
children[i] = snapshotRoute(&r.children[i], full)
}
return RegisteredRoute{
Template: r.pattern,
Path: full,
Name: r.name,
Params: params,
Children: children,
Meta: cloneMeta(r.meta),
}
}
resolveRoutePath
Parameters
Returns
func resolveRoutePath(parent, path string) string
{
if path == "" {
if parent == "" {
return "/"
}
return parent
}
if strings.HasPrefix(path, "/") {
return path
}
trimmed := strings.TrimPrefix(path, "/")
if parent == "" || parent == "/" {
return "/" + trimmed
}
if strings.HasSuffix(parent, "/") {
return parent + trimmed
}
return parent + "/" + trimmed
}
routeParamReceiver
type routeParamReceiver interface
Methods
routeParamHandler
type routeParamHandler interface
Methods
matchRoute
func matchRoute(routes []route, path string) (*route, []Guard, map[string]string)
{
for i := range routes {
r := &routes[i]
matches := r.regex.FindStringSubmatch(path)
if matches == nil {
if child, guards, params := matchRoute(r.children, path); child != nil {
return child, append(r.guards, guards...), params
}
continue
}
params := map[string]string{}
for i, name := range r.matchNames {
if i+1 < len(matches) {
params[name] = decodeRouteParam(matches[i+1])
}
}
if child, guards, childParams := matchRoute(r.children, path); child != nil {
return child, append(r.guards, guards...), childParams
}
matchedPath := strings.TrimSuffix(matches[0], "/")
if (r.loader != nil || r.redirect != "") && matchedPath == strings.TrimSuffix(path, "/") {
return r, r.guards, params
}
}
return nil, nil, nil
}
decodeRouteParam
Parameters
Returns
func decodeRouteParam(value string) string
{
decoded, err := url.PathUnescape(value)
if err != nil {
return value
}
return decoded
}
Replace
Replace behaves like Navigate outside browser builds.
Parameters
func Replace(fullPath string)
{
Navigate(fullPath)
}
SetScrollRestoration
SetScrollRestoration is a no-op outside browser builds.
Parameters
func SetScrollRestoration(bool)
{}
Page
Page registers a route with an optional set of guards.
Parameters
func Page(path string, component any, guards ...Guard)
{
RegisterRoute(Route{
Path: path,
Component: component,
Guards: guards,
})
}
Group
Group registers a set of child routes below a path prefix.
Parameters
func Group(prefix string, fn func(*GroupBuilder))
{
b := &GroupBuilder{prefix: prefix}
fn(b)
RegisterRoute(Route{
Path: prefix,
Children: b.children,
})
}
GroupBuilder
GroupBuilder collects routes for Group.
type GroupBuilder struct
Methods
Page adds a child route within a Group.
Parameters
func (*GroupBuilder) Page(path string, component any, guards ...Guard)
{
g.children = append(g.children, Route{
Path: path,
Component: component,
Guards: guards,
})
}
Page adds a route to the group.
Parameters
func (*GroupBuilder) Page(path string, component any, guards ...Guard)
{
g.children = append(g.children, Route{
Path: path,
Component: component,
Guards: guards,
})
}
Fields
| Name | Type | Description |
|---|---|---|
| prefix | string | |
| children | []Route |
InitRouter
InitRouter is a no-op outside browser builds.
func InitRouter()
{}
RouterData
RouterData returns the values exposed to component templates.
Returns
func RouterData() map[string]any
{
return map[string]any{
"ActivePath": activePathSig,
"NavItems": NavItemsMap(),
}
}
TemplateData
TemplateData returns the values exposed to component templates.
Returns
func TemplateData() map[string]any
{ return RouterData() }
ActivePath
ActivePath returns the reactive signal holding the current route path.
Returns
func ActivePath() *state.Signal[string]
{
return activePathSig
}
recordComponent
type recordComponent struct
Methods
func (*recordComponent) Mount()
{}
func (*recordComponent) Unmount()
{}
func (*recordComponent) OnMount()
{}
func (*recordComponent) OnUnmount()
{}
Parameters
func (*recordComponent) OnParams(p map[string]string)
{
c.SetRouteParams(p)
}
Parameters
func (*recordComponent) SetRouteParams(p map[string]string)
{
c.params = map[string]string{}
for k, v := range p {
c.params[k] = v
}
}
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| params | map[string]string |
resetRouter
Parameters
func resetRouter(t *testing.T)
{
t.Helper()
Reset()
t.Cleanup(func() { Reset() })
}
mustRecord
Parameters
Returns
func mustRecord(t *testing.T, c core.Component) *recordComponent
{
t.Helper()
rc, ok := c.(*recordComponent)
if !ok {
t.Fatalf("expected *recordComponent, got %T", c)
}
return rc
}
TestRegisterRoute_BasicRouting
Parameters
func TestRegisterRoute_BasicRouting(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/a", Component: func() core.Component { return &recordComponent{name: "a"} }})
RegisterRoute(Route{Path: "/users/:id", Component: func() core.Component { return &recordComponent{name: "user"} }})
NavigateTo("/a")
if got := mustRecord(t, CurrentComponent()).name; got != "a" {
t.Fatalf("expected current component 'a', got %q", got)
}
NavigateTo("/users/123")
rc := mustRecord(t, CurrentComponent())
if rc.params["id"] != "123" {
t.Fatalf("expected id=123, got %v", rc.params)
}
var gotPath string
NotFoundCallback = func(p string) { gotPath = p }
NavigateTo("/missing")
if gotPath != "/missing" {
t.Fatalf("expected NotFoundCallback '/missing', got %q", gotPath)
}
}
TestQueryParams
Parameters
func TestQueryParams(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/search/:kind", Component: func() core.Component { return &recordComponent{name: "search"} }})
NavigateTo("/search/books?q=go&lang=en")
rc := mustRecord(t, CurrentComponent())
want := map[string]string{"kind": "books", "q": "go", "lang": "en"}
if !reflect.DeepEqual(rc.params, want) {
t.Fatalf("expected params %v, got %v", want, rc.params)
}
}
TestNotFoundComponent
Parameters
func TestNotFoundComponent(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/home", Component: func() core.Component { return &recordComponent{name: "home"} }})
NavigateTo("/home")
_ = mustRecord(t, CurrentComponent())
NotFoundComponent = func() core.Component { return &recordComponent{name: "404"} }
NavigateTo("/missing")
nf := mustRecord(t, CurrentComponent())
if nf.name != "404" {
t.Fatalf("expected 404 current component, got %q", nf.name)
}
}
TestTrailingSlashNormalization
Parameters
func TestTrailingSlashNormalization(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/trail", Component: func() core.Component { return &recordComponent{name: "trail"} }})
NavigateTo("/trail/")
if got := mustRecord(t, CurrentComponent()).name; got != "trail" {
t.Fatalf("expected trailing slash to match, got %q", got)
}
var called bool
NotFoundCallback = func(string) { called = true }
NavigateTo("/trail/extra")
if !called {
t.Fatalf("expected not found for extra segments")
}
}
TestNestedRouteChildParamShadowsParent
Parameters
func TestNestedRouteChildParamShadowsParent(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{
Path: "/teams/:id",
Children: []Route{{
Path: "users/:id",
Component: func() core.Component { return &recordComponent{name: "user"} },
}},
})
NavigateTo("/teams/acme/users/alice")
rc := mustRecord(t, CurrentComponent())
if rc.params["id"] != "alice" {
t.Fatalf("expected child id=alice, got %v", rc.params)
}
}
TestParentRouteDoesNotMatchUnknownChild
Parameters
func TestParentRouteDoesNotMatchUnknownChild(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{
Path: "/docs",
Component: func() core.Component { return &recordComponent{name: "docs"} },
Children: []Route{{
Path: "page",
Component: func() core.Component { return &recordComponent{name: "page"} },
}},
})
called := false
NotFoundCallback = func(string) { called = true }
NavigateTo("/docs/unknown")
if !called {
t.Fatal("expected unknown child path to be not found")
}
}
dataComponent
type dataComponent struct
Methods
Parameters
func (*dataComponent) SetRouteData(data any)
{
component.data = data
}
Fields
| Name | Type | Description |
|---|---|---|
| data | any |
TestNamedRouteURLAndMetadata
Parameters
func TestNamedRouteURLAndMetadata(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{
Path: "/teams/:team",
Children: []Route{{
Path: "users/:user",
Name: "team-user",
Component: func() core.Component { return &recordComponent{name: "user"} },
Meta: map[string]any{"title": "User"},
}},
})
path, err := URL("team-user", map[string]string{
"team": "core",
"user": "Ada Lovelace",
}, url.Values{"tab": {"activity"}})
if err != nil {
t.Fatalf("build URL: %v", err)
}
if path != "/teams/core/users/Ada%20Lovelace?tab=activity" {
t.Fatalf("unexpected URL: %s", path)
}
if err := NavigateContext(context.Background(), "/teams/core/users/Ada%20Lovelace"); err != nil {
t.Fatalf("navigate generated URL: %v", err)
}
component := CurrentComponent().(*recordComponent)
if component.params["user"] != "Ada Lovelace" {
t.Fatalf("route parameter was not decoded: %#v", component.params)
}
definitions := RegisteredRoutes()
child := definitions[0].Children[0]
if child.Name != "team-user" || child.Meta["title"] != "User" {
t.Fatalf("named route metadata missing: %#v", child)
}
}
TestRouteLoaderCommitsDataAndMeta
Parameters
func TestRouteLoaderCommitsDataAndMeta(t *testing.T)
{
resetRouter(t)
var loadedContext LoadContext
RegisterRoute(Route{
Path: "/reports/:id",
Name: "report",
Component: func() core.Component {
return &dataComponent{recordComponent: recordComponent{name: "report"}}
},
Loader: func(_ context.Context, loadContext LoadContext) (any, error) {
loadedContext = loadContext
return map[string]any{"total": 4}, nil
},
Meta: map[string]any{"section": "reports"},
})
if err := NavigateContext(context.Background(), "/reports/7?period=week"); err != nil {
t.Fatalf("navigate: %v", err)
}
component := CurrentComponent().(*dataComponent)
if !reflect.DeepEqual(component.data, map[string]any{"total": 4}) {
t.Fatalf("loader data missing: %#v", component.data)
}
if loadedContext.Params["id"] != "7" || loadedContext.Query.Get("period") != "week" {
t.Fatalf("loader context incorrect: %#v", loadedContext)
}
if Status().Get() != NavigationReady || Error().Get() != nil {
t.Fatalf("unexpected navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
if Meta().Get()["section"] != "reports" {
t.Fatalf("route metadata missing: %#v", Meta().Get())
}
}
TestRouteRedirectInterpolatesParameters
Parameters
func TestRouteRedirectInterpolatesParameters(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/legacy/:id", Redirect: "/users/:id"})
RegisterRoute(Route{
Path: "/users/:id",
Component: func() core.Component { return &dataComponent{recordComponent: recordComponent{name: "user"}} },
Loader: func(ctx context.Context, load LoadContext) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return load.Params["id"], nil
},
})
if err := NavigateContext(context.Background(), "/legacy/42"); err != nil {
t.Fatalf("redirect: %v", err)
}
component := CurrentComponent().(*dataComponent)
if component.params["id"] != "42" || ActivePath().Get() != "/users/42" {
t.Fatalf("redirect destination incorrect: component=%#v path=%s", component, ActivePath().Get())
}
if component.data != "42" {
t.Fatalf("redirected loader did not complete: %#v", component.data)
}
}
TestRouteRedirectLoopFails
Parameters
func TestRouteRedirectLoopFails(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/loop-a", Redirect: "/loop-b"})
RegisterRoute(Route{Path: "/loop-b", Redirect: "/loop-a"})
if err := NavigateContext(context.Background(), "/loop-a"); !errors.Is(err, ErrRedirectLoop) {
t.Fatalf("expected redirect loop error, got %v", err)
}
}
CurrentComponent
CurrentComponent returns the current routed component.
Returns
func CurrentComponent() core.Component
{ return currentComponent }