router
packageAPI reference for the router
package.
Imports
(18)strings
STD
testing
STD
time
INT
github.com/rfwlab/rfw/v2/core
INT
github.com/rfwlab/rfw/v2/dom
INT
github.com/rfwlab/rfw/v2/state
STD
context
INT
github.com/rfwlab/rfw/v2/js
STD
net/url
STD
regexp
STD
sync
INT
github.com/rfwlab/rfw/v2/events
INT
github.com/rfwlab/rfw/v2/internal/rendertrace
INT
github.com/rfwlab/rfw/v2/types
STD
errors
STD
reflect
STD
sort
STD
syscall/js
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)
}
}
Revalidate
Revalidate re-runs the guards of the route that is currently mounted against
the committed path, and applies their decision to it. It is what an
application calls after the authority a guard reads changes under a page the
user is already on: install the new snapshot first, then revalidate, and the
guards stay the single declarative place where access is decided.
A route its guards still allow is left untouched. One they now refuse is torn
down like the navigation that would have been refused: the routed component
unmounts, its subtree leaves the DOM, and the shell around the outlet stays
mounted. A guard that redirects sends navigation to its destination, and one
that hands off to the host loads the host page. With no routed component
mounted, or none for the committed path, nothing happens, so calling it again
after a refusal is a no-op.
It must not be called from a guard: a guard returns a decision, and the
router is what acts on it.
func Revalidate()
{
_ = RevalidateContext(context.Background())
}
RevalidateContext
RevalidateContext revalidates the mounted route like Revalidate and returns
the outcome: nil when the route may stay, when a redirect committed, when the
browser was handed to the host, or when there was nothing to revalidate, and
otherwise the error the guards decided on (ErrNavigationForbidden,
ErrNavigationBlocked, ErrInvalidGuardResult) or the context error.
Parameters
Returns
func RevalidateContext(parent context.Context) error
{
// A caller with no context to give passes none, the way Revalidate does.
// Normalized here, at the one entry point: a redirect derives its context
// from this parent, and deriving from a nil one panics.
if parent == nil {
parent = context.Background()
}
var revalidationErr error
core.TryNavigate(activePathSig.Get(), func() {
revalidationErr = revalidateImpl(parent)
})
return revalidationErr
}
revalidateImpl
Parameters
Returns
func revalidateImpl(parent context.Context) error
{
// Nothing mounted is nothing to revalidate: a navigation that has not
// committed yet still has its own guards ahead of it, and a refusal already
// cleared the owner it tore down.
if currentComponent == nil {
return nil
}
r, guards, params := matchCurrentRoute(activePathSig.Get())
if r == nil {
return nil
}
// The match only speaks for the route that actually committed what is
// mounted. It does not when a route is registered for the path the
// not-found component is sitting on: those guards never ran to put anything
// there, so they have no say over a component that is not theirs.
if r.component != currentComponent {
return nil
}
// Taking the navigation generation cancels whatever is in flight: it was
// authorized under the snapshot this call replaces, so its loader must not
// come back and commit over the decision made here.
ctx, _ := beginNavigation(parent)
if err := ctx.Err(); err != nil {
return err
}
guardResult, guardErr := runGuards(guards, params)
if guardErr != nil {
// Every refusal fails closed the same way, a legacy guard that blocks
// included: the route is mounted, so leaving it there is the one outcome
// a refusal cannot have. Navigation to a refused destination keeps its
// own behavior, where nothing was committed to take back.
revokeMountedRoute(guardErr)
return guardErr
}
switch guardResult.Action {
case GuardAllow:
settleRevalidatedRoute()
return nil
case GuardHostReplace:
// The host owns the destination and the browser loads it as a document.
// Nothing commits and nothing is torn down first: the page the user is
// on stays exactly as it is until the browser unloads it.
hostReplace(guardResult.Path)
return nil
}
redirectCtx, err := nextRedirectContext(parent)
if err != nil {
failNavigation(err)
return err
}
// The entry the browser is sitting on is the one the guard just refused, so
// both redirect actions replace it: pushing would leave the refused path
// behind for back to return to, where the guard refuses it again. From
// there it is ordinary routing, which unmounts the current owner once.
return navigateImpl(redirectCtx, guardResult.Path, historyReplace)
}
revokeMountedRoute
revokeMountedRoute tears down the routed owner its guards just refused. Only
the route goes: the persistent root mounted with MountRoot and the outlet
inside it stay where they are, so the next allowed navigation renders into
the shell the user is still looking at. What the route loaded goes with it,
so nothing reads its data or its metadata after the refusal. The committed
path is kept, so a repeated revalidation finds nothing mounted and does
nothing, while an explicit navigation can enter the route again once its
guards allow it.
Parameters
func revokeMountedRoute(err error)
{
owner := currentComponent
// Dropped before the teardown, not after: the outlet repaints whatever
// currentComponent points at whenever a component around it paints, and
// unmounting paints.
currentComponent = nil
core.Log().Debug("Unmounting revoked component: %s", owner.GetName())
core.TriggerUnmount(owner)
// Unmount first, so the component's own cleanup, its scope and its host
// registrations still see the tree they own.
owner.Unmount()
clearRoutedSubtree(owner.GetID())
revokeCommittedRoute(err)
}
clearRoutedSubtree
clearRoutedSubtree removes the DOM the routed owner had, and nothing else.
With a live outlet the marker div is the router’s anchor inside the shell, so
its content goes and the marker itself stays where a later navigation and a
shell re-render expect to find it. Without one the router rendered the page
into #app itself and the owner’s own root is removed.
dom.ComponentRoot answers #app for an id it cannot resolve, which here would
mean erasing the shell instead of the route, so a resolved root is used only
when it really carries the id it was asked for.
Parameters
func clearRoutedSubtree(id string)
{
if content, ok := routedOutletContent(); ok {
content.SetHTML("")
return
}
root := dom.ComponentRoot(id)
if root.IsNull() || root.IsUndefined() || root.Attr("data-component-id") != id {
return
}
root.Call("remove")
}
routedOutletContent
routedOutletContent resolves the element a live outlet renders routed
components into: the marker div, or the outlet’s own root when the template
carries no marker. It reports false when there is no outlet or its root is
not in the document, the case renderChild falls back to #app for.
Returns
func routedOutletContent() (dom.Element, bool)
{
if liveOutlet == nil {
return dom.Element{Value: js.Null()}, false
}
root := dom.ComponentRoot(liveOutlet.GetID())
if root.IsNull() || root.IsUndefined() || root.Attr("data-component-id") != liveOutlet.GetID() {
return dom.Element{Value: js.Null()}, false
}
if target := root.Query("[data-router-outlet]"); !target.IsNull() && !target.IsUndefined() {
return target, true
}
return root, true
}
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
| Name | Type | Description |
|---|---|---|
| Path | string | |
| Name | string | |
| Component | any | |
| Guards | []Guard | |
| ResultGuards | []ResultGuard | |
| Children | []Route | |
| Loader | Loader | |
| Redirect | string | |
| Meta | map[string]any |
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 | []guardEntry | |
| 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: routeGuardEntries(r.Guards, r.ResultGuards),
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
Parameters
Returns
func matchRoute(routes []route, path string) (*route, []guardEntry, 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, joinGuards(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, joinGuards(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
}
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 }
hostGuardPage
hostGuardPage is a routed page that counts its unmounts, so a test can tell
a page left in place from one the router tore down.
type hostGuardPage struct
Methods
func (*hostGuardPage) Unmount()
{
p.unmounted++
p.HTMLComponent.Unmount()
}
Fields
| Name | Type | Description |
|---|---|---|
| unmounted | int |
newHostGuardPage
Parameters
Returns
func newHostGuardPage(name, marker string) *hostGuardPage
{
page := &hostGuardPage{HTMLComponent: core.NewHTMLComponent(
name,
[]byte(`<root><div data-host-page="`+marker+`"></div></root>`),
nil,
)}
page.SetComponent(page)
page.Init(nil)
return page
}
captureHostReplace
captureHostReplace swaps the browser handoff for a recorder. The production
path calls location.replace, which would unload the page running the test:
this proves the router asked for the handoff and with which path, not that
the host served the document.
Parameters
Returns
func captureHostReplace(t *testing.T) *[]string
{
t.Helper()
previous := hostReplace
calls := []string{}
hostReplace = func(path string) { calls = append(calls, path) }
t.Cleanup(func() { hostReplace = previous })
return &calls
}
mountHostShell
mountHostShell renders a shell with a router outlet into #app and returns the
page mounted on path, with a marker injected after mount the way a fetch
callback fills a card. A remount would wipe that marker.
Parameters
Returns
func mountHostShell(t *testing.T, path string) *hostGuardPage
{
t.Helper()
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
shell := core.NewHTMLComponent("HostShell", []byte(`<root>@include:outlet</root>`), nil)
shell.SetComponent(shell)
shell.AddDependency("outlet", NewOutlet())
shell.Init(nil)
page := newHostGuardPage("HostDashboard", "dashboard")
Page(path, page)
MountRoot(shell)
Navigate(path)
dom.Query("[data-host-page='dashboard']").SetHTML(`<b id="host-built">built</b>`)
if !strings.Contains(dom.ByID("app").HTML(), "host-built") {
t.Fatal("the mounted page was not rendered")
}
return page
}
TestHostReplaceHandsOffWithoutCommitting
A guard that hands the document to a host-owned path performs the browser
navigation and commits nothing on the way out: the protected route’s loader,
component factory, DOM and route state stay untouched, no history entry is
written, and the page the user is looking at survives unchanged until the
browser unloads it.
Parameters
func TestHostReplaceHandsOffWithoutCommitting(t *testing.T)
{
Reset()
restoreHistoryPath(t)
calls := captureHostReplace(t)
dashboard := mountHostShell(t, "/host-dashboard")
created := 0
RegisterRoute(Route{
Path: "/host-admin",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return HostReplace("/login") }},
Component: func() core.Component {
created++
return newHostGuardPage("HostAdmin", "admin")
},
Loader: func(context.Context, LoadContext) (any, error) {
t.Error("the protected route ran its loader before the host handoff")
return nil, nil
},
Meta: map[string]any{"section": "admin"},
})
NotFoundCallback = func(string) { t.Error("the host handoff went through the not-found callback") }
NotFoundComponent = func() core.Component {
t.Error("the host handoff rendered the not-found component")
return newHostGuardPage("HostNotFound", "not-found")
}
historyLength := js.History().Get("length").Int()
location := js.Location().Get("pathname").String()
if err := NavigateContext(context.Background(), "/host-admin"); err != nil {
t.Fatalf("host handoff: %v", err)
}
if len(*calls) != 1 || (*calls)[0] != "/login" {
t.Fatalf("host replace calls = %#v, want one call with /login", *calls)
}
if created != 0 {
t.Fatalf("the protected component was created %d times", created)
}
if got := js.History().Get("length").Int(); got != historyLength {
t.Fatalf("history length changed from %d to %d", historyLength, got)
}
if got := js.Location().Get("pathname").String(); got != location {
t.Fatalf("the SPA moved the location to %q, want %q", got, location)
}
if got := ActivePath().Get(); got != "/host-dashboard" {
t.Fatalf("active path = %q, want /host-dashboard", got)
}
if mounted, ok := CurrentComponent().(*hostGuardPage); !ok || mounted != dashboard {
t.Fatalf("current component changed to %#v", CurrentComponent())
}
if dashboard.unmounted != 0 {
t.Fatalf("the mounted page was unmounted %d times before the handoff", dashboard.unmounted)
}
html := dom.ByID("app").HTML()
if !strings.Contains(html, "host-built") {
t.Fatalf("the handoff tore down the mounted page: %s", html)
}
if strings.Contains(html, `data-host-page="admin"`) {
t.Fatalf("the protected page reached the DOM: %s", html)
}
if Status().Get() != NavigationReady {
t.Fatalf("navigation status = %s, want the mounted route's ready", Status().Get())
}
if Error().Get() != nil {
t.Fatalf("navigation error = %v, want none", Error().Get())
}
if section, ok := Meta().Get()["section"]; ok {
t.Fatalf("the protected route committed its metadata: %v", section)
}
}
TestParentHostReplaceStopsChildGuards
A parent guard that hands off stops there: no child guard, no child loader,
no component.
Parameters
func TestParentHostReplaceStopsChildGuards(t *testing.T)
{
Reset()
restoreHistoryPath(t)
calls := captureHostReplace(t)
childRan, created := false, 0
RegisterRoute(Route{
Path: "/host-parent",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return HostReplace("/login") }},
Children: []Route{{
Path: "users",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
childRan = true
return Allow()
}},
Component: func() core.Component {
created++
return routeComponent{}
},
Loader: func(context.Context, LoadContext) (any, error) {
t.Error("a child loader ran after the parent handed off")
return nil, nil
},
}},
})
if err := NavigateContext(context.Background(), "/host-parent/users"); err != nil {
t.Fatalf("host handoff: %v", err)
}
if len(*calls) != 1 || (*calls)[0] != "/login" {
t.Fatalf("host replace calls = %#v, want one call with /login", *calls)
}
if childRan {
t.Fatal("a child guard ran after the parent handed off")
}
if created != 0 {
t.Fatalf("the child component was created %d times", created)
}
}
TestSessionGuardHandsOffUnauthenticated
An unauthenticated caller leaves for the host login page while the same guard
keeps a permission refusal inside the SPA.
Parameters
func TestSessionGuardHandsOffUnauthenticated(t *testing.T)
{
Reset()
t.Cleanup(Reset)
calls := captureHostReplace(t)
authenticated, permitted := false, true
created := sessionGuardedRoute(t, &authenticated, &permitted)
if err := NavigateContext(context.Background(), "/composed"); err != nil {
t.Fatalf("host handoff: %v", err)
}
if len(*calls) != 1 || (*calls)[0] != "/login" {
t.Fatalf("host replace calls = %#v, want one call with /login", *calls)
}
if *created != 0 || CurrentComponent() != nil {
t.Fatalf("the handoff committed a route: created=%d component=%#v", *created, CurrentComponent())
}
}
TestHostReplaceHandsOffAcceptedTargets
The accepted targets reach the handoff exactly as validation returned them.
Parameters
func TestHostReplaceHandsOffAcceptedTargets(t *testing.T)
{
for _, target := range hostReplaceAccepts {
t.Run(target, func(t *testing.T) {
Reset()
t.Cleanup(Reset)
calls := captureHostReplace(t)
destination := target
created := guardedRoute(t, "/host-accepted", []ResultGuard{func(map[string]string) GuardResult {
return HostReplace(destination)
}})
if err := NavigateContext(context.Background(), "/host-accepted"); err != nil {
t.Fatalf("host handoff: %v", err)
}
if len(*calls) != 1 || (*calls)[0] != target {
t.Fatalf("host replace calls = %#v, want one call with %q", *calls, target)
}
if *created != 0 {
t.Fatalf("the protected component was created %d times", *created)
}
})
}
}
TestHostReplaceRejectedTargetsNeverHandOff
A target validation refuses never reaches the browser: navigation fails
closed and the document stays where it is.
Parameters
func TestHostReplaceRejectedTargetsNeverHandOff(t *testing.T)
{
for _, tc := range hostReplaceRejects {
t.Run(tc.name, func(t *testing.T) {
Reset()
restoreHistoryPath(t)
calls := captureHostReplace(t)
destination := tc.target
created := guardedRoute(t, "/host-rejected", []ResultGuard{func(map[string]string) GuardResult {
return HostReplace(destination)
}})
location := js.Location().Get("pathname").String()
err := NavigateContext(context.Background(), "/host-rejected")
if !errors.Is(err, ErrInvalidGuardResult) {
t.Fatalf("expected an invalid guard result, got %v", err)
}
if len(*calls) != 0 {
t.Fatalf("a rejected target reached the browser: %#v", *calls)
}
if *created != 0 || CurrentComponent() != nil {
t.Fatalf("a rejected target committed a route: created=%d component=%#v", *created, CurrentComponent())
}
if got := js.Location().Get("pathname").String(); got != location {
t.Fatalf("location moved to %q, want %q", got, location)
}
})
}
}
TestGuardRedirectsStayInsideTheSPA
The SPA redirects are untouched by the new action: they keep matching routes
and writing history entries, and never reach the host handoff.
Parameters
func TestGuardRedirectsStayInsideTheSPA(t *testing.T)
{
Reset()
restoreHistoryPath(t)
calls := captureHostReplace(t)
RegisterRoute(Route{Path: "/spa-origin", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/spa-offers", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/spa-login", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{
Path: "/spa-promo",
Component: func() core.Component { return routeComponent{} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return RedirectTo("/spa-offers") }},
})
RegisterRoute(Route{
Path: "/spa-account",
Component: func() core.Component { return routeComponent{} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return ReplaceWith("/spa-login") }},
})
Navigate("/spa-origin")
before := js.History().Get("length").Int()
Navigate("/spa-promo")
if got := js.Location().Get("pathname").String(); got != "/spa-offers" {
t.Fatalf("path after the guard redirect = %q", got)
}
if got := js.History().Get("length").Int(); got != before+1 {
t.Fatalf("history length = %d, want %d", got, before+1)
}
before = js.History().Get("length").Int()
Navigate("/spa-account")
if got := js.Location().Get("pathname").String(); got != "/spa-login" {
t.Fatalf("path after the guard replace = %q", got)
}
if got := js.History().Get("length").Int(); got != before {
t.Fatalf("history length changed from %d to %d", before, got)
}
if len(*calls) != 0 {
t.Fatalf("an SPA redirect reached the host handoff: %#v", *calls)
}
}
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 |
GuardAction
GuardAction is the outcome a result guard asks the router to apply.
type GuardAction uint8
GuardResult
GuardResult is the decision a ResultGuard returns. Build it with Allow,
Forbid, RedirectTo, ReplaceWith or HostReplace rather than by hand.
type GuardResult struct
Fields
| Name | Type | Description |
|---|---|---|
| Action | GuardAction | |
| Path | string |
Uses
ResultGuard
ResultGuard decides whether a route may be entered and, when it may not,
where navigation goes instead. A result guard must not call Navigate or
Replace itself: returning the destination lets the router apply it once, in
the right order, without reentering an in-flight navigation.
type ResultGuard func(map[string]string) GuardResult
Allow
Allow lets navigation continue.
Returns
func Allow() GuardResult
{ return GuardResult{Action: GuardAllow} }
Uses
Forbid
Forbid refuses navigation without a destination to fall back to.
Returns
func Forbid() GuardResult
{ return GuardResult{Action: GuardForbid} }
Uses
RedirectTo
RedirectTo sends navigation to path and keeps the current history entry,
unless navigation came from an initial load or a popstate.
Parameters
Returns
func RedirectTo(path string) GuardResult
{
return GuardResult{Action: GuardRedirect, Path: path}
}
Uses
ReplaceWith
ReplaceWith sends navigation to path in place of the current history entry.
Parameters
Returns
func ReplaceWith(path string) GuardResult
{
return GuardResult{Action: GuardReplace, Path: path}
}
Uses
HostReplace
HostReplace hands the document to a host-owned path on the same origin, the
server-rendered login or session-expiry page an application does not route
itself. The browser loads it as a real navigation and replaces the current
history entry; RedirectTo and ReplaceWith stay inside the SPA and only
target registered routes. The path must already be rooted and same origin as
written, surrounding whitespace included: an external or ambiguous target
fails closed with ErrInvalidGuardResult.
Parameters
Returns
func HostReplace(path string) GuardResult
{
return GuardResult{Action: GuardHostReplace, Path: path}
}
Uses
guardEntry
guardEntry is one guard in evaluation order. Exactly one of its fields is
set: legacy guards keep their allow-or-block contract, result guards carry
the typed one.
type guardEntry struct
Fields
| Name | Type | Description |
|---|---|---|
| legacy | Guard | |
| result | ResultGuard |
routeGuardEntries
routeGuardEntries orders the guards of a single route: its legacy guards
first, then its result guards. Nesting keeps parents before children, so the
whole chain is deterministic.
Parameters
Returns
func routeGuardEntries(legacy []Guard, results []ResultGuard) []guardEntry
{
if len(legacy) == 0 && len(results) == 0 {
return nil
}
entries := make([]guardEntry, 0, len(legacy)+len(results))
for _, guard := range legacy {
entries = append(entries, guardEntry{legacy: guard})
}
for _, guard := range results {
entries = append(entries, guardEntry{result: guard})
}
return entries
}
joinGuards
joinGuards concatenates a parent chain with its child chain without writing
into the parent’s own slice.
Parameters
Returns
func joinGuards(parent, child []guardEntry) []guardEntry
{
if len(parent) == 0 {
return child
}
if len(child) == 0 {
return parent
}
joined := make([]guardEntry, 0, len(parent)+len(child))
joined = append(joined, parent...)
return append(joined, child...)
}
runGuards
runGuards evaluates the chain and stops at the first guard that does not
allow navigation. The returned result is meaningful only when err is nil: a
GuardRedirect, GuardReplace or GuardHostReplace action carries the validated
destination, and GuardAllow means the route may be loaded.
Parameters
Returns
func runGuards(entries []guardEntry, params map[string]string) (GuardResult, error)
{
for _, entry := range entries {
if entry.legacy != nil {
if entry.legacy(params) {
continue
}
return GuardResult{}, ErrNavigationBlocked
}
if entry.result == nil {
continue
}
result := entry.result(params)
switch result.Action {
case GuardAllow:
continue
case GuardForbid:
return GuardResult{}, ErrNavigationForbidden
case GuardRedirect, GuardReplace:
destination, err := guardRedirectTarget(result.Path)
if err != nil {
return GuardResult{}, err
}
result.Path = destination
return result, nil
case GuardHostReplace:
destination, err := guardHostReplaceTarget(result.Path)
if err != nil {
return GuardResult{}, err
}
result.Path = destination
return result, nil
default:
return GuardResult{}, ErrInvalidGuardResult
}
}
return GuardResult{}, nil
}
Uses
guardRedirectTarget
guardRedirectTarget validates a guard destination. It must be a rooted
application path: an empty, relative or scheme-relative target would send
navigation somewhere the router cannot resolve, so it fails closed.
Parameters
Returns
func guardRedirectTarget(path string) (string, error)
{
destination := strings.TrimSpace(path)
if destination == "" || !strings.HasPrefix(destination, "/") {
return "", ErrInvalidGuardResult
}
// "//host" and "/\host" leave the application: browsers read them as
// protocol-relative URLs, which is an open redirect, not a route.
if strings.HasPrefix(destination, "//") || strings.HasPrefix(destination, `/\`) {
return "", ErrInvalidGuardResult
}
return destination, nil
}
guardHostReplaceTarget
guardHostReplaceTarget validates a host handoff destination. This one is not
matched against a route: the browser loads it, so what matters is the URL the
browser ends up with after its own normalization. The string a guard returned
is therefore checked as it is and never repaired first: trimming would accept
” /login” by turning it into a different string, and the caller would have
handed the browser something validation never saw. A target that is not
already a rooted same-origin path fails closed, and an accepted one is
returned byte for byte.
Parameters
Returns
func guardHostReplaceTarget(path string) (string, error)
{
if path == "" || !strings.HasPrefix(path, "/") {
return "", ErrInvalidGuardResult
}
// Browsers remove tabs and newlines from anywhere in a URL, so "/<tab>/host"
// reaches the network as "//host", and they strip C0 characters and spaces
// from both ends before parsing. Refuse them rather than validate a string
// the browser will rewrite: a leading one already fails the rooted check
// above, and a trailing space is the only stripped byte left to catch.
if strings.ContainsFunc(path, isURLControl) {
return "", ErrInvalidGuardResult
}
if strings.HasSuffix(path, " ") {
return "", ErrInvalidGuardResult
}
// A backslash is read as a slash, which turns "/\host" into a
// protocol-relative URL pointing somewhere else entirely.
if strings.ContainsRune(path, '\\') {
return "", ErrInvalidGuardResult
}
if strings.HasPrefix(path, "//") {
return "", ErrInvalidGuardResult
}
parsed, err := url.Parse(path)
if err != nil {
return "", ErrInvalidGuardResult
}
if parsed.Scheme != "" || parsed.Opaque != "" || parsed.Host != "" || parsed.User != nil {
return "", ErrInvalidGuardResult
}
if !strings.HasPrefix(parsed.Path, "/") {
return "", ErrInvalidGuardResult
}
// Parse keeps the query raw, so a malformed escape there survives it.
if _, err := url.ParseQuery(parsed.RawQuery); err != nil {
return "", ErrInvalidGuardResult
}
return path, nil
}
isURLControl
Parameters
Returns
func isURLControl(r rune) bool
{ return r < 0x20 || r == 0x7f }
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)
})
}
revokeCommittedRoute
revokeCommittedRoute fails navigation for a route that was committed and has
just been taken back. What it loaded goes with it: the data and the metadata
belong to a page the guards no longer allow, so they are dropped in the same
batch that reports the refusal, and Data and Meta never keep serving what the
revocation removed from the screen. A denied navigation still uses
failNavigation, where the route the user is on stays allowed and keeps
everything it committed.
Parameters
func revokeCommittedRoute(err error)
{
state.Batch(func() {
currentRouteData.Set(nil)
currentRouteMeta.Set(map[string]any{})
navigationError.Set(err)
navigationStatus.Set(NavigationError)
})
}
settleRevalidatedRoute
settleRevalidatedRoute leaves a route its guards still allow exactly as it
is. The one thing it repairs is the loading status of the navigation the
revalidation cancelled: that navigation can no longer reach a status of its
own, and the route the user is looking at is ready, with the data and the
metadata it committed when it was entered.
func settleRevalidatedRoute()
{
if navigationStatus.Get() != NavigationLoading {
return
}
state.Batch(func() {
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
}
matchCurrentRoute
matchCurrentRoute resolves a committed full path the way navigation resolves
a destination: the same matcher, the same guard chain, and the same
parameters, route and query merged in the same order. Revalidation reuses it
so a guard cannot see one set of parameters when it decides on entry and
another when it decides whether the route may stay.
Parameters
Returns
func matchCurrentRoute(fullPath string) (*route, []guardEntry, map[string]string)
{
path := fullPath
query := ""
if idx := strings.Index(fullPath, "?"); idx != -1 {
path = fullPath[:idx]
query = fullPath[idx+1:]
}
r, guards, params := matchRoute(routes, path)
if r == nil {
return nil, nil, nil
}
if params == nil {
params = map[string]string{}
}
queryParams, _ := routeQuery(query)
for key, value := range queryParams {
params[key] = value
}
return r, guards, params
}
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
}
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, rendertrace.Cause{Kind: "parent"})
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, rendertrace.Cause{Kind: "router"})
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, cause rendertrace.Cause)
{
root := dom.ComponentRoot(o.GetID())
if root.IsNull() || root.IsUndefined() {
renderComponent(c, cause, "", 0, func(html string) {
dom.UpdateDOM(c.GetID(), html)
})
return
}
target := root.Query("[data-router-outlet]")
if target.IsNull() || target.IsUndefined() {
target = root
}
parentID, depth := "", 0
if rendertrace.Enabled() {
parentID = o.GetID()
depth = componentDOMDepth(parentID) + 1
}
renderComponent(c, cause, parentID, depth, func(html string) {
dom.UpdateDOMIn(target, c.GetID(), html)
})
}
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
renderComponent(mountedRoot, rendertrace.Cause{Kind: "mount"}, "", 0, func(html string) {
dom.UpdateDOM(mountedRoot.GetID(), html)
})
mountedRoot.Mount()
core.TriggerMount(mountedRoot)
}
renderComponent
Parameters
func renderComponent(c core.Component, cause rendertrace.Cause, parentID string, depth int, commit func(string))
{
if !rendertrace.Enabled() {
commit(core.TryRender(c))
return
}
cause = rendertrace.NormalizeCause(cause)
batchID := rendertrace.NextBatchID()
renderID := rendertrace.NextRenderID()
started := rendertrace.NowMS()
base := rendertrace.Record{
BatchID: batchID,
RenderID: renderID,
ComponentID: c.GetID(),
ComponentName: c.GetName(),
ParentComponentID: parentID,
Depth: depth,
Cause: cause,
Causes: []rendertrace.Cause{cause},
}
startedRecord := base
startedRecord.Event = "started"
rendertrace.Emit(startedRecord)
var templateMS, domMS float64
phase, phaseStarted := "template", rendertrace.NowMS()
defer func() {
if recovered := recover(); recovered != nil {
now := rendertrace.NowMS()
switch phase {
case "template":
templateMS = now - phaseStarted
case "dom":
domMS = now - phaseStarted
}
failed := base
failed.Event = "failed"
failed.TemplateMS = templateMS
failed.DOMMS = domMS
failed.TotalMS = rendertrace.NowMS() - started
failed.Outcome = "failed"
failed.Reason = "panic"
rendertrace.Emit(failed)
panic(recovered)
}
}()
html := core.TryRender(c)
templateMS = rendertrace.NowMS() - phaseStarted
phase, phaseStarted = "dom", rendertrace.NowMS()
commit(html)
domMS = rendertrace.NowMS() - phaseStarted
phase = ""
completed := base
completed.Event = "committed"
completed.TemplateMS = templateMS
completed.DOMMS = domMS
completed.TotalMS = rendertrace.NowMS() - started
completed.Outcome = "committed"
rendertrace.Emit(completed)
}
componentDOMDepth
Parameters
Returns
func componentDOMDepth(componentID string) int
{
root := dom.ComponentRoot(componentID)
if root.IsNull() || root.IsUndefined() || root.Attr("data-component-id") != componentID {
return 0
}
depth := 0
for parent := root.Get("parentElement"); parent.Truthy(); parent = parent.Get("parentElement") {
if parent.Call("hasAttribute", "data-component-id").Bool() {
depth++
}
}
return depth
}
routerTraceSnapshot
type routerTraceSnapshot struct
Fields
| Name | Type | Description |
|---|---|---|
| event | string | |
| componentID | string | |
| parentID | string | |
| cause | string | |
| hasTimings | bool | |
| hasQueue | bool | |
| templateMS | float64 | |
| domMS | float64 |
TestOutletTraceCoversRootMountAndRouteCommit
Parameters
func TestOutletTraceCoversRootMountAndRouteCommit(t *testing.T)
{
restoreTrace := rendertrace.SetEnabledForTest(true)
defer restoreTrace()
var records []routerTraceSnapshot
callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
detail := args[0].Get("detail")
cause := detail.Get("cause")
record := routerTraceSnapshot{
event: detail.Get("event").String(),
componentID: detail.Get("componentId").String(),
hasTimings: detail.Call("hasOwnProperty", "templateMs").Bool() && detail.Call("hasOwnProperty", "domMs").Bool(),
hasQueue: detail.Call("hasOwnProperty", "queueDepth").Bool(),
}
if value := detail.Get("parentComponentId"); value.Type() == js.TypeString {
record.parentID = value.String()
}
if cause.Type() == js.TypeObject {
record.cause = cause.Get("kind").String()
}
if value := detail.Get("templateMs"); value.Type() == js.TypeNumber {
record.templateMS = value.Float()
}
if value := detail.Get("domMs"); value.Type() == js.TypeNumber {
record.domMS = value.Float()
}
records = append(records, record)
return nil
})
js.Global().Call("addEventListener", "rfw:render-trace", callback)
defer func() {
js.Global().Call("removeEventListener", "rfw:render-trace", callback)
callback.Release()
}()
Reset()
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
outlet := NewOutlet()
shell := core.NewHTMLComponent("TraceShell", []byte(`<root><aside>shell</aside>@include:outlet</root>`), nil)
shell.SetComponent(shell)
shell.AddDependency("outlet", outlet)
shell.Init(nil)
page := core.NewHTMLComponent("TracePage", []byte(`<root><main>page</main></root>`), nil)
page.SetComponent(page)
page.Init(nil)
defer page.Unmount()
defer shell.Unmount()
Page("/trace-route", page)
MountRoot(shell)
Navigate("/trace-route")
var shellCommit, pageCommit *routerTraceSnapshot
for index := range records {
record := &records[index]
if record.event != "committed" {
continue
}
switch record.componentID {
case shell.ID:
shellCommit = record
case page.ID:
pageCommit = record
}
}
if shellCommit == nil || shellCommit.cause != "mount" || !shellCommit.hasTimings || shellCommit.hasQueue {
t.Fatalf("root mount trace = %#v (all=%#v)", shellCommit, records)
}
if pageCommit == nil || pageCommit.cause != "router" || pageCommit.parentID != outlet.GetID() || !pageCommit.hasTimings || pageCommit.hasQueue {
t.Fatalf("route commit trace = %#v (all=%#v)", pageCommit, records)
}
}
TestDirectRenderFailureReportsElapsedDOMTime
Parameters
func TestDirectRenderFailureReportsElapsedDOMTime(t *testing.T)
{
restoreTrace := rendertrace.SetEnabledForTest(true)
defer restoreTrace()
var failed routerTraceSnapshot
callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
detail := args[0].Get("detail")
if detail.Get("event").String() != "failed" || detail.Get("componentId").String() != "direct-failure" {
return nil
}
failed = routerTraceSnapshot{
event: "failed",
componentID: "direct-failure",
hasTimings: detail.Call("hasOwnProperty", "templateMs").Bool() && detail.Call("hasOwnProperty", "domMs").Bool(),
hasQueue: detail.Call("hasOwnProperty", "queueDepth").Bool(),
templateMS: detail.Get("templateMs").Float(),
domMS: detail.Get("domMs").Float(),
}
return nil
})
js.Global().Call("addEventListener", "rfw:render-trace", callback)
defer func() {
js.Global().Call("removeEventListener", "rfw:render-trace", callback)
callback.Release()
}()
component := core.NewHTMLComponent("DirectFailure", []byte(`<root><main>failure</main></root>`), nil)
component.ID = "direct-failure"
component.Init(nil)
func() {
defer func() {
if recover() == nil {
t.Fatal("direct commit panic was not propagated")
}
}()
renderComponent(component, rendertrace.Cause{Kind: "router"}, "", 0, func(string) {
time.Sleep(3 * time.Millisecond)
panic("direct commit failure")
})
}()
if failed.event != "failed" || !failed.hasTimings || failed.hasQueue || failed.domMS < 2 {
t.Fatalf("direct failure trace = %#v", failed)
}
}
guardedRoute
guardedRoute registers a protected route whose loader and component factory
must never run when a guard refuses the destination.
Parameters
Returns
func guardedRoute(t *testing.T, path string, guards []ResultGuard, legacy ...Guard) *int
{
t.Helper()
created := 0
RegisterRoute(Route{
Path: path,
Guards: legacy,
ResultGuards: guards,
Component: func() core.Component {
created++
return &recordComponent{name: "protected"}
},
Loader: func(context.Context, LoadContext) (any, error) {
t.Error("route loader ran for a guarded destination")
return nil, nil
},
})
return &created
}
TestResultGuardForbidsWithoutCommitting
Parameters
func TestResultGuardForbidsWithoutCommitting(t *testing.T)
{
resetRouter(t)
created := guardedRoute(t, "/vault", []ResultGuard{func(map[string]string) GuardResult {
return Forbid()
}})
err := NavigateContext(context.Background(), "/vault")
if !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden navigation, got %v", err)
}
if *created != 0 {
t.Fatalf("protected component was created %d times", *created)
}
if CurrentComponent() != nil {
t.Fatalf("forbidden navigation committed %#v", CurrentComponent())
}
if Status().Get() != NavigationError || !errors.Is(Error().Get(), ErrNavigationForbidden) {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
}
TestResultGuardsComposeParentBeforeChild
Guards run parent before child, legacy before typed, and stop at the first
one that does not allow navigation.
Parameters
func TestResultGuardsComposeParentBeforeChild(t *testing.T)
{
resetRouter(t)
var order []string
record := func(name string) { order = append(order, name) }
RegisterRoute(Route{
Path: "/app",
Guards: []Guard{func(map[string]string) bool { record("parent-legacy"); return true }},
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
record("parent-result")
return Allow()
}},
Children: []Route{{
Path: "settings",
Guards: []Guard{func(map[string]string) bool { record("child-legacy"); return true }},
Component: func() core.Component { return &recordComponent{name: "settings"} },
ResultGuards: []ResultGuard{
func(map[string]string) GuardResult { record("child-result"); return Forbid() },
func(map[string]string) GuardResult { record("unreachable"); return Allow() },
},
}},
})
if err := NavigateContext(context.Background(), "/app/settings"); !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden navigation, got %v", err)
}
want := []string{"parent-legacy", "parent-result", "child-legacy", "child-result"}
if !reflect.DeepEqual(order, want) {
t.Fatalf("guard order = %v, want %v", order, want)
}
}
TestParentResultGuardStopsChildGuards
A parent guard that refuses stops the chain before any child guard runs.
Parameters
func TestParentResultGuardStopsChildGuards(t *testing.T)
{
resetRouter(t)
childRan := false
RegisterRoute(Route{
Path: "/admin",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
return ReplaceWith("/login")
}},
Children: []Route{{
Path: "users",
Component: func() core.Component { return &recordComponent{name: "users"} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
childRan = true
return Allow()
}},
}},
})
RegisterRoute(Route{Path: "/login", Component: func() core.Component { return &recordComponent{name: "login"} }})
if err := NavigateContext(context.Background(), "/admin/users"); err != nil {
t.Fatalf("guard redirect: %v", err)
}
if childRan {
t.Fatal("a child guard ran after the parent refused")
}
if got := mustRecord(t, CurrentComponent()).name; got != "login" {
t.Fatalf("current component = %q, want login", got)
}
}
TestResultGuardRedirectsToLogin
Parameters
func TestResultGuardRedirectsToLogin(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/login", Component: func() core.Component { return &recordComponent{name: "login"} }})
created := guardedRoute(t, "/account", []ResultGuard{func(map[string]string) GuardResult {
return ReplaceWith("/login")
}})
if err := NavigateContext(context.Background(), "/account"); err != nil {
t.Fatalf("guard redirect: %v", err)
}
if *created != 0 {
t.Fatalf("protected component was created %d times", *created)
}
if got := mustRecord(t, CurrentComponent()).name; got != "login" {
t.Fatalf("current component = %q, want login", got)
}
if ActivePath().Get() != "/login" {
t.Fatalf("active path = %q, want /login", ActivePath().Get())
}
if Status().Get() != NavigationReady {
t.Fatalf("navigation status = %s, want ready", Status().Get())
}
}
TestResultGuardRedirectKeepsRouteParameters
Parameters
func TestResultGuardRedirectKeepsRouteParameters(t *testing.T)
{
resetRouter(t)
var seen map[string]string
RegisterRoute(Route{Path: "/denied", Component: func() core.Component { return &recordComponent{name: "denied"} }})
RegisterRoute(Route{
Path: "/orders/:id",
Component: func() core.Component { return &recordComponent{name: "order"} },
ResultGuards: []ResultGuard{func(params map[string]string) GuardResult {
seen = params
return RedirectTo("/denied")
}},
})
if err := NavigateContext(context.Background(), "/orders/17?ref=mail"); err != nil {
t.Fatalf("guard redirect: %v", err)
}
if seen["id"] != "17" || seen["ref"] != "mail" {
t.Fatalf("guard parameters = %#v", seen)
}
if got := mustRecord(t, CurrentComponent()).name; got != "denied" {
t.Fatalf("current component = %q, want denied", got)
}
}
TestInvalidGuardResultFailsClosed
Parameters
func TestInvalidGuardResultFailsClosed(t *testing.T)
{
cases := map[string]GuardResult{
"empty": RedirectTo(""),
"blank": ReplaceWith(" "),
"relative": RedirectTo("login"),
"scheme relative": RedirectTo("//evil.example/login"),
"unknown action": {Action: GuardAction(42), Path: "/login"},
}
for name, result := range cases {
t.Run(name, func(t *testing.T) {
resetRouter(t)
decision := result
created := guardedRoute(t, "/secret", []ResultGuard{func(map[string]string) GuardResult {
return decision
}})
err := NavigateContext(context.Background(), "/secret")
if !errors.Is(err, ErrInvalidGuardResult) {
t.Fatalf("expected an invalid guard result, got %v", err)
}
if *created != 0 || CurrentComponent() != nil {
t.Fatalf("invalid guard result committed a route: created=%d component=%#v", *created, CurrentComponent())
}
})
}
}
TestResultGuardRedirectLoopFails
Parameters
func TestResultGuardRedirectLoopFails(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{
Path: "/ping",
Component: func() core.Component { return &recordComponent{name: "ping"} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return RedirectTo("/pong") }},
})
RegisterRoute(Route{
Path: "/pong",
Component: func() core.Component { return &recordComponent{name: "pong"} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return ReplaceWith("/ping") }},
})
if err := NavigateContext(context.Background(), "/ping"); !errors.Is(err, ErrRedirectLoop) {
t.Fatalf("expected a redirect loop error, got %v", err)
}
if CurrentComponent() != nil {
t.Fatalf("redirect loop committed %#v", CurrentComponent())
}
}
TestLegacyGuardStillBlocksBeforeResultGuards
Legacy bool guards keep their contract: a false guard blocks with
ErrNavigationBlocked and never reaches the typed guards behind it.
Parameters
func TestLegacyGuardStillBlocksBeforeResultGuards(t *testing.T)
{
resetRouter(t)
resultRan := false
RegisterRoute(Route{Path: "/", Component: func() core.Component { return &recordComponent{name: "root"} }})
RegisterRoute(Route{
Path: "/legacy",
Component: func() core.Component { return &recordComponent{name: "legacy"} },
Guards: []Guard{func(map[string]string) bool { return false }},
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
resultRan = true
return Allow()
}},
})
if err := NavigateContext(context.Background(), "/legacy"); !errors.Is(err, ErrNavigationBlocked) {
t.Fatalf("expected a blocked navigation, got %v", err)
}
if resultRan {
t.Fatal("a result guard ran after a legacy guard blocked")
}
}
TestAllowingResultGuardCommitsRoute
A guard that allows leaves the route untouched: loader, component and route
state commit exactly as they do without guards.
Parameters
func TestAllowingResultGuardCommitsRoute(t *testing.T)
{
resetRouter(t)
loaded := false
RegisterRoute(Route{
Path: "/open",
Component: func() core.Component { return &recordComponent{name: "open"} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return Allow() }},
Loader: func(context.Context, LoadContext) (any, error) {
loaded = true
return nil, nil
},
})
if err := NavigateContext(context.Background(), "/open"); err != nil {
t.Fatalf("navigate: %v", err)
}
if !loaded {
t.Fatal("an allowed route did not run its loader")
}
if got := mustRecord(t, CurrentComponent()).name; got != "open" {
t.Fatalf("current component = %q, want open", got)
}
}
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)
}
}
restoreHistoryPath
restoreHistoryPath puts the page back on the path the test found, so the
suite that follows starts where it expects to.
Parameters
func restoreHistoryPath(t *testing.T)
{
t.Helper()
original := js.Location().Get("pathname").String() +
js.Location().Get("search").String() +
js.Location().Get("hash").String()
t.Cleanup(func() {
js.History().Call("replaceState", nil, "", original)
Reset()
})
}
travelHistory
travelHistory triggers a browser history move and waits for the popstate
that carries the new URL.
Parameters
Returns
func travelHistory(t *testing.T, direction string) string
{
t.Helper()
popstate, stop := events.Listen("popstate", js.Window())
defer stop()
js.History().Call(direction)
select {
case <-popstate:
case <-time.After(2 * time.Second):
t.Fatalf("history %s did not fire popstate", direction)
}
return js.Location().Get("pathname").String()
}
TestGuardReplaceLeavesNoHistoryEntry
A login redirect replaces the entry it was serving, so the protected path
leaves no history entry to go back to.
Parameters
func TestGuardReplaceLeavesNoHistoryEntry(t *testing.T)
{
Reset()
restoreHistoryPath(t)
RegisterRoute(Route{Path: "/guard-base", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/guard-entry", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/guard-login", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{
Path: "/guard-account",
Component: func() core.Component { return routeComponent{} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return ReplaceWith("/guard-login") }},
})
Navigate("/guard-base")
Navigate("/guard-entry")
before := js.History().Get("length").Int()
Navigate("/guard-account")
if got := js.Location().Get("pathname").String(); got != "/guard-login" {
t.Fatalf("path after the login redirect = %q", got)
}
if got := js.History().Get("length").Int(); got != before {
t.Fatalf("history length changed from %d to %d", before, got)
}
if got := travelHistory(t, "back"); got != "/guard-base" {
t.Fatalf("back landed on %q, want /guard-base", got)
}
if got := travelHistory(t, "forward"); got != "/guard-login" {
t.Fatalf("forward landed on %q, want /guard-login", got)
}
}
TestGuardRedirectPushesHistoryEntry
A push redirect keeps the entry it came from, so back returns to it.
Parameters
func TestGuardRedirectPushesHistoryEntry(t *testing.T)
{
Reset()
restoreHistoryPath(t)
RegisterRoute(Route{Path: "/guard-origin", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/guard-offers", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{
Path: "/guard-promo",
Component: func() core.Component { return routeComponent{} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return RedirectTo("/guard-offers") }},
})
Navigate("/guard-origin")
before := js.History().Get("length").Int()
Navigate("/guard-promo")
if got := js.Location().Get("pathname").String(); got != "/guard-offers" {
t.Fatalf("path after the guard redirect = %q", got)
}
if got := js.History().Get("length").Int(); got != before+1 {
t.Fatalf("history length = %d, want %d", got, before+1)
}
if got := travelHistory(t, "back"); got != "/guard-origin" {
t.Fatalf("back landed on %q, want /guard-origin", got)
}
if got := travelHistory(t, "forward"); got != "/guard-offers" {
t.Fatalf("forward landed on %q, want /guard-offers", got)
}
}
TestGuardRedirectOnHistoryNoneReplacesEntry
A push redirect served on an initial load or a popstate replaces the denied
entry instead: back and forward must not run through the refused path.
Parameters
func TestGuardRedirectOnHistoryNoneReplacesEntry(t *testing.T)
{
Reset()
restoreHistoryPath(t)
RegisterRoute(Route{Path: "/none-origin", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/none-offers", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{
Path: "/none-promo",
Component: func() core.Component { return routeComponent{} },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return RedirectTo("/none-offers") }},
})
Navigate("/none-origin")
js.History().Call("pushState", nil, "", "/none-promo")
before := js.History().Get("length").Int()
navigate("/none-promo", historyNone)
if got := js.Location().Get("pathname").String(); got != "/none-offers" {
t.Fatalf("path after the guard redirect = %q", got)
}
if got := js.History().Get("length").Int(); got != before {
t.Fatalf("history length changed from %d to %d", before, got)
}
if got := travelHistory(t, "back"); got != "/none-origin" {
t.Fatalf("back landed on %q, want /none-origin", got)
}
if got := travelHistory(t, "forward"); got != "/none-offers" {
t.Fatalf("forward landed on %q, want /none-offers", got)
}
}
TestGuardReplaceOnDeepLink
Landing directly on a protected path, the way a deep link does, ends on the
login route without committing the protected one.
Parameters
func TestGuardReplaceOnDeepLink(t *testing.T)
{
Reset()
restoreHistoryPath(t)
created := 0
RegisterRoute(Route{Path: "/deep-login", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{
Path: "/deep-account",
Component: func() core.Component {
created++
return routeComponent{}
},
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return ReplaceWith("/deep-login") }},
})
js.History().Call("pushState", nil, "", "/deep-account")
navigate("/deep-account", historyNone)
if got := js.Location().Get("pathname").String(); got != "/deep-login" {
t.Fatalf("deep link path = %q, want /deep-login", got)
}
if created != 0 {
t.Fatalf("protected component was created %d times", created)
}
}
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)
}
}
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())
}
}
TestRevalidateHostReplaceReportsHandoff
Outside the browser there is no document to hand to the host, so a
revalidation that hands off reports the target and leaves the mounted route
exactly where it is, the contract NavigateContext already keeps.
Parameters
func TestRevalidateHostReplaceReportsHandoff(t *testing.T)
{
resetRouter(t)
valid := true
session := registerSessionRoute(t, "/reval-host", func(map[string]string) GuardResult {
if !valid {
return HostReplace("/login?next=%2Freval-host")
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-host"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
created, loaded := session.created, session.loaded
valid = false
err := RevalidateContext(context.Background())
if got := hostNavigation(t, err); got != "/login?next=%2Freval-host" {
t.Fatalf("host path = %q", got)
}
if page.unmounted != 0 || page.cleanups != 0 {
t.Fatalf("the handoff tore the mounted page down: unmounted=%d cleanups=%d", page.unmounted, page.cleanups)
}
if CurrentComponent() != page {
t.Fatalf("current component changed to %#v", CurrentComponent())
}
if session.created != created || session.loaded != loaded {
t.Fatalf("the handoff re-ran the route: created=%d loaded=%d", session.created, session.loaded)
}
if got := ActivePath().Get(); got != "/reval-host" {
t.Fatalf("active path = %q", got)
}
if Data().Get() != "payload" || Meta().Get()["section"] != "session" {
t.Fatalf("the handoff changed route state: data=%#v meta=%#v", Data().Get(), Meta().Get())
}
if Status().Get() != NavigationError || !errors.Is(Error().Get(), ErrHostNavigation) {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
}
TestRevalidateInvalidGuardResultFailsClosed
A guard result validation refuses is a refusal like any other: the route the
user is on is mounted, so it fails closed and is torn down rather than left
in place on a decision the router could not apply.
Parameters
func TestRevalidateInvalidGuardResultFailsClosed(t *testing.T)
{
for name, decision := range map[string]GuardResult{
"scheme relative": RedirectTo("//evil.example/login"),
"relative": ReplaceWith("login"),
"off origin host": HostReplace("https://evil.example/login"),
"unknown action": {Action: GuardAction(42), Path: "/login"},
} {
t.Run(name, func(t *testing.T) {
resetRouter(t)
result := decision
valid := true
registerSessionRoute(t, "/reval-invalid", func(map[string]string) GuardResult {
if !valid {
return result
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-invalid"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
valid = false
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrInvalidGuardResult) {
t.Fatalf("expected an invalid guard result, got %v", err)
}
if page.unmounted != 1 || page.cleanups != 1 {
t.Fatalf("teardown ran %d times (cleanups %d), want 1", page.unmounted, page.cleanups)
}
if CurrentComponent() != nil {
t.Fatalf("the route stayed current: %#v", CurrentComponent())
}
// A refusal the router could not even apply revokes like any other.
if Data().Get() != nil || len(Meta().Get()) != 0 {
t.Fatalf("the revoked route's state survived: data=%#v meta=%#v", Data().Get(), Meta().Get())
}
})
}
}
hostReplaceCase
hostReplaceCase names a host handoff target under test.
type hostReplaceCase struct
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| target | string |
sessionGuardedRoute
sessionGuardedRoute registers /composed behind the guard an authenticated
application writes: no session hands the browser to the host login page, a
session without the permission is refused inside the SPA, and anything else
enters normally.
Parameters
Returns
func sessionGuardedRoute(t *testing.T, authenticated, permitted *bool) *int
{
t.Helper()
created := 0
RegisterRoute(Route{
Path: "/composed",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
switch {
case !*authenticated:
return HostReplace("/login")
case !*permitted:
return Forbid()
default:
return Allow()
}
}},
Component: func() core.Component {
created++
return &recordComponent{name: "composed"}
},
})
return &created
}
TestHostReplaceTargetRejectsOffOriginTargets
Parameters
func TestHostReplaceTargetRejectsOffOriginTargets(t *testing.T)
{
for _, tc := range hostReplaceRejects {
t.Run(tc.name, func(t *testing.T) {
destination, err := guardHostReplaceTarget(tc.target)
if !errors.Is(err, ErrInvalidGuardResult) {
t.Fatalf("guardHostReplaceTarget(%q) = %q, %v", tc.target, destination, err)
}
if destination != "" {
t.Fatalf("rejected target returned a destination: %q", destination)
}
})
}
}
TestHostReplaceTargetAcceptsRootedPaths
Parameters
func TestHostReplaceTargetAcceptsRootedPaths(t *testing.T)
{
for _, target := range hostReplaceAccepts {
t.Run(target, func(t *testing.T) {
destination, err := guardHostReplaceTarget(target)
if err != nil {
t.Fatalf("guardHostReplaceTarget(%q): %v", target, err)
}
if destination != target {
t.Fatalf("destination = %q, want %q", destination, target)
}
})
}
}
TestGuardRedirectTargetStillRejectsOffOriginTargets
The SPA redirects keep their own validation: a host handoff loosens nothing
for RedirectTo and ReplaceWith.
Parameters
func TestGuardRedirectTargetStillRejectsOffOriginTargets(t *testing.T)
{
for _, target := range []string{"", " ", "login", "//evil.example", `/\evil.example`} {
if _, err := guardRedirectTarget(target); !errors.Is(err, ErrInvalidGuardResult) {
t.Fatalf("guardRedirectTarget(%q) = %v", target, err)
}
}
}
TestSessionGuardAllowsAndForbidsInsideSPA
Allow and Forbid are unchanged by the new action: an allowed navigation
enters the route, and a refused one stays inside the SPA with
ErrNavigationForbidden instead of leaving for the host.
Parameters
func TestSessionGuardAllowsAndForbidsInsideSPA(t *testing.T)
{
resetRouter(t)
authenticated, permitted := true, true
created := sessionGuardedRoute(t, &authenticated, &permitted)
if err := NavigateContext(context.Background(), "/composed"); err != nil {
t.Fatalf("navigate: %v", err)
}
if got := mustRecord(t, CurrentComponent()).name; got != "composed" {
t.Fatalf("current component = %q, want composed", got)
}
if *created != 1 {
t.Fatalf("component created %d times, want 1", *created)
}
permitted = false
err := NavigateContext(context.Background(), "/composed")
if !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden navigation, got %v", err)
}
if errors.Is(err, ErrHostNavigation) {
t.Fatal("an authenticated caller without the permission was sent to the host")
}
if *created != 1 {
t.Fatalf("forbidden navigation created the component again: %d", *created)
}
}
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")
}
}
routeUnmounter
routeUnmounter is a routed component that owns lifecycle cleanup. The
Component interface outside browser builds is render-only, so a revoked route
is unmounted through this one when the component provides it.
type routeUnmounter interface
Methods
func Unmount(...)
Revalidate
Revalidate re-runs the guards of the route that is currently mounted against
the committed path, and applies their decision to it. It is what an
application calls after the authority a guard reads changes under a page the
user is already on: install the new snapshot first, then revalidate, and the
guards stay the single declarative place where access is decided.
A route its guards still allow is left untouched. One they now refuse is torn
down like the navigation that would have been refused: the routed component
is unmounted and dropped. A guard that redirects navigates to its
destination. With no routed component mounted, or none for the committed
path, nothing happens, so calling it again after a refusal is a no-op.
It must not be called from a guard: a guard returns a decision, and the
router is what acts on it.
func Revalidate()
{
_ = RevalidateContext(context.Background())
}
RevalidateContext
RevalidateContext revalidates the mounted route like Revalidate and returns
the outcome: nil when the route may stay, when a redirect committed, or when
there was nothing to revalidate, and otherwise the error the guards decided
on (ErrNavigationForbidden, ErrNavigationBlocked, ErrInvalidGuardResult), the
*HostNavigationError of a host handoff, or the context error.
Parameters
Returns
func RevalidateContext(parent context.Context) error
{
// A caller with no context to give passes none, the way Revalidate does.
// Normalized here, at the one entry point: a redirect derives its context
// from this parent, and deriving from a nil one panics.
if parent == nil {
parent = context.Background()
}
// Nothing mounted is nothing to revalidate: a navigation that has not
// committed yet still has its own guards ahead of it, and a refusal already
// cleared the owner it dropped.
if currentComponent == nil {
return nil
}
r, guards, params := matchCurrentRoute(activePathSig.Get())
if r == nil {
return nil
}
// The match only speaks for the route that actually committed what is
// mounted. It does not when a route is registered for the path the
// not-found component is sitting on: those guards never ran to put anything
// there, so they have no say over a component that is not theirs.
if r.component != currentComponent {
return nil
}
// Taking the navigation generation cancels whatever is in flight: it was
// authorized under the snapshot this call replaces, so its loader must not
// come back and commit over the decision made here.
ctx, _ := beginNavigation(parent)
if err := ctx.Err(); err != nil {
return err
}
guardResult, guardErr := runGuards(guards, params)
if guardErr != nil {
// Every refusal fails closed the same way, a legacy guard that blocks
// included: the route is mounted, so leaving it there is the one outcome
// a refusal cannot have. Navigation to a refused destination keeps its
// own behavior, where nothing was committed to take back.
revokeMountedRoute(guardErr)
return guardErr
}
switch guardResult.Action {
case GuardAllow:
settleRevalidatedRoute()
return nil
case GuardHostReplace:
// There is no document to replace here and the host page is not a route
// this router could load in its place, so the handoff is reported with
// its target and the mounted route is left alone, exactly as
// NavigateContext reports it.
hostErr := &HostNavigationError{Path: guardResult.Path}
failNavigation(hostErr)
return hostErr
}
redirectCtx, err := nextRedirectContext(parent)
if err != nil {
failNavigation(err)
return err
}
// Taken before the redirect, released after it commits: the destination is
// what decides whether this route is still the one the user owns.
owner := currentComponent
// The stub keeps no browser history, so a push and a replace redirect both
// navigate, as they do for a refused destination.
if err := NavigateContext(redirectCtx, guardResult.Path); err != nil {
// The destination refused, failed to load or was cancelled: nothing
// committed over the route the user is on, so it stays current and keeps
// everything it holds.
return err
}
releaseReplacedOwner(owner)
return nil
}
releaseReplacedOwner
releaseReplacedOwner runs the lifecycle of the routed owner a committed
redirect left behind. Navigation outside the browser has no DOM to tear down
and only moves the pointer to the routed owner, so the one it replaced is
released here, exactly once, where the browser gets the same teardown from
navigateImpl. An owner the destination resolved back to is still current and
is left running.
Parameters
func releaseReplacedOwner(owner core.Component)
{
if owner == nil || owner == currentComponent {
return
}
tearDownRoutedOwner(owner)
}
revokeMountedRoute
revokeMountedRoute drops the routed owner its guards just refused, and what
it loaded with it, so nothing reads its data or its metadata after the
refusal. The committed path is kept, so a repeated revalidation finds nothing
mounted and does nothing, while an explicit navigation can enter the route
again once its guards allow it.
Parameters
func revokeMountedRoute(err error)
{
owner := currentComponent
currentComponent = nil
tearDownRoutedOwner(owner)
revokeCommittedRoute(err)
}
tearDownRoutedOwner
tearDownRoutedOwner runs the lifecycle of a routed owner that is no longer
current, exactly once.
Parameters
func tearDownRoutedOwner(owner core.Component)
{
core.TriggerUnmount(owner)
if unmounter, ok := owner.(routeUnmounter); ok {
unmounter.Unmount()
}
}
TestHostReplaceReportsHandoffWithoutCommitting
Outside the browser there is no document to replace, so the router reports
the handoff with its target instead of pretending the host page loaded.
Parameters
func TestHostReplaceReportsHandoffWithoutCommitting(t *testing.T)
{
resetRouter(t)
RegisterRoute(Route{Path: "/", Component: func() core.Component { return &recordComponent{name: "root"} }})
Navigate("/")
created := 0
RegisterRoute(Route{
Path: "/admin",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
return HostReplace("/login?next=%2Fadmin")
}},
Component: func() core.Component {
created++
return &recordComponent{name: "admin"}
},
Loader: func(context.Context, LoadContext) (any, error) {
t.Error("the protected route ran its loader before the host handoff")
return nil, nil
},
Meta: map[string]any{"section": "admin"},
})
err := NavigateContext(context.Background(), "/admin")
if got := hostNavigation(t, err); got != "/login?next=%2Fadmin" {
t.Fatalf("host path = %q, want /login?next=%%2Fadmin", got)
}
if created != 0 {
t.Fatalf("the protected component was created %d times", created)
}
if got := mustRecord(t, CurrentComponent()).name; got != "root" {
t.Fatalf("current component = %q, want the mounted root", got)
}
if got := ActivePath().Get(); got != "/" {
t.Fatalf("active path = %q, want /", got)
}
if Data().Get() != nil {
t.Fatalf("the handoff committed route data: %#v", Data().Get())
}
if section, ok := Meta().Get()["section"]; ok {
t.Fatalf("the handoff committed route metadata: %v", section)
}
if Status().Get() != NavigationError || !errors.Is(Error().Get(), ErrHostNavigation) {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
}
TestHostReplaceReportsAcceptedTargets
Parameters
func TestHostReplaceReportsAcceptedTargets(t *testing.T)
{
for _, target := range hostReplaceAccepts {
t.Run(target, func(t *testing.T) {
resetRouter(t)
destination := target
created := guardedRoute(t, "/host-accepted", []ResultGuard{func(map[string]string) GuardResult {
return HostReplace(destination)
}})
err := NavigateContext(context.Background(), "/host-accepted")
if got := hostNavigation(t, err); got != target {
t.Fatalf("host path = %q, want %q", got, target)
}
if *created != 0 || CurrentComponent() != nil {
t.Fatalf("the handoff committed a route: created=%d component=%#v", *created, CurrentComponent())
}
})
}
}
TestHostReplaceRejectedTargetsFailClosed
A target validation refuses fails closed with ErrInvalidGuardResult and never
becomes a host handoff.
Parameters
func TestHostReplaceRejectedTargetsFailClosed(t *testing.T)
{
for _, tc := range hostReplaceRejects {
t.Run(tc.name, func(t *testing.T) {
resetRouter(t)
destination := tc.target
created := guardedRoute(t, "/host-rejected", []ResultGuard{func(map[string]string) GuardResult {
return HostReplace(destination)
}})
err := NavigateContext(context.Background(), "/host-rejected")
if !errors.Is(err, ErrInvalidGuardResult) {
t.Fatalf("expected an invalid guard result, got %v", err)
}
if errors.Is(err, ErrHostNavigation) {
t.Fatalf("a rejected target became a host handoff: %v", err)
}
if *created != 0 || CurrentComponent() != nil {
t.Fatalf("a rejected target committed a route: created=%d component=%#v", *created, CurrentComponent())
}
})
}
}
TestParentHostReplaceStopsChildGuardsOnStub
A parent guard that hands off stops the chain: no child guard, no child
loader, no component.
Parameters
func TestParentHostReplaceStopsChildGuardsOnStub(t *testing.T)
{
resetRouter(t)
childRan, created := false, 0
RegisterRoute(Route{
Path: "/host-parent",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return HostReplace("/login") }},
Children: []Route{{
Path: "users",
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
childRan = true
return Allow()
}},
Component: func() core.Component {
created++
return &recordComponent{name: "users"}
},
Loader: func(context.Context, LoadContext) (any, error) {
t.Error("a child loader ran after the parent handed off")
return nil, nil
},
}},
})
err := NavigateContext(context.Background(), "/host-parent/users")
if got := hostNavigation(t, err); got != "/login" {
t.Fatalf("host path = %q, want /login", got)
}
if childRan {
t.Fatal("a child guard ran after the parent handed off")
}
if created != 0 {
t.Fatalf("the child component was created %d times", created)
}
}
TestSessionGuardReportsHandoffForUnauthenticated
An unauthenticated caller is handed to the host login page by the same guard
that keeps a permission refusal inside the SPA.
Parameters
func TestSessionGuardReportsHandoffForUnauthenticated(t *testing.T)
{
resetRouter(t)
authenticated, permitted := false, true
created := sessionGuardedRoute(t, &authenticated, &permitted)
err := NavigateContext(context.Background(), "/composed")
if got := hostNavigation(t, err); got != "/login" {
t.Fatalf("host path = %q, want /login", got)
}
if *created != 0 || CurrentComponent() != nil {
t.Fatalf("the handoff committed a route: created=%d component=%#v", *created, CurrentComponent())
}
}
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)
}
}
revalidatePage
revalidatePage is a routed component that counts the lifecycle a revocation
has to run exactly once, and owns a scope the way a page with running work
does.
type revalidatePage struct
Methods
func (*revalidatePage) Mount()
{}
func (*revalidatePage) OnMount()
{}
func (*revalidatePage) OnUnmount()
{}
func (*revalidatePage) Unmount()
{
p.unmounted++
p.scope.Close()
}
Parameters
func (*revalidatePage) OnParams(map[string]string)
{ p.paramRuns++ }
Fields
| Name | Type | Description |
|---|---|---|
| name | string | |
| scope | *core.Scope | |
| unmounted | int | |
| paramRuns | int | |
| cleanups | int |
newRevalidatePage
Parameters
Returns
func newRevalidatePage(name string) *revalidatePage
{
page := &revalidatePage{name: name}
page.scope = core.NewScope()
page.scope.Defer(func() { page.cleanups++ })
return page
}
mustRevalidatePage
Parameters
Returns
func mustRevalidatePage(t *testing.T, c core.Component) *revalidatePage
{
t.Helper()
page, ok := c.(*revalidatePage)
if !ok {
t.Fatalf("expected *revalidatePage, got %T", c)
}
return page
}
sessionRoute
sessionRoute registers a route whose guard reads a decision the test changes
between navigations, the shape of an application that installs a new
authorization snapshot and then revalidates. It counts every guard run, every
component creation and every loader run.
type sessionRoute struct
Fields
| Name | Type | Description |
|---|---|---|
| decide | func(map[string]string) GuardResult | |
| guards | int | |
| created | int | |
| loaded | int |
registerSessionRoute
Parameters
Returns
func registerSessionRoute(t *testing.T, path string, decide func(map[string]string) GuardResult) *sessionRoute
{
t.Helper()
sr := &sessionRoute{decide: decide}
RegisterRoute(Route{
Path: path,
ResultGuards: []ResultGuard{func(params map[string]string) GuardResult {
sr.guards++
return sr.decide(params)
}},
Component: func() core.Component {
sr.created++
return newRevalidatePage("session")
},
Loader: func(context.Context, LoadContext) (any, error) {
sr.loaded++
return "payload", nil
},
Meta: map[string]any{"section": "session"},
})
return sr
}
Uses
TestRevalidateAllowedRouteIsANoOp
A route its guards still allow is left exactly as it is: the guards run
again, and nothing else does.
Parameters
func TestRevalidateAllowedRouteIsANoOp(t *testing.T)
{
resetRouter(t)
allowed := true
session := registerSessionRoute(t, "/reval-open", func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-open?tab=usage"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
guards, created, loaded, paramRuns := session.guards, session.created, session.loaded, page.paramRuns
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if session.guards != guards+1 {
t.Fatalf("guard ran %d times, want %d", session.guards, guards+1)
}
if session.created != created || session.loaded != loaded {
t.Fatalf("revalidation re-ran the route: created=%d loaded=%d", session.created, session.loaded)
}
if page.paramRuns != paramRuns {
t.Fatalf("revalidation re-ran OnParams %d times, want %d", page.paramRuns, paramRuns)
}
if page.unmounted != 0 || page.cleanups != 0 {
t.Fatalf("revalidation tore the page down: unmounted=%d cleanups=%d", page.unmounted, page.cleanups)
}
if CurrentComponent() != core.Component(page) {
t.Fatalf("current component changed to %#v", CurrentComponent())
}
if got := ActivePath().Get(); got != "/reval-open?tab=usage" {
t.Fatalf("active path = %q", got)
}
if Status().Get() != NavigationReady || Error().Get() != nil {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
if Data().Get() != "payload" {
t.Fatalf("route data = %#v, want payload", Data().Get())
}
if Meta().Get()["section"] != "session" {
t.Fatalf("route metadata = %#v", Meta().Get())
}
}
TestRevalidateForbidTearsDownTheOwnerOnce
A guard that refuses a route the user is already on unmounts it once, and
keeps the committed path so a later navigation can retry it.
Parameters
func TestRevalidateForbidTearsDownTheOwnerOnce(t *testing.T)
{
resetRouter(t)
allowed := true
session := registerSessionRoute(t, "/reval-vault", func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-vault"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
if Data().Get() != "payload" || Meta().Get()["section"] != "session" {
t.Fatalf("the route committed no state to revoke: data=%#v meta=%#v", Data().Get(), Meta().Get())
}
allowed = false
err := RevalidateContext(context.Background())
if !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden revalidation, got %v", err)
}
// The refused route's loader data and metadata went with it: they described a
// page the guards no longer allow.
if Data().Get() != nil {
t.Fatalf("the revoked route's data is still readable: %#v", Data().Get())
}
if meta := Meta().Get(); len(meta) != 0 {
t.Fatalf("the revoked route's metadata is still readable: %#v", meta)
}
if page.unmounted != 1 {
t.Fatalf("page unmounted %d times, want 1", page.unmounted)
}
if page.cleanups != 1 {
t.Fatalf("scope cleanup ran %d times, want 1", page.cleanups)
}
if CurrentComponent() != nil {
t.Fatalf("the revoked route stayed current: %#v", CurrentComponent())
}
if Status().Get() != NavigationError || !errors.Is(Error().Get(), ErrNavigationForbidden) {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
if got := ActivePath().Get(); got != "/reval-vault" {
t.Fatalf("active path = %q, want the committed path", got)
}
guards := session.guards
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("repeated revalidation: %v", err)
}
if session.guards != guards {
t.Fatalf("a repeated revalidation re-ran the guards: %d, want %d", session.guards, guards)
}
if page.unmounted != 1 || page.cleanups != 1 {
t.Fatalf("a repeated revalidation tore the page down again: unmounted=%d cleanups=%d", page.unmounted, page.cleanups)
}
allowed = true
if err := NavigateContext(context.Background(), "/reval-vault"); err != nil {
t.Fatalf("navigating back into the route: %v", err)
}
if mustRevalidatePage(t, CurrentComponent()) == page {
t.Fatal("the revoked page was remounted instead of a fresh one")
}
}
TestRevalidateRedirectRoutesToTheDestination
A guard that redirects during revalidation routes to its destination the way
navigation does, for both redirect actions.
Parameters
func TestRevalidateRedirectRoutesToTheDestination(t *testing.T)
{
for name, decision := range map[string]GuardResult{
"redirect": RedirectTo("/reval-login"),
"replace": ReplaceWith("/reval-login"),
} {
t.Run(name, func(t *testing.T) {
resetRouter(t)
result := decision
allowed := true
RegisterRoute(Route{Path: "/reval-login", Component: func() core.Component { return newRevalidatePage("login") }})
session := registerSessionRoute(t, "/reval-account", func(map[string]string) GuardResult {
if !allowed {
return result
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-account"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
created := session.created
allowed = false
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if got := mustRevalidatePage(t, CurrentComponent()).name; got != "login" {
t.Fatalf("current component = %q, want login", got)
}
// The redirect committed over the refused route, so the owner it
// replaced is released with it, once.
if page.unmounted != 1 || page.cleanups != 1 {
t.Fatalf("teardown ran %d times (cleanups %d), want 1", page.unmounted, page.cleanups)
}
if got := ActivePath().Get(); got != "/reval-login" {
t.Fatalf("active path = %q, want /reval-login", got)
}
if session.created != created {
t.Fatalf("the refused route was created again: %d", session.created)
}
if Status().Get() != NavigationReady {
t.Fatalf("navigation status = %s, want ready", Status().Get())
}
})
}
}
TestRevalidateFailedRedirectKeepsTheOwner
A redirect that never commits leaves the route the user is on exactly as it
is: the destination is what failed, so the owner still holds the page, its
scope and everything hanging off it, and a later revalidation can decide
again. Nothing may be released on the way to a destination that was refused.
Parameters
func TestRevalidateFailedRedirectKeepsTheOwner(t *testing.T)
{
for name, register := range map[string]func(){
"forbidden destination": func() {
RegisterRoute(Route{
Path: "/reval-dead-login",
Component: func() core.Component { return newRevalidatePage("login") },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return Forbid() }},
})
},
"failing loader": func() {
RegisterRoute(Route{
Path: "/reval-dead-login",
Component: func() core.Component { return newRevalidatePage("login") },
Loader: func(context.Context, LoadContext) (any, error) {
return nil, errors.New("login unavailable")
},
})
},
} {
t.Run(name, func(t *testing.T) {
resetRouter(t)
register()
allowed := true
registerSessionRoute(t, "/reval-dead", func(map[string]string) GuardResult {
if !allowed {
return ReplaceWith("/reval-dead-login")
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-dead"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
allowed = false
if err := RevalidateContext(context.Background()); err == nil {
t.Fatal("a redirect to a destination that never commits reported success")
}
if CurrentComponent() != core.Component(page) {
t.Fatalf("the owner was replaced by a redirect that failed: %#v", CurrentComponent())
}
if page.unmounted != 0 || page.cleanups != 0 {
t.Fatalf("a failed redirect tore the owner down: unmounted=%d cleanups=%d", page.unmounted, page.cleanups)
}
if got := ActivePath().Get(); got != "/reval-dead" {
t.Fatalf("active path = %q, want the committed path", got)
}
})
}
}
noContext
noContext returns the nil context a caller with none of its own passes. It
goes through a function so the “do not pass a nil Context” analysis does not
rewrite the very call under test.
Returns
func noContext() context.Context
{ return nil }
TestRevalidateNilContextRedirects
A caller that passes no context gets the one Revalidate builds for itself,
redirects included: a redirect derives its context from the parent, and
deriving from a nil one is what the boundary normalizes away.
Parameters
func TestRevalidateNilContextRedirects(t *testing.T)
{
resetRouter(t)
allowed := true
RegisterRoute(Route{Path: "/reval-nil-login", Component: func() core.Component { return newRevalidatePage("login") }})
registerSessionRoute(t, "/reval-nil", func(map[string]string) GuardResult {
if !allowed {
return ReplaceWith("/reval-nil-login")
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-nil"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
allowed = false
if err := RevalidateContext(noContext()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if got := mustRevalidatePage(t, CurrentComponent()).name; got != "login" {
t.Fatalf("current component = %q, want login", got)
}
if page.unmounted != 1 {
t.Fatalf("the refused page unmounted %d times, want 1", page.unmounted)
}
if got := ActivePath().Get(); got != "/reval-nil-login" {
t.Fatalf("active path = %q, want /reval-nil-login", got)
}
}
TestRevalidateRedirectLoopFails
Two guards redirecting at each other during revalidation hit the same
redirect-loop limit navigation does.
Parameters
func TestRevalidateRedirectLoopFails(t *testing.T)
{
resetRouter(t)
revalidating := false
RegisterRoute(Route{
Path: "/reval-ping",
Component: func() core.Component { return newRevalidatePage("ping") },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
if revalidating {
return RedirectTo("/reval-pong")
}
return Allow()
}},
})
RegisterRoute(Route{
Path: "/reval-pong",
Component: func() core.Component { return newRevalidatePage("pong") },
ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return ReplaceWith("/reval-ping") }},
})
if err := NavigateContext(context.Background(), "/reval-ping"); err != nil {
t.Fatalf("navigate: %v", err)
}
revalidating = true
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrRedirectLoop) {
t.Fatalf("expected a redirect loop error, got %v", err)
}
}
TestRevalidateWithoutAMountedRouteDoesNothing
With no routed component mounted there is nothing to revalidate.
Parameters
func TestRevalidateWithoutAMountedRouteDoesNothing(t *testing.T)
{
resetRouter(t)
session := registerSessionRoute(t, "/reval-idle", func(map[string]string) GuardResult { return Forbid() })
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if session.guards != 0 {
t.Fatalf("guards ran with nothing mounted: %d", session.guards)
}
if Status().Get() != NavigationIdle || Error().Get() != nil {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
}
TestRevalidateWithoutAMatchingRouteIsANoOp
A committed path that matches no route (the not-found component) has no
guards to re-run, so revalidation leaves it alone.
Parameters
func TestRevalidateWithoutAMatchingRouteIsANoOp(t *testing.T)
{
resetRouter(t)
session := registerSessionRoute(t, "/reval-present", func(map[string]string) GuardResult { return Forbid() })
NotFoundComponent = func() core.Component { return newRevalidatePage("not-found") }
if err := NavigateContext(context.Background(), "/reval-absent"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if session.guards != 0 {
t.Fatalf("guards of another route ran: %d", session.guards)
}
if CurrentComponent() != core.Component(page) || page.unmounted != 0 {
t.Fatalf("the not-found page was disturbed: current=%#v unmounted=%d", CurrentComponent(), page.unmounted)
}
}
TestRevalidateIgnoresARouteThatDoesNotOwnTheMountedComponent
A route registered for the path the not-found component is sitting on matches
it without owning what is mounted: its guards never ran to commit anything
there, so revalidation leaves the component alone instead of letting them
decide the fate of a page that is not theirs.
Parameters
func TestRevalidateIgnoresARouteThatDoesNotOwnTheMountedComponent(t *testing.T)
{
resetRouter(t)
NotFoundComponent = func() core.Component { return newRevalidatePage("not-found") }
if err := NavigateContext(context.Background(), "/reval-late"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
// The route arrives afterwards, the shape of a feature that registers its
// routes when it is installed rather than at startup.
session := registerSessionRoute(t, "/reval-late", func(map[string]string) GuardResult { return Forbid() })
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if session.guards != 0 {
t.Fatalf("the guards of a route that owns nothing ran %d times", session.guards)
}
if CurrentComponent() != core.Component(page) || page.unmounted != 0 {
t.Fatalf("the mounted component was disturbed: current=%#v unmounted=%d", CurrentComponent(), page.unmounted)
}
if got := ActivePath().Get(); got != "/reval-late" {
t.Fatalf("active path = %q, want the committed path", got)
}
}
TestRevalidateCancelledContextSkipsGuards
A cancelled context is answered before the guards run, and the mounted route
stays where it is.
Parameters
func TestRevalidateCancelledContextSkipsGuards(t *testing.T)
{
resetRouter(t)
allowed := true
session := registerSessionRoute(t, "/reval-cancelled", func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-cancelled"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
guards := session.guards
allowed = false
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := RevalidateContext(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected a cancelled revalidation, got %v", err)
}
if session.guards != guards {
t.Fatalf("guards ran for a cancelled revalidation: %d", session.guards)
}
if CurrentComponent() != core.Component(page) || page.unmounted != 0 {
t.Fatalf("a cancelled revalidation tore the page down: current=%#v unmounted=%d", CurrentComponent(), page.unmounted)
}
}
TestRevalidateCancelsAnInFlightLoader
A loader still running was authorized under the snapshot revalidation
replaces: it must not commit afterwards, whether the mounted route survives
the revalidation or not.
Parameters
func TestRevalidateCancelsAnInFlightLoader(t *testing.T)
{
resetRouter(t)
allowed := true
registerSessionRoute(t, "/reval-current", func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
entered, release := make(chan struct{}, 2), make(chan struct{})
pending := 0
RegisterRoute(Route{
Path: "/reval-pending",
Component: func() core.Component {
pending++
return newRevalidatePage("pending")
},
Loader: func(_ context.Context, _ LoadContext) (any, error) {
entered <- struct{}{}
<-release
return "late", nil
},
})
if err := NavigateContext(context.Background(), "/reval-current"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
// The route is still allowed: the in-flight navigation is cancelled and the
// mounted page stays.
navigation := make(chan error, 1)
go func() { navigation <- NavigateContext(context.Background(), "/reval-pending") }()
<-entered
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
release <- struct{}{}
if err := <-navigation; err == nil {
t.Fatal("the cancelled navigation reported success")
}
if CurrentComponent() != core.Component(page) {
t.Fatalf("a cancelled loader committed over the mounted route: %#v", CurrentComponent())
}
if pending != 0 {
t.Fatalf("the cancelled destination was created %d times", pending)
}
if Status().Get() != NavigationReady || Error().Get() != nil {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
if Data().Get() != "payload" {
t.Fatalf("route data = %#v, want the mounted route's", Data().Get())
}
// Now the guard refuses: the same cancelled loader must not resurrect the
// route the revalidation tore down.
go func() { navigation <- NavigateContext(context.Background(), "/reval-pending") }()
<-entered
allowed = false
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden revalidation, got %v", err)
}
close(release)
if err := <-navigation; err == nil {
t.Fatal("the cancelled navigation reported success")
}
if CurrentComponent() != nil {
t.Fatalf("a cancelled loader committed after the revocation: %#v", CurrentComponent())
}
if page.unmounted != 1 || page.cleanups != 1 {
t.Fatalf("teardown ran %d times (cleanups %d), want 1", page.unmounted, page.cleanups)
}
if pending != 0 {
t.Fatalf("the cancelled destination was created %d times", pending)
}
}
TestRevalidateIsIdempotentUnderRepetition
Revalidation is repeatable: an allowed route survives any number of them, and
a refused one is torn down exactly once however often it is asked again.
Parameters
func TestRevalidateIsIdempotentUnderRepetition(t *testing.T)
{
resetRouter(t)
allowed := true
session := registerSessionRoute(t, "/reval-stress", func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
if err := NavigateContext(context.Background(), "/reval-stress"); err != nil {
t.Fatalf("navigate: %v", err)
}
page := mustRevalidatePage(t, CurrentComponent())
const rounds = 50
for i := 0; i < rounds; i++ {
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidation %d: %v", i, err)
}
}
if session.guards != rounds+1 {
t.Fatalf("guard ran %d times, want %d", session.guards, rounds+1)
}
if session.created != 1 || session.loaded != 1 || page.unmounted != 0 {
t.Fatalf("repeated revalidation re-ran the route: created=%d loaded=%d unmounted=%d", session.created, session.loaded, page.unmounted)
}
allowed = false
refusals := 0
for i := 0; i < rounds; i++ {
if err := RevalidateContext(context.Background()); err != nil {
refusals++
}
}
if refusals != 1 {
t.Fatalf("%d revalidations refused, want 1", refusals)
}
if page.unmounted != 1 || page.cleanups != 1 {
t.Fatalf("teardown ran %d times (cleanups %d), want 1", page.unmounted, page.cleanups)
}
if CurrentComponent() != nil {
t.Fatalf("the revoked route stayed current: %#v", CurrentComponent())
}
}
mountRevalidateShell
mountRevalidateShell renders a shell with a router outlet into #app and
mounts page behind guard on path. page must carry the “reval” marker, which
is filled after mount the way a fetch callback fills a card: a remount or a
stray re-render would wipe it. It returns the shell so a caller can hold on
to the root MountRoot mounted.
Parameters
Returns
func mountRevalidateShell(t *testing.T, path string, page *hostGuardPage, guard ResultGuard) *core.HTMLComponent
{
t.Helper()
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
shell := core.NewHTMLComponent("RevalShell", []byte(`<root>@include:outlet</root>`), nil)
shell.SetComponent(shell)
shell.AddDependency("outlet", NewOutlet())
shell.Init(nil)
RegisterRoute(Route{
Path: path,
Component: page,
ResultGuards: []ResultGuard{guard},
Loader: func(context.Context, LoadContext) (any, error) { return "payload", nil },
Meta: map[string]any{"section": "session"},
})
MountRoot(shell)
Navigate(path)
dom.Query("[data-host-page='reval']").SetHTML(`<b id="reval-built">built</b>`)
if !strings.Contains(dom.ByID("app").HTML(), "reval-built") {
t.Fatal("the mounted page was not rendered")
}
return shell
}
Uses
mustComponentRoot
mustComponentRoot resolves a component’s own root node. dom.ComponentRoot
answers #app for an id it cannot resolve, so the id it carries is checked:
asserting on the fallback would compare the shell to the container around it.
Parameters
Returns
func mustComponentRoot(t *testing.T, id string) dom.Element
{
t.Helper()
root := dom.ComponentRoot(id)
if root.IsNull() || root.IsUndefined() || root.Attr("data-component-id") != id {
t.Fatalf("component %s has no root of its own: %s", id, dom.ByID("app").HTML())
}
return root
}
TestRevalidateClearsTheRoutedSubtreeAndKeepsTheShell
An allowed revalidation touches no DOM at all, and a refused one removes the
routed subtree while the shell and the outlet marker around it stay the very
nodes they were: the next navigation renders into the page the user is still
looking at.
Parameters
func TestRevalidateClearsTheRoutedSubtreeAndKeepsTheShell(t *testing.T)
{
Reset()
restoreHistoryPath(t)
allowed := true
page := newHostGuardPage("RevalPage", "reval")
shell := mountRevalidateShell(t, "/reval-dom", page, func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
if Data().Get() != "payload" || Meta().Get()["section"] != "session" {
t.Fatalf("the route committed no state to revoke: data=%#v meta=%#v", Data().Get(), Meta().Get())
}
appNode := dom.ByID("app")
shellNode := mustComponentRoot(t, shell.GetID())
outletNode := dom.Query("[data-router-outlet]")
historyLength := js.History().Get("length").Int()
location := js.Location().Get("pathname").String()
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if !strings.Contains(dom.ByID("app").HTML(), "reval-built") {
t.Fatalf("an allowed revalidation re-rendered the page: %s", dom.ByID("app").HTML())
}
if page.unmounted != 0 {
t.Fatalf("an allowed revalidation unmounted the page %d times", page.unmounted)
}
if got := mustComponentRoot(t, shell.GetID()); !got.Equal(shellNode.Value) {
t.Fatal("an allowed revalidation replaced the shell root")
}
allowed = false
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden revalidation, got %v", err)
}
if page.unmounted != 1 {
t.Fatalf("the page unmounted %d times, want 1", page.unmounted)
}
if CurrentComponent() != nil {
t.Fatalf("the revoked route stayed current: %#v", CurrentComponent())
}
if root := dom.Query("[data-host-page='reval']"); !root.IsNull() {
t.Fatalf("the protected subtree is still in the document: %s", dom.ByID("app").HTML())
}
if strings.Contains(dom.ByID("app").HTML(), "reval-built") {
t.Fatalf("the protected DOM survived the revocation: %s", dom.ByID("app").HTML())
}
if got := dom.ByID("app"); !got.Equal(appNode.Value) {
t.Fatal("the revocation replaced the application container")
}
if got := mustComponentRoot(t, shell.GetID()); !got.Equal(shellNode.Value) {
t.Fatal("the revocation replaced the shell root MountRoot mounted")
}
if !strings.Contains(shellNode.HTML(), "data-router-outlet") {
t.Fatalf("the revocation emptied the shell instead of the route: %s", shellNode.HTML())
}
after := dom.Query("[data-router-outlet]")
if after.IsNull() || !after.Equal(outletNode.Value) {
t.Fatal("the revocation replaced the outlet marker instead of emptying it")
}
if html := after.HTML(); html != "" {
t.Fatalf("the outlet still carries the routed subtree: %s", html)
}
if got := js.History().Get("length").Int(); got != historyLength {
t.Fatalf("history length changed from %d to %d", historyLength, got)
}
if got := js.Location().Get("pathname").String(); got != location {
t.Fatalf("the revocation moved the location to %q", got)
}
if got := ActivePath().Get(); got != "/reval-dom" {
t.Fatalf("active path = %q, want the committed path", got)
}
if Status().Get() != NavigationError || !errors.Is(Error().Get(), ErrNavigationForbidden) {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
// The DOM is not the only place the route left something behind: what it
// loaded goes with the subtree it was rendered into.
if Data().Get() != nil {
t.Fatalf("the revoked route's data is still readable: %#v", Data().Get())
}
if meta := Meta().Get(); len(meta) != 0 {
t.Fatalf("the revoked route's metadata is still readable: %#v", meta)
}
}
TestRevalidateWithoutAnOutletClearsOnlyTheRouteRoot
Without an outlet the router owns #app itself: the revocation removes the
route’s own root and leaves the container it was rendered into. #app is what
dom.ComponentRoot falls back to for an id it cannot resolve, and that
fallback must never be cleared in the route’s place.
Parameters
func TestRevalidateWithoutAnOutletClearsOnlyTheRouteRoot(t *testing.T)
{
Reset()
restoreHistoryPath(t)
previousOutlet := liveOutlet
liveOutlet = nil
t.Cleanup(func() { liveOutlet = previousOutlet })
if dom.ByID("app").IsNull() {
host := dom.CreateElement("div")
host.SetAttr("id", "app")
dom.Doc().Body().AppendChild(host)
}
allowed := true
page := newHostGuardPage("RevalBarePage", "bare")
RegisterRoute(Route{
Path: "/reval-bare",
Component: page,
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
}},
})
Navigate("/reval-bare")
if dom.Query("[data-host-page='bare']").IsNull() {
t.Fatalf("the page was not rendered: %s", dom.ByID("app").HTML())
}
appNode := dom.ByID("app")
allowed = false
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden revalidation, got %v", err)
}
if page.unmounted != 1 {
t.Fatalf("the page unmounted %d times, want 1", page.unmounted)
}
if root := dom.Query("[data-host-page='bare']"); !root.IsNull() {
t.Fatalf("the protected subtree is still in the document: %s", dom.ByID("app").HTML())
}
if got := dom.ByID("app"); got.IsNull() || !got.Equal(appNode.Value) {
t.Fatal("the revocation removed the application container instead of the route")
}
}
TestRevalidateHostReplaceHandsOffWithoutTearingDown
A guard that hands off during revalidation performs the browser navigation
and commits nothing on the way out: the page the user is on survives
untouched until the browser unloads it.
Parameters
func TestRevalidateHostReplaceHandsOffWithoutTearingDown(t *testing.T)
{
Reset()
restoreHistoryPath(t)
calls := captureHostReplace(t)
valid := true
page := newHostGuardPage("RevalHostPage", "reval")
mountRevalidateShell(t, "/reval-host-dom", page, func(map[string]string) GuardResult {
if !valid {
return HostReplace("/login?next=%2Freval-host-dom")
}
return Allow()
})
historyLength := js.History().Get("length").Int()
location := js.Location().Get("pathname").String()
valid = false
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("host handoff: %v", err)
}
if len(*calls) != 1 || (*calls)[0] != "/login?next=%2Freval-host-dom" {
t.Fatalf("host replace calls = %#v, want one handoff", *calls)
}
if page.unmounted != 0 {
t.Fatalf("the mounted page was unmounted %d times before the handoff", page.unmounted)
}
if CurrentComponent() != core.Component(page) {
t.Fatalf("current component changed to %#v", CurrentComponent())
}
if !strings.Contains(dom.ByID("app").HTML(), "reval-built") {
t.Fatalf("the handoff tore down the mounted page: %s", dom.ByID("app").HTML())
}
if got := js.History().Get("length").Int(); got != historyLength {
t.Fatalf("history length changed from %d to %d", historyLength, got)
}
if got := js.Location().Get("pathname").String(); got != location {
t.Fatalf("the SPA moved the location to %q", got)
}
if got := ActivePath().Get(); got != "/reval-host-dom" {
t.Fatalf("active path = %q", got)
}
if Status().Get() != NavigationReady || Error().Get() != nil {
t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
}
// Nothing was taken back, so the page still holds what it loaded until the
// browser unloads it.
if Data().Get() != "payload" || Meta().Get()["section"] != "session" {
t.Fatalf("the handoff changed route state: data=%#v meta=%#v", Data().Get(), Meta().Get())
}
}
TestRevalidateRedirectReplacesTheRefusedEntry
Both redirect actions replace the entry the guard refused: back returns to
the page before the protected one, never to the protected one itself.
Parameters
func TestRevalidateRedirectReplacesTheRefusedEntry(t *testing.T)
{
for name, decision := range map[string]GuardResult{
"redirect": RedirectTo("/reval-hist-login"),
"replace": ReplaceWith("/reval-hist-login"),
} {
t.Run(name, func(t *testing.T) {
Reset()
restoreHistoryPath(t)
result := decision
allowed := true
RegisterRoute(Route{Path: "/reval-hist-base", Component: func() core.Component { return routeComponent{} }})
RegisterRoute(Route{Path: "/reval-hist-login", Component: func() core.Component { return routeComponent{} }})
page := newHostGuardPage("RevalHistPage", "hist")
RegisterRoute(Route{
Path: "/reval-hist-account",
Component: page,
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
if !allowed {
return result
}
return Allow()
}},
})
Navigate("/reval-hist-base")
Navigate("/reval-hist-account")
before := js.History().Get("length").Int()
allowed = false
if err := RevalidateContext(context.Background()); err != nil {
t.Fatalf("revalidate: %v", err)
}
if got := js.Location().Get("pathname").String(); got != "/reval-hist-login" {
t.Fatalf("path after the redirect = %q", got)
}
if got := js.History().Get("length").Int(); got != before {
t.Fatalf("history length changed from %d to %d", before, got)
}
if page.unmounted != 1 {
t.Fatalf("the refused page unmounted %d times, want 1", page.unmounted)
}
if got := ActivePath().Get(); got != "/reval-hist-login" {
t.Fatalf("active path = %q", got)
}
if got := travelHistory(t, "back"); got != "/reval-hist-base" {
t.Fatalf("back landed on %q, want /reval-hist-base", got)
}
})
}
}
TestRevalidateRevocationSurvivesBackAndForward
The revocation leaves the refused path in the address bar, so the browser can
come back to it. It does, through the guards: a back and a forward cannot put
the protected page back on screen.
Parameters
func TestRevalidateRevocationSurvivesBackAndForward(t *testing.T)
{
Reset()
restoreHistoryPath(t)
allowed, guards := true, 0
RegisterRoute(Route{Path: "/reval-back-base", Component: func() core.Component { return routeComponent{} }})
page := newHostGuardPage("RevalBackPage", "back")
RegisterRoute(Route{
Path: "/reval-back-guarded",
Component: page,
ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
guards++
if !allowed {
return Forbid()
}
return Allow()
}},
})
Navigate("/reval-back-base")
Navigate("/reval-back-guarded")
allowed = false
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden revalidation, got %v", err)
}
if root := dom.Query("[data-host-page='back']"); !root.IsNull() {
t.Fatal("the protected subtree survived the revocation")
}
back := travelHistory(t, "back")
if back != "/reval-back-base" {
t.Fatalf("back landed on %q, want /reval-back-base", back)
}
// What the popstate listener InitRouter installs does with the new URL.
navigate(back, historyNone)
before := guards
forward := travelHistory(t, "forward")
if forward != "/reval-back-guarded" {
t.Fatalf("forward landed on %q, want /reval-back-guarded", forward)
}
navigate(forward, historyNone)
if guards <= before {
t.Fatal("forward restored the refused path without running its guards")
}
if root := dom.Query("[data-host-page='back']"); !root.IsNull() {
t.Fatalf("forward put the protected page back on screen: %s", dom.ByID("app").HTML())
}
if CurrentComponent() == core.Component(page) {
t.Fatal("forward remounted the revoked page")
}
if page.unmounted != 1 {
t.Fatalf("the page unmounted %d times, want 1", page.unmounted)
}
}
revalidateGate
revalidateGate stands in for the ownership gate a host registration hands to
the frames delivered under it: releasing the registration closes it.
type revalidateGate struct
Methods
Fields
| Name | Type | Description |
|---|---|---|
| closed | bool |
TestRevalidateReleasesOwnedHostWorkAndIgnoresLateFrames
The revocation runs the component’s own cleanup: the scope closes (the
disconnect a long-lived session such as a noVNC client hangs off it), the
owned host registration is released, and the signals go with it. A frame
still in flight for that component then finds no root, no signals, and a
closed gate.
Parameters
func TestRevalidateReleasesOwnedHostWorkAndIgnoresLateFrames(t *testing.T)
{
Reset()
restoreHistoryPath(t)
gate := &revalidateGate{}
releases := 0
core.SetHostRegistrar(func(string, string, []string) func() {
return func() {
releases++
gate.closed = true
}
})
t.Cleanup(func() { core.SetHostRegistrar(nil) })
allowed := true
page := newHostGuardPage("RevalOwnedPage", "reval")
// Declared before the mount, the way a page that talks to a host component
// declares it in its constructor: the mount is what opens the registration.
page.AddHostComponent("RevalHostComponent")
mountRevalidateShell(t, "/reval-host-owned", page, func(map[string]string) GuardResult {
if !allowed {
return Forbid()
}
return Allow()
})
disconnects := 0
page.Scope().Defer(func() { disconnects++ })
signal := state.NewSignal("connected")
dom.RegisterSignal(page.GetID(), "status", signal)
if releases != 0 {
t.Fatalf("the registration was released %d times before the revocation", releases)
}
allowed = false
if err := RevalidateContext(context.Background()); !errors.Is(err, ErrNavigationForbidden) {
t.Fatalf("expected a forbidden revalidation, got %v", err)
}
if releases != 1 {
t.Fatalf("the host registration was released %d times, want 1", releases)
}
if disconnects != 1 {
t.Fatalf("scope cleanup ran %d times, want 1", disconnects)
}
if signals := dom.SnapshotComponentSignals(page.GetID()); len(signals) != 0 {
t.Fatalf("the revoked component still exposes host signals: %#v", signals)
}
// A late frame resolves its target the way host delivery does, on the exact
// id and never through the #app fallback.
if root := dom.Doc().Query("[data-component-id='" + page.GetID() + "']"); !root.IsNull() {
t.Fatal("a late frame would still find the revoked component's root")
}
if signal.SetFromHostGated("hijacked", gate.open) {
t.Fatal("a late host frame wrote into the revoked component's signal")
}
if got := signal.Get(); got != "connected" {
t.Fatalf("the revoked component's signal changed to %q", got)
}
}
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
| Name | Type | Description |
|---|---|---|
| Path | string | |
| Name | string | |
| Component | any | |
| Guards | []Guard | |
| ResultGuards | []ResultGuard | |
| Children | []Route | |
| Loader | Loader | |
| Redirect | string | |
| Meta | map[string]any |
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 | []guardEntry | |
| 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: routeGuardEntries(r.Guards, r.ResultGuards),
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
Parameters
Returns
func matchRoute(routes []route, path string) (*route, []guardEntry, 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, joinGuards(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, joinGuards(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
}
trailingComponent
type trailingComponent struct
Methods
func (*trailingComponent) Mount()
{}
func (*trailingComponent) Unmount()
{}
func (*trailingComponent) OnMount()
{}
func (*trailingComponent) OnUnmount()
{}
Parameters
func (*trailingComponent) OnParams(map[string]string)
{}