router API

router

package

API reference for the router package.

F
function

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

router/outlet_preserve_test.go:18-64
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)
	}
}
F
function

waitForRouterRender

router/outlet_preserve_test.go:66-68
func waitForRouterRender()

{
	time.Sleep(20 * time.Millisecond)
}
F
function

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

router/outlet_repaint_test.go:16-63
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)
	}
}
F
function

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.

router/revalidate.go:29-31
func Revalidate()

{
	_ = RevalidateContext(context.Background())
}
F
function

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

error
router/revalidate.go:38-50
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
}
F
function

revalidateImpl

Parameters

Returns

error
router/revalidate.go:52-109
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)
}
F
function

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

err
error
router/revalidate.go:119-132
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)
}
F
function

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

id
string
router/revalidate.go:143-153
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")
}
F
function

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

router/revalidate.go:159-171
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
}
T
type

Guard

Guard is a function that determines whether navigation to a route is
permitted based on the provided parameters.

router/router.go:25-25
type Guard func(map[string]string) bool
S
struct

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).

router/router.go:34-46
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
F
function

Singleton

Singleton wraps a pre-created View into a Route.Component value.
Every navigation returns the same instance, no re-creation.

Parameters

Returns

any
router/router.go:50-52
func Singleton(v *types.View) any

{
	return v
}
S
struct

route

router/router.go:54-69
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
S
struct

RegisteredRoute

RegisteredRoute describes a registered route in a navigable tree form.

router/router.go:72-87
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"
F
function

Reset

Reset clears the router’s registered routes and current component.
It is primarily intended for use in tests to ensure a clean state.

router/router.go:116-124
func Reset()

{
	routes = nil
	currentComponent = nil
	NotFoundComponent = nil
	NotFoundCallback = nil
	activePathSig.Set("/")
	resetNavigation()
	scrollPositions = map[string][2]int{}
}
F
function

RegisterRoute

RegisterRoute adds a new Route to the router’s configuration.

Parameters

r
router/router.go:127-129
func RegisterRoute(r Route)

{
	routes = append(routes, buildRoute(r))
}
F
function

buildRoute

Parameters

r

Returns

router/router.go:131-133
func buildRoute(r Route) route

{
	return buildRouteAt(r, "")
}
F
function

buildRouteAt

Parameters

r
parent
string

Returns

router/router.go:135-207
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
}
F
function

RegisteredRoutes

RegisteredRoutes returns the registered routes including nested children and
resolved full paths. The data can be used for tooling and diagnostics.

Returns

router/router.go:211-217
func RegisteredRoutes() []RegisteredRoute

{
	out := make([]RegisteredRoute, 0, len(routes))
	for i := range routes {
		out = append(out, snapshotRoute(&routes[i], ""))
	}
	return out
}
F
function

snapshotRoute

Parameters

r
parent
string

Returns

router/router.go:219-235
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),
	}
}
F
function

resolveRoutePath

Parameters

parent
string
path
string

Returns

string
router/router.go:237-255
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
}
I
interface

routeParamReceiver

router/router.go:257-259
type routeParamReceiver interface

Methods

Parameters

map[string]string
func SetRouteParams(...)
F
function

matchRoute

Parameters

routes
path
string

Returns

map[string]string
router/router.go:261-286
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
}
F
function

decodeRouteParam

Parameters

value
string

Returns

string
router/router.go:288-294
func decodeRouteParam(value string) string

{
	decoded, err := url.PathUnescape(value)
	if err != nil {
		return value
	}
	return decoded
}
T
type

historyMode

router/router.go:296-296
type historyMode uint8
F
function

Replace

Replace navigates without adding a new browser history entry.

Parameters

fullPath
string
router/router.go:312-314
func Replace(fullPath string)

{
	navigate(fullPath, historyReplace)
}
F
function

updateHistory

Parameters

path
string
router/router.go:531-538
func updateHistory(mode historyMode, path string)

{
	switch mode {
	case historyPush:
		js.History().Call("pushState", nil, "", path)
	case historyReplace:
		js.History().Call("replaceState", nil, "", path)
	}
}
F
function

SetScrollRestoration

SetScrollRestoration enables or disables router-managed scroll positions.

Parameters

enabled
bool
router/router.go:541-543
func SetScrollRestoration(enabled bool)

{
	scrollEnabled = enabled
}
F
function

saveScroll

router/router.go:545-554
func saveScroll()

{
	if !scrollEnabled || currentComponent == nil {
		return
	}
	path := activePathSig.Get()
	scrollPositions[path] = [2]int{
		js.Window().Get("scrollX").Int(),
		js.Window().Get("scrollY").Int(),
	}
}
F
function

restoreScroll

Parameters

path
string
history
meta
map[string]any
router/router.go:556-568
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])
}
F
function

CanNavigate

CanNavigate reports whether the specified path matches a registered route.

Parameters

fullPath
string

Returns

bool
router/router.go:571-578
func CanNavigate(fullPath string) bool

{
	path := fullPath
	if idx := strings.Index(fullPath, "?"); idx != -1 {
		path = fullPath[:idx]
	}
	r, _, _ := matchRoute(routes, path)
	return r != nil
}
F
function

ExposeNavigate

ExposeNavigate makes the Navigate function accessible from JavaScript and
automatically routes internal anchor clicks.

router/router.go:582-608
func ExposeNavigate()

{
	exposeNavigateOnce.Do(func() {
		js.ExposeFunc("goNavigate", func(_ js.Value, args []js.Value) any {
			path := args[0].String()
			Navigate(path)
			return nil
		})

		events.On("click", js.Document(), func(evt js.Value) {
			link := evt.Get("target").Call("closest", "a[href]")
			if !link.Truthy() {
				return
			}
			if t := link.Get("target").String(); t != "" && t != "_self" {
				return
			}
			if link.Get("origin").String() != js.Location().Get("origin").String() {
				return
			}
			path := link.Get("pathname").String() + link.Get("search").String()
			if CanNavigate(path) {
				evt.Call("preventDefault")
				Navigate(path)
			}
		})
	})
}
F
function

Page

Page registers a route with path, component and optional guards.

Parameters

path
string
component
any
guards
...Guard
router/router.go:611-617
func Page(path string, component any, guards ...Guard)

{
	RegisterRoute(Route{
		Path:      path,
		Component: component,
		Guards:    guards,
	})
}
F
function

Group

Group creates nested routes under a common path prefix
and registers them. Returns the parent Route for chaining.

Parameters

prefix
string
fn
func(*GroupBuilder)
router/router.go:621-628
func Group(prefix string, fn func(*GroupBuilder))

{
	b := &GroupBuilder{prefix: prefix}
	fn(b)
	RegisterRoute(Route{
		Path:     prefix,
		Children: b.children,
	})
}
S
struct

GroupBuilder

GroupBuilder collects child routes within a Group callback.

router/router.go:631-634
type GroupBuilder struct

Methods

Page
Method

Page adds a child route within a Group.

Parameters

path string
component any
guards ...Guard
func (*GroupBuilder) Page(path string, component any, guards ...Guard)
{
	g.children = append(g.children, Route{
		Path:      path,
		Component: component,
		Guards:    guards,
	})
}
Page
Method

Page adds a route to the group.

Parameters

path string
component any
guards ...Guard
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
F
function

InitRouter

InitRouter initializes the router and begins listening for navigation
events.

router/router.go:647-662
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)
}
F
function

SetNavItems

SetNavItems registers the navigation items to be consumed by templates.

Parameters

items
router/router.go:672-674
func SetNavItems(items []NavItem)

{
	navItems = items
}
F
function

RouterData

RouterData returns the values exposed to component templates.

Returns

map[string]any
router/router.go:696-701
func RouterData() map[string]any

{
	return map[string]any{
		"ActivePath": activePathSig,
		"NavItems":   NavItemsMap(),
	}
}
F
function

TemplateData

TemplateData returns the values exposed to component templates.

Returns

map[string]any
router/router.go:704-704
func TemplateData() map[string]any

{ return RouterData() }
F
function

ActivePath

ActivePath returns the reactive signal holding the current route path.

router/router.go:707-709
func ActivePath() *state.Signal[string]

{
	return activePathSig
}
S
struct

dataComponent

router/router_data_test.go:13-16
type dataComponent struct

Methods

SetRouteData
Method

Parameters

data any
func (*dataComponent) SetRouteData(data any)
{
	component.data = data
}

Fields

Name Type Description
data any
F
function

TestNamedRouteURLAndMetadata

Parameters

router/router_data_test.go:22-57
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)
	}
}
F
function

TestRouteLoaderCommitsDataAndMeta

Parameters

router/router_data_test.go:59-91
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())
	}
}
F
function

TestNewNavigationCancelsPreviousLoader

Parameters

router/router_data_test.go:93-124
func TestNewNavigationCancelsPreviousLoader(t *testing.T)

{
	resetRouter(t)
	started := make(chan struct{})
	RegisterRoute(Route{
		Path:      "/slow",
		Component: func() core.Component { return &recordComponent{name: "slow"} },
		Loader: func(ctx context.Context, _ LoadContext) (any, error) {
			close(started)
			<-ctx.Done()
			return nil, ctx.Err()
		},
	})
	RegisterRoute(Route{
		Path:      "/fast",
		Component: func() core.Component { return &recordComponent{name: "fast"} },
	})

	result := make(chan error, 1)
	go func() {
		result <- NavigateContext(context.Background(), "/slow")
	}()
	<-started
	if err := NavigateContext(context.Background(), "/fast"); err != nil {
		t.Fatalf("fast navigation: %v", err)
	}
	if err := <-result; !errors.Is(err, context.Canceled) {
		t.Fatalf("slow loader was not cancelled: %v", err)
	}
	if CurrentComponent().GetName() != "fast" || Status().Get() != NavigationReady {
		t.Fatalf("stale loader replaced current route: component=%v status=%s", CurrentComponent(), Status().Get())
	}
}
F
function

TestRouteRedirectInterpolatesParameters

Parameters

router/router_data_test.go:126-150
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)
	}
}
F
function

TestRouteRedirectLoopFails

Parameters

router/router_data_test.go:152-160
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)
	}
}
F
function

TestCancelledNavigationDoesNotCommit

Parameters

router/router_data_test.go:162-176
func TestCancelledNavigationDoesNotCommit(t *testing.T)

{
	resetRouter(t)
	RegisterRoute(Route{
		Path:      "/cancelled",
		Component: func() core.Component { return &recordComponent{name: "cancelled"} },
	})
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	if err := NavigateContext(ctx, "/cancelled"); !errors.Is(err, context.Canceled) {
		t.Fatalf("expected cancelled context, got %v", err)
	}
	if CurrentComponent() != nil {
		t.Fatalf("cancelled navigation committed %#v", CurrentComponent())
	}
}
F
function

CurrentComponent

CurrentComponent returns the current routed component.

Returns

router/router_helpers.go:9-9
func CurrentComponent() core.Component

{ return currentComponent }
S
struct •
Implements: routeUnmounter

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.

router/router_host_replace_wasm_test.go:18-21
type hostGuardPage struct

Methods

Unmount
Method
func (*hostGuardPage) Unmount()
{
	p.unmounted++
	p.HTMLComponent.Unmount()
}

Fields

Name Type Description
unmounted int
F
function

newHostGuardPage

Parameters

name
string
marker
string

Returns

router/router_host_replace_wasm_test.go:28-37
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
}
F
function

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

*[]string
router/router_host_replace_wasm_test.go:43-50
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
}
F
function

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

path
string

Returns

router/router_host_replace_wasm_test.go:55-77
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
}
F
function

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

router/router_host_replace_wasm_test.go:84-154
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)
	}
}
F
function

TestParentHostReplaceStopsChildGuards

A parent guard that hands off stops there: no child guard, no child loader,
no component.

Parameters

router/router_host_replace_wasm_test.go:158-197
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)
	}
}
F
function

TestSessionGuardHandsOffUnauthenticated

An unauthenticated caller leaves for the host login page while the same guard
keeps a permission refusal inside the SPA.

Parameters

router/router_host_replace_wasm_test.go:201-219
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())
	}
}
F
function

TestHostReplaceHandsOffAcceptedTargets

The accepted targets reach the handoff exactly as validation returned them.

Parameters

router/router_host_replace_wasm_test.go:222-245
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)
			}
		})
	}
}
F
function

TestHostReplaceRejectedTargetsNeverHandOff

A target validation refuses never reaches the browser: navigation fails
closed and the document stays where it is.

Parameters

router/router_host_replace_wasm_test.go:249-277
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)
			}
		})
	}
}
F
function

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

router/router_host_replace_wasm_test.go:281-325
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)
	}
}
S
struct •
Implements: routeUnmounter

testComponent

testComponent implements core.Component and routeParamReceiver for testing.

router/router_query_test.go:12-14
type testComponent struct

Methods

Render
Method

Returns

string
func (*testComponent) Render() string
{ return "" }
Mount
Method
func (*testComponent) Mount()
{}
Unmount
Method
func (*testComponent) Unmount()
{}
OnMount
Method
func (*testComponent) OnMount()
{}
OnUnmount
Method
func (*testComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (*testComponent) GetName() string
{ return "test" }
GetID
Method

Returns

string
func (*testComponent) GetID() string
{ return "" }
SetSlots
Method

Parameters

map[string]any
func (*testComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*testComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

p map[string]string
func (*testComponent) OnParams(p map[string]string)
{ c.params = p }

Parameters

p map[string]string
func (*testComponent) SetRouteParams(p map[string]string)
{ c.params = p }

Fields

Name Type Description
params map[string]string
F
function

TestNavigateQueryParams

Parameters

router/router_query_test.go:28-39
func TestNavigateQueryParams(t *testing.T)

{
	Reset()
	RegisterRoute(Route{Path: "/query", Component: func() core.Component { return &testComponent{} }})
	Navigate("/query?key=value")
	tc, ok := currentComponent.(*testComponent)
	if !ok {
		t.Fatalf("expected testComponent, got %T", currentComponent)
	}
	if tc.params["key"] != "value" {
		t.Fatalf("expected query param 'key=value', got %v", tc.params)
	}
}
F
function

TestNavigateNotFound

Parameters

router/router_query_test.go:41-50
func TestNavigateNotFound(t *testing.T)

{
	Reset()
	called := false
	NotFoundCallback = func(string) { called = true }
	Navigate("/missing")
	if !called {
		t.Fatalf("expected NotFoundCallback to be called")
	}
	NotFoundCallback = nil
}
S
struct

HostNavigationError

HostNavigationError carries the host path a guard handed navigation to.
Outside browser builds there is no document to replace, so the router
reports the request rather than pretending the host page loaded. It wraps
ErrHostNavigation, so errors.Is identifies it without a type assertion.

router/data.go:39-42
type HostNavigationError struct

Methods

Error
Method

Error reports the handoff and the path it targets.

Returns

string
func (*HostNavigationError) Error() string
{
	return ErrHostNavigation.Error() + ": " + e.Path
}
Unwrap
Method

Unwrap returns ErrHostNavigation, the sentinel callers match on.

Returns

error
func (*HostNavigationError) Unwrap() error
{ return ErrHostNavigation }

Fields

Name Type Description
Path string
T
type

GuardAction

GuardAction is the outcome a result guard asks the router to apply.

router/data.go:53-53
type GuardAction uint8
S
struct

GuardResult

GuardResult is the decision a ResultGuard returns. Build it with Allow,
Forbid, RedirectTo, ReplaceWith or HostReplace rather than by hand.

router/data.go:74-77
type GuardResult struct

Fields

Name Type Description
Action GuardAction
Path string
T
type

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.

router/data.go:83-83
type ResultGuard func(map[string]string) GuardResult
F
function

Allow

Allow lets navigation continue.

Returns

router/data.go:86-86
func Allow() GuardResult

{ return GuardResult{Action: GuardAllow} }
F
function

Forbid

Forbid refuses navigation without a destination to fall back to.

Returns

router/data.go:89-89
func Forbid() GuardResult

{ return GuardResult{Action: GuardForbid} }
F
function

RedirectTo

RedirectTo sends navigation to path and keeps the current history entry,
unless navigation came from an initial load or a popstate.

Parameters

path
string

Returns

router/data.go:93-95
func RedirectTo(path string) GuardResult

{
	return GuardResult{Action: GuardRedirect, Path: path}
}
F
function

ReplaceWith

ReplaceWith sends navigation to path in place of the current history entry.

Parameters

path
string

Returns

router/data.go:98-100
func ReplaceWith(path string) GuardResult

{
	return GuardResult{Action: GuardReplace, Path: path}
}
F
function

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

path
string

Returns

router/data.go:109-111
func HostReplace(path string) GuardResult

{
	return GuardResult{Action: GuardHostReplace, Path: path}
}
S
struct

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.

router/data.go:116-119
type guardEntry struct

Fields

Name Type Description
legacy Guard
result ResultGuard
F
function

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

legacy
results

Returns

router/data.go:124-136
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
}
F
function

joinGuards

joinGuards concatenates a parent chain with its child chain without writing
into the parent’s own slice.

Parameters

parent
child

Returns

router/data.go:140-150
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...)
}
F
function

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

entries
params
map[string]string

Returns

error
router/data.go:156-192
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
}
F
function

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

path
string

Returns

string
error
router/data.go:197-208
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
}
F
function

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

path
string

Returns

string
error
router/data.go:218-256
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
}
F
function

isURLControl

Parameters

r
rune

Returns

bool
router/data.go:258-258
func isURLControl(r rune) bool

{ return r < 0x20 || r == 0x7f }
S
struct

LoadContext

LoadContext describes the destination passed to a route loader.

router/data.go:261-265
type LoadContext struct

Fields

Name Type Description
Path string
Params map[string]string
Query url.Values
T
type

Loader

Loader resolves data before a route component is committed.

router/data.go:268-268
type Loader func(context.Context, LoadContext) (any, error)
I
interface

RouteDataReceiver

RouteDataReceiver accepts data returned by a route Loader.

router/data.go:271-273
type RouteDataReceiver interface

Methods

SetRouteData
Method

Parameters

any
func SetRouteData(...)
F
function

Status

Status returns the reactive navigation status.

router/data.go:301-303
func Status() *state.Signal[NavigationStatus]

{
	return navigationStatus
}
F
function

Error

Error returns the latest reactive loader error.

router/data.go:306-308
func Error() *state.Signal[error]

{
	return navigationError
}
F
function

Data

Data returns the current route’s reactive loader data.

router/data.go:311-313
func Data() *state.Signal[any]

{
	return currentRouteData
}
F
function

Meta

Meta returns the current route’s reactive metadata.

router/data.go:316-318
func Meta() *state.Signal[map[string]any]

{
	return currentRouteMeta
}
F
function

beginNavigation

Parameters

Returns

router/data.go:320-334
func beginNavigation(parent context.Context) (context.Context, uint64)

{
	if parent == nil {
		parent = context.Background()
	}
	navigationMu.Lock()
	if navigationCancel != nil {
		navigationCancel()
	}
	ctx, cancel := context.WithCancel(parent)
	navigationCancel = cancel
	navigationID++
	id := navigationID
	navigationMu.Unlock()
	return ctx, id
}
F
function

resetNavigation

router/data.go:343-357
func resetNavigation()

{
	navigationMu.Lock()
	if navigationCancel != nil {
		navigationCancel()
	}
	navigationCancel = nil
	navigationID++
	navigationMu.Unlock()
	state.Batch(func() {
		navigationStatus.Set(NavigationIdle)
		navigationError.Set(nil)
		currentRouteData.Set(nil)
		currentRouteMeta.Set(map[string]any{})
	})
}
F
function

commitRouteState

Parameters

data
any
meta
map[string]any
router/data.go:359-366
func commitRouteState(data any, meta map[string]any)

{
	state.Batch(func() {
		currentRouteData.Set(data)
		currentRouteMeta.Set(cloneMeta(meta))
		navigationError.Set(nil)
		navigationStatus.Set(NavigationReady)
	})
}
F
function

failNavigation

Parameters

err
error
router/data.go:368-373
func failNavigation(err error)

{
	state.Batch(func() {
		navigationError.Set(err)
		navigationStatus.Set(NavigationError)
	})
}
F
function

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

err
error
router/data.go:382-389
func revokeCommittedRoute(err error)

{
	state.Batch(func() {
		currentRouteData.Set(nil)
		currentRouteMeta.Set(map[string]any{})
		navigationError.Set(err)
		navigationStatus.Set(NavigationError)
	})
}
F
function

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.

router/data.go:396-404
func settleRevalidatedRoute()

{
	if navigationStatus.Get() != NavigationLoading {
		return
	}
	state.Batch(func() {
		navigationError.Set(nil)
		navigationStatus.Set(NavigationReady)
	})
}
F
function

cloneMeta

Parameters

meta
map[string]any

Returns

map[string]any
router/data.go:406-415
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
}
F
function

cloneStringMap

Parameters

values
map[string]string

Returns

map[string]string
router/data.go:417-423
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
}
F
function

URL

URL builds a URL for a named route.

Parameters

name
string
params
map[string]string
query

Returns

string
error
router/data.go:426-448
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
}
F
function

MustURL

MustURL builds a named URL and panics if it is invalid.

Parameters

name
string
params
map[string]string
query

Returns

string
router/data.go:451-457
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
}
F
function

namedRoutePath

Parameters

list
name
string

Returns

string
bool
router/data.go:459-469
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
}
F
function

routeQuery

Parameters

raw
string

Returns

map[string]string
router/data.go:471-485
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
}
F
function

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

fullPath
string

Returns

map[string]string
router/data.go:492-511
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
}
S
struct

redirectDepthKey

router/data.go:513-513
type redirectDepthKey struct
F
function

nextRedirectContext

Parameters

Returns

router/data.go:515-521
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
}
F
function

redirectPath

Parameters

template
string
params
map[string]string

Returns

string
error
router/data.go:523-537
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
}
S
struct

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.

router/outlet.go:16-18
type Outlet struct

Methods

repaint
Method

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
Method

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
Method

OnUnmount clears the live outlet (the shell around it is going away).

func (*Outlet) OnUnmount()
{
	if liveOutlet == o {
		liveOutlet = nil
	}
	o.HTMLComponent.OnUnmount()
}
renderChild
Method

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)
	})
}
F
function

NewOutlet

NewOutlet builds the outlet component; mount it via a dependency include.

Returns

router/outlet.go:25-31
func NewOutlet() *Outlet

{
	c := &Outlet{HTMLComponent: core.NewHTMLComponent("RouterOutlet", outletTpl, nil)}
	c.SetComponent(c)
	c.Init(nil)
	installOutletRepaint()
	return c
}
F
function

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.

router/outlet.go:43-57
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()
	})
}
F
function

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

router/outlet.go:132-139
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)
}
F
function

renderComponent

Parameters

parentID
string
depth
int
commit
func(string)
router/outlet.go:141-201
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)
}
F
function

componentDOMDepth

Parameters

componentID
string

Returns

int
router/outlet.go:203-215
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
}
S
struct

routerTraceSnapshot

router/outlet_trace_test.go:15-24
type routerTraceSnapshot struct

Fields

Name Type Description
event string
componentID string
parentID string
cause string
hasTimings bool
hasQueue bool
templateMS float64
domMS float64
F
function

TestOutletTraceCoversRootMountAndRouteCommit

Parameters

router/outlet_trace_test.go:26-100
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)
	}
}
F
function

TestDirectRenderFailureReportsElapsedDOMTime

Parameters

router/outlet_trace_test.go:102-145
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)
	}
}
S
struct •

canNavigateComponent

router/router_can_navigate_test.go:11-11
type canNavigateComponent struct

Methods

Render
Method

Returns

string
func (*canNavigateComponent) Render() string
{ return "" }
Mount
Method
func (*canNavigateComponent) Mount()
{}
Unmount
Method
func (*canNavigateComponent) Unmount()
{}
OnMount
Method
func (*canNavigateComponent) OnMount()
{}
OnUnmount
Method
func (*canNavigateComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (*canNavigateComponent) GetName() string
{ return "test" }
GetID
Method

Returns

string
func (*canNavigateComponent) GetID() string
{ return "" }
SetSlots
Method

Parameters

map[string]any
func (*canNavigateComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*canNavigateComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

map[string]string
func (*canNavigateComponent) OnParams(map[string]string)
{}
F
function

TestCanNavigate

Parameters

router/router_can_navigate_test.go:24-36
func TestCanNavigate(t *testing.T)

{
	Reset()
	RegisterRoute(Route{Path: "/can", Component: func() core.Component { return &canNavigateComponent{} }})
	if !CanNavigate("/can") {
		t.Fatalf("expected true for registered route")
	}
	if !CanNavigate("/can?foo=bar") {
		t.Fatalf("expected true for registered route with query")
	}
	if CanNavigate("/missing") {
		t.Fatalf("expected false for unregistered route")
	}
}
F
function

guardedRoute

guardedRoute registers a protected route whose loader and component factory
must never run when a guard refuses the destination.

Parameters

path
string
guards
legacy
...Guard

Returns

*int
router/router_guard_result_test.go:14-31
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
}
F
function

TestResultGuardForbidsWithoutCommitting

Parameters

router/router_guard_result_test.go:33-53
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())
	}
}
F
function

TestResultGuardsComposeParentBeforeChild

Guards run parent before child, legacy before typed, and stop at the first
one that does not allow navigation.

Parameters

router/router_guard_result_test.go:57-87
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)
	}
}
F
function

TestParentResultGuardStopsChildGuards

A parent guard that refuses stops the chain before any child guard runs.

Parameters

router/router_guard_result_test.go:90-118
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)
	}
}
F
function

TestResultGuardRedirectsToLogin

Parameters

router/router_guard_result_test.go:120-142
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())
	}
}
F
function

TestResultGuardRedirectKeepsRouteParameters

Parameters

router/router_guard_result_test.go:144-166
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)
	}
}
F
function

TestInvalidGuardResultFailsClosed

Parameters

router/router_guard_result_test.go:168-194
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())
			}
		})
	}
}
F
function

TestResultGuardRedirectLoopFails

Parameters

router/router_guard_result_test.go:196-215
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())
	}
}
F
function

TestLegacyGuardStillBlocksBeforeResultGuards

Legacy bool guards keep their contract: a false guard blocks with
ErrNavigationBlocked and never reaches the typed guards behind it.

Parameters

router/router_guard_result_test.go:219-239
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")
	}
}
F
function

TestAllowingResultGuardCommitsRoute

A guard that allows leaves the route untouched: loader, component and route
state commit exactly as they do without guards.

Parameters

router/router_guard_result_test.go:243-265
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)
	}
}
F
function

TestCancelledNavigationSkipsResultGuards

A cancelled navigation never reaches its guards.

Parameters

router/router_guard_result_test.go:268-288
func TestCancelledNavigationSkipsResultGuards(t *testing.T)

{
	resetRouter(t)
	guardRan := false
	RegisterRoute(Route{
		Path:      "/cancelled-guard",
		Component: func() core.Component { return &recordComponent{name: "cancelled"} },
		ResultGuards: []ResultGuard{func(map[string]string) GuardResult {
			guardRan = true
			return Allow()
		}},
	})
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	if err := NavigateContext(ctx, "/cancelled-guard"); !errors.Is(err, context.Canceled) {
		t.Fatalf("expected a cancelled navigation, got %v", err)
	}
	if guardRan {
		t.Fatal("a guard ran for a cancelled navigation")
	}
}
F
function

TestReplaceKeepsHistoryLength

Parameters

router/router_history_test.go:14-38
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)
	}
}
F
function

restoreHistoryPath

restoreHistoryPath puts the page back on the path the test found, so the
suite that follows starts where it expects to.

Parameters

router/router_history_test.go:42-51
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()
	})
}
F
function

travelHistory

travelHistory triggers a browser history move and waits for the popstate
that carries the new URL.

Parameters

direction
string

Returns

string
router/router_history_test.go:55-66
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()
}
F
function

TestGuardReplaceLeavesNoHistoryEntry

A login redirect replaces the entry it was serving, so the protected path
leaves no history entry to go back to.

Parameters

router/router_history_test.go:70-101
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)
	}
}
F
function

TestGuardRedirectPushesHistoryEntry

A push redirect keeps the entry it came from, so back returns to it.

Parameters

router/router_history_test.go:104-133
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)
	}
}
F
function

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

router/router_history_test.go:137-167
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)
	}
}
F
function

TestHistoryNoneGuardFallbackDoesNotPush

Parameters

router/router_history_test.go:197-216
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)
	}
}
F
function

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

router/router_inplace_test.go:19-66
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())
	}
}
F
function

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

router/router_revalidate_native_test.go:14-54
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())
	}
}
F
function

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

router/router_revalidate_native_test.go:59-98
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())
			}
		})
	}
}
S
struct

hostReplaceCase

hostReplaceCase names a host handoff target under test.

router/router_host_replace_test.go:12-15
type hostReplaceCase struct

Fields

Name Type Description
name string
target string
F
function

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

authenticated
*bool
permitted
*bool

Returns

*int
router/router_host_replace_test.go:73-94
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
}
F
function

TestHostReplaceTargetRejectsOffOriginTargets

Parameters

router/router_host_replace_test.go:96-108
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)
			}
		})
	}
}
F
function

TestHostReplaceTargetAcceptsRootedPaths

Parameters

router/router_host_replace_test.go:110-122
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)
			}
		})
	}
}
F
function

TestHostNavigationErrorIsDistinct

A host handoff is its own outcome: it is neither a refusal nor a route the
router could resolve, and it carries the target it was asked for.

Parameters

router/router_host_replace_test.go:126-141
func TestHostNavigationErrorIsDistinct(t *testing.T)

{
	var err error = &HostNavigationError{Path: "/login"}
	if !errors.Is(err, ErrHostNavigation) {
		t.Fatalf("host navigation error does not wrap ErrHostNavigation: %v", err)
	}
	if errors.Is(err, ErrNavigationForbidden) || errors.Is(err, ErrRouteNotFound) || errors.Is(err, ErrInvalidGuardResult) {
		t.Fatalf("host navigation error matches another outcome: %v", err)
	}
	var host *HostNavigationError
	if !errors.As(err, &host) || host.Path != "/login" {
		t.Fatalf("host navigation error target = %#v", host)
	}
	if err.Error() != "router: host navigation requested: /login" {
		t.Fatalf("error message = %q", err.Error())
	}
}
F
function

TestGuardRedirectTargetStillRejectsOffOriginTargets

The SPA redirects keep their own validation: a host handoff loosens nothing
for RedirectTo and ReplaceWith.

Parameters

router/router_host_replace_test.go:145-151
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)
		}
	}
}
F
function

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

router/router_host_replace_test.go:156-183
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)
	}
}
S
struct •
Implements: routeUnmounter

recordComponent

router/router_test.go:10-13
type recordComponent struct

Methods

Render
Method

Returns

string
func (*recordComponent) Render() string
{ return "" }
Mount
Method
func (*recordComponent) Mount()
{}
Unmount
Method
func (*recordComponent) Unmount()
{}
OnMount
Method
func (*recordComponent) OnMount()
{}
OnUnmount
Method
func (*recordComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (*recordComponent) GetName() string
{ return c.name }
GetID
Method

Returns

string
func (*recordComponent) GetID() string
{ return c.name }
SetSlots
Method

Parameters

map[string]any
func (*recordComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*recordComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

p map[string]string
func (*recordComponent) OnParams(p map[string]string)
{
	c.SetRouteParams(p)
}

Parameters

p map[string]string
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
F
function

resetRouter

Parameters

router/router_test.go:34-38
func resetRouter(t *testing.T)

{
	t.Helper()
	Reset()
	t.Cleanup(func() { Reset() })
}
F
function

mustRecord

Parameters

Returns

router/router_test.go:40-47
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
}
F
function

TestRegisterRoute_BasicRouting

Parameters

router/router_test.go:49-72
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)
	}
}
F
function

TestNavigateTo_CurrentComponent

Parameters

router/router_test.go:74-87
func TestNavigateTo_CurrentComponent(t *testing.T)

{
	resetRouter(t)

	NavigateTo("/nothing")
	if CurrentComponent() != nil {
		t.Fatalf("expected nil current component before routes")
	}

	RegisterRoute(Route{Path: "/home", Component: func() core.Component { return &recordComponent{name: "home"} }})
	NavigateTo("/home")
	if got := mustRecord(t, CurrentComponent()).name; got != "home" {
		t.Fatalf("expected 'home', got %q", got)
	}
}
F
function

TestRouteGuards_BlockNavigation

Parameters

router/router_test.go:89-114
func TestRouteGuards_BlockNavigation(t *testing.T)

{
	resetRouter(t)

	RegisterRoute(Route{Path: "/", Component: func() core.Component { return &recordComponent{name: "root"} }})

	var guardParams map[string]string
	RegisterRoute(Route{
		Path:      "/admin/:id",
		Component: func() core.Component { return &recordComponent{name: "admin"} },
		Guards: []Guard{func(p map[string]string) bool {
			guardParams = map[string]string{}
			for k, v := range p {
				guardParams[k] = v
			}
			return false
		}},
	})

	NavigateTo("/admin/42")
	if guardParams["id"] != "42" {
		t.Fatalf("expected guard to receive id=42, got %v", guardParams)
	}
	if got := mustRecord(t, CurrentComponent()).name; got != "root" {
		t.Fatalf("expected navigation to '/', got %q", got)
	}
}
F
function

TestQueryParams

Parameters

router/router_test.go:116-127
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)
	}
}
F
function

TestNotFoundComponent

Parameters

router/router_test.go:129-143
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)
	}
}
F
function

TestTrailingSlashNormalization

Parameters

router/router_test.go:145-160
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")
	}
}
F
function

TestNestedRouteNavigation

Parameters

router/router_test.go:162-186
func TestNestedRouteNavigation(t *testing.T)

{
	resetRouter(t)

	RegisterRoute(Route{
		Path: "/teams/:team",
		Children: []Route{{
			Path:      "projects/:project",
			Component: func() core.Component { return &recordComponent{name: "project"} },
		}},
	})

	NavigateTo("/teams/acme/projects/rfw?tab=issues")
	rc := mustRecord(t, CurrentComponent())
	want := map[string]string{
		"team":    "acme",
		"project": "rfw",
		"tab":     "issues",
	}
	if !reflect.DeepEqual(rc.params, want) {
		t.Fatalf("expected params %v, got %v", want, rc.params)
	}
	if !CanNavigate("/teams/acme/projects/rfw") {
		t.Fatal("expected nested route to be navigable")
	}
}
F
function

TestNestedRouteChildParamShadowsParent

Parameters

router/router_test.go:188-204
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)
	}
}
F
function

TestParentRouteDoesNotMatchUnknownChild

Parameters

router/router_test.go:206-225
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")
	}
}
F
function

TestAbsoluteNestedRouteNavigation

Parameters

router/router_test.go:227-247
func TestAbsoluteNestedRouteNavigation(t *testing.T)

{
	resetRouter(t)
	guardCalled := false
	RegisterRoute(Route{
		Path:   "/admin",
		Guards: []Guard{func(map[string]string) bool { guardCalled = true; return true }},
		Children: []Route{{
			Path:      "/login",
			Component: func() core.Component { return &recordComponent{name: "login"} },
		}},
	})

	NavigateTo("/login")

	if got := mustRecord(t, CurrentComponent()).name; got != "login" {
		t.Fatalf("expected absolute child route, got %q", got)
	}
	if !guardCalled {
		t.Fatal("expected parent guard to run for absolute child")
	}
}
I
interface

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.

router/revalidate_stub.go:14-14
type routeUnmounter interface

Methods

Unmount
Method
func Unmount(...)
F
function

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.

router/revalidate_stub.go:30-32
func Revalidate()

{
	_ = RevalidateContext(context.Background())
}
F
function

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

error
router/revalidate_stub.go:39-112
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
}
F
function

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

router/revalidate_stub.go:120-125
func releaseReplacedOwner(owner core.Component)

{
	if owner == nil || owner == currentComponent {
		return
	}
	tearDownRoutedOwner(owner)
}
F
function

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

err
error
router/revalidate_stub.go:132-137
func revokeMountedRoute(err error)

{
	owner := currentComponent
	currentComponent = nil
	tearDownRoutedOwner(owner)
	revokeCommittedRoute(err)
}
F
function

tearDownRoutedOwner

tearDownRoutedOwner runs the lifecycle of a routed owner that is no longer
current, exactly once.

Parameters

router/revalidate_stub.go:141-146
func tearDownRoutedOwner(owner core.Component)

{
	core.TriggerUnmount(owner)
	if unmounter, ok := owner.(routeUnmounter); ok {
		unmounter.Unmount()
	}
}
F
function

hostNavigation

hostNavigation asserts that err is the host handoff contract and returns the
path it carries.

Parameters

err
error

Returns

string
router/router_host_replace_native_test.go:15-25
func hostNavigation(t *testing.T, err error) string

{
	t.Helper()
	if !errors.Is(err, ErrHostNavigation) {
		t.Fatalf("expected a host navigation, got %v", err)
	}
	var host *HostNavigationError
	if !errors.As(err, &host) {
		t.Fatalf("host navigation error does not carry its target: %v", err)
	}
	return host.Path
}
F
function

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

router/router_host_replace_native_test.go:29-73
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())
	}
}
F
function

TestHostReplaceReportsAcceptedTargets

Parameters

router/router_host_replace_native_test.go:75-94
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())
			}
		})
	}
}
F
function

TestHostReplaceRejectedTargetsFailClosed

A target validation refuses fails closed with ErrInvalidGuardResult and never
becomes a host handoff.

Parameters

router/router_host_replace_native_test.go:98-120
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())
			}
		})
	}
}
F
function

TestParentHostReplaceStopsChildGuardsOnStub

A parent guard that hands off stops the chain: no child guard, no child
loader, no component.

Parameters

router/router_host_replace_native_test.go:124-158
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)
	}
}
F
function

TestSessionGuardReportsHandoffForUnauthenticated

An unauthenticated caller is handed to the host login page by the same guard
that keeps a permission refusal inside the SPA.

Parameters

router/router_host_replace_native_test.go:162-175
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())
	}
}
S
struct •

routeComponent

router/router_registered_test.go:11-11
type routeComponent struct

Methods

Render
Method

Returns

string
func (routeComponent) Render() string
{ return "" }
Mount
Method
func (routeComponent) Mount()
{}
Unmount
Method
func (routeComponent) Unmount()
{}
OnMount
Method
func (routeComponent) OnMount()
{}
OnUnmount
Method
func (routeComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (routeComponent) GetName() string
{ return "route" }
GetID
Method

Returns

string
func (routeComponent) GetID() string
{ return "route" }
SetSlots
Method

Parameters

map[string]any
func (routeComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (routeComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

map[string]string
func (routeComponent) OnParams(map[string]string)
{}
F
function

TestRegisteredRoutes

Parameters

router/router_registered_test.go:24-68
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)
	}
}
S
struct •

reloadComponent

router/router_reload_test.go:11-11
type reloadComponent struct

Methods

Render
Method

Returns

string
func (*reloadComponent) Render() string
{ return "" }
Mount
Method
func (*reloadComponent) Mount()
{}
Unmount
Method
func (*reloadComponent) Unmount()
{}
OnMount
Method
func (*reloadComponent) OnMount()
{}
OnUnmount
Method
func (*reloadComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (*reloadComponent) GetName() string
{ return "reload" }
GetID
Method

Returns

string
func (*reloadComponent) GetID() string
{ return "" }
SetSlots
Method

Parameters

map[string]any
func (*reloadComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*reloadComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

map[string]string
func (*reloadComponent) OnParams(map[string]string)
{}
F
function

TestNavigateReloadsRouteEachTime

Parameters

router/router_reload_test.go:24-36
func TestNavigateReloadsRouteEachTime(t *testing.T)

{
	Reset()
	count := 0
	RegisterRoute(Route{Path: "/reload", Component: func() core.Component {
		count++
		return &reloadComponent{}
	}})
	Navigate("/reload")
	Navigate("/reload")
	if count != 2 {
		t.Fatalf("expected loader called twice, got %d", count)
	}
}
S
struct •

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.

router/router_revalidate_test.go:14-20
type revalidatePage struct

Methods

Render
Method

Returns

string
func (*revalidatePage) Render() string
{ return "" }
Mount
Method
func (*revalidatePage) Mount()
{}
OnMount
Method
func (*revalidatePage) OnMount()
{}
OnUnmount
Method
func (*revalidatePage) OnUnmount()
{}
GetName
Method

Returns

string
func (*revalidatePage) GetName() string
{ return p.name }
GetID
Method

Returns

string
func (*revalidatePage) GetID() string
{ return p.name }
SetSlots
Method

Parameters

map[string]any
func (*revalidatePage) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*revalidatePage) IsMounted() bool
{ return p.unmounted == 0 }
Unmount
Method
func (*revalidatePage) Unmount()
{
	p.unmounted++
	p.scope.Close()
}
OnParams
Method

Parameters

map[string]string
func (*revalidatePage) OnParams(map[string]string)
{ p.paramRuns++ }

Fields

Name Type Description
name string
scope *core.Scope
unmounted int
paramRuns int
cleanups int
F
function

newRevalidatePage

Parameters

name
string

Returns

router/router_revalidate_test.go:22-27
func newRevalidatePage(name string) *revalidatePage

{
	page := &revalidatePage{name: name}
	page.scope = core.NewScope()
	page.scope.Defer(func() { page.cleanups++ })
	return page
}
F
function

mustRevalidatePage

Parameters

Returns

router/router_revalidate_test.go:45-52
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
}
S
struct

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.

router/router_revalidate_test.go:58-63
type sessionRoute struct

Fields

Name Type Description
decide func(map[string]string) GuardResult
guards int
created int
loaded int
F
function

registerSessionRoute

Parameters

path
string
decide
func(map[string]string) GuardResult

Returns

router/router_revalidate_test.go:65-85
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
}
F
function

TestRevalidateAllowedRouteIsANoOp

A route its guards still allow is left exactly as it is: the guards run
again, and nothing else does.

Parameters

router/router_revalidate_test.go:89-136
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())
	}
}
F
function

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

router/router_revalidate_test.go:140-206
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")
	}
}
F
function

TestRevalidateLegacyBlockTearsDownAndNavigationIsUnchanged

A legacy bool guard that now blocks tears the mounted route down the same
way, while a blocked navigation to another route keeps its own behavior:
nothing was committed there, so nothing is taken back.

Parameters

router/router_revalidate_test.go:211-291
func TestRevalidateLegacyBlockTearsDownAndNavigationIsUnchanged(t *testing.T)

{
	resetRouter(t)
	allowed := true
	RegisterRoute(Route{
		Path:      "/reval-legacy",
		Guards:    []Guard{func(map[string]string) bool { return allowed }},
		Component: func() core.Component { return newRevalidatePage("legacy") },
		Loader:    func(context.Context, LoadContext) (any, error) { return "payload", nil },
		Meta:      map[string]any{"section": "legacy"},
	})
	blocked := 0
	RegisterRoute(Route{
		Path:      "/reval-legacy-other",
		Guards:    []Guard{func(map[string]string) bool { blocked++; return false }},
		Component: func() core.Component { return newRevalidatePage("other") },
	})
	RegisterRoute(Route{
		Path:         "/reval-legacy-forbidden",
		ResultGuards: []ResultGuard{func(map[string]string) GuardResult { return Forbid() }},
		Component:    func() core.Component { return newRevalidatePage("forbidden") },
	})

	if err := NavigateContext(context.Background(), "/reval-legacy"); err != nil {
		t.Fatalf("navigate: %v", err)
	}
	page := mustRevalidatePage(t, CurrentComponent())

	// An ordinary denied navigation leaves the mounted page and the navigation
	// state exactly as they were.
	if err := NavigateContext(context.Background(), "/reval-legacy-other"); !errors.Is(err, ErrNavigationBlocked) {
		t.Fatalf("expected a blocked navigation, got %v", err)
	}
	if blocked != 1 {
		t.Fatalf("the blocking guard ran %d times, want 1", blocked)
	}
	if CurrentComponent() != core.Component(page) || page.unmounted != 0 {
		t.Fatalf("a blocked navigation disturbed the mounted page: current=%#v unmounted=%d", CurrentComponent(), page.unmounted)
	}
	if Status().Get() != NavigationReady || Error().Get() != nil {
		t.Fatalf("a blocked navigation changed navigation state: status=%s error=%v", Status().Get(), Error().Get())
	}
	if Data().Get() != "payload" || Meta().Get()["section"] != "legacy" {
		t.Fatalf("a blocked navigation dropped the mounted route's state: data=%#v meta=%#v", Data().Get(), Meta().Get())
	}
	// A refused destination is denied the same way: nothing committed there, so
	// the page the user is on keeps everything it loaded.
	if err := NavigateContext(context.Background(), "/reval-legacy-forbidden"); !errors.Is(err, ErrNavigationForbidden) {
		t.Fatalf("expected a forbidden navigation, got %v", err)
	}
	if CurrentComponent() != core.Component(page) || page.unmounted != 0 {
		t.Fatalf("a forbidden navigation disturbed the mounted page: current=%#v unmounted=%d", CurrentComponent(), page.unmounted)
	}
	if Data().Get() != "payload" || Meta().Get()["section"] != "legacy" {
		t.Fatalf("a forbidden navigation dropped the mounted route's state: data=%#v meta=%#v", Data().Get(), Meta().Get())
	}

	allowed = false
	err := RevalidateContext(context.Background())

	if !errors.Is(err, ErrNavigationBlocked) {
		t.Fatalf("expected a blocked revalidation, got %v", err)
	}
	if page.unmounted != 1 || page.cleanups != 1 {
		t.Fatalf("teardown ran wrong: unmounted=%d cleanups=%d", page.unmounted, page.cleanups)
	}
	if CurrentComponent() != nil {
		t.Fatalf("the blocked route stayed current: %#v", CurrentComponent())
	}
	if Status().Get() != NavigationError || !errors.Is(Error().Get(), ErrNavigationBlocked) {
		t.Fatalf("navigation state: status=%s error=%v", Status().Get(), Error().Get())
	}
	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 got := ActivePath().Get(); got != "/reval-legacy" {
		t.Fatalf("active path = %q, want the committed path", got)
	}
}
F
function

TestRevalidateRedirectRoutesToTheDestination

A guard that redirects during revalidation routes to its destination the way
navigation does, for both redirect actions.

Parameters

router/router_revalidate_test.go:295-342
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())
			}
		})
	}
}
F
function

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

router/router_revalidate_test.go:348-399
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)
			}
		})
	}
}
F
function

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

router/router_revalidate_test.go:404-404
func noContext() context.Context

{ return nil }
F
function

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

router/router_revalidate_test.go:409-439
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)
	}
}
F
function

TestRevalidateRedirectLoopFails

Two guards redirecting at each other during revalidation hit the same
redirect-loop limit navigation does.

Parameters

router/router_revalidate_test.go:443-470
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)
	}
}
F
function

TestRevalidateWithoutAMountedRouteDoesNothing

With no routed component mounted there is nothing to revalidate.

Parameters

router/router_revalidate_test.go:473-486
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())
	}
}
F
function

TestRevalidateWithoutAMatchingRouteIsANoOp

A committed path that matches no route (the not-found component) has no
guards to re-run, so revalidation leaves it alone.

Parameters

router/router_revalidate_test.go:490-509
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)
	}
}
F
function

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

router/router_revalidate_test.go:515-540
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)
	}
}
F
function

TestRevalidateCancelledContextSkipsGuards

A cancelled context is answered before the guards run, and the mounted route
stays where it is.

Parameters

router/router_revalidate_test.go:544-573
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)
	}
}
F
function

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

router/router_revalidate_test.go:578-655
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)
	}
}
F
function

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

router/router_revalidate_test.go:659-703
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())
	}
}
F
function

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

path
string
guard
router/router_revalidate_wasm_test.go:22-49
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
}
F
function

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

id
string

Returns

router/router_revalidate_wasm_test.go:54-61
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
}
F
function

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

router/router_revalidate_wasm_test.go:67-154
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)
	}
}
F
function

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

router/router_revalidate_wasm_test.go:160-204
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")
	}
}
F
function

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

router/router_revalidate_wasm_test.go:209-259
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())
	}
}
F
function

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

router/router_revalidate_wasm_test.go:263-314
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)
			}
		})
	}
}
F
function

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

router/router_revalidate_wasm_test.go:319-375
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)
	}
}
S
struct

revalidateGate

revalidateGate stands in for the ownership gate a host registration hands to
the frames delivered under it: releasing the registration closes it.

router/router_revalidate_wasm_test.go:379-379
type revalidateGate struct

Methods

open
Method

Returns

bool
func (*revalidateGate) open() bool
{ return !g.closed }

Fields

Name Type Description
closed bool
F
function

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

router/router_revalidate_wasm_test.go:388-448
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)
	}
}
T
type

Guard

Guard decides whether a route can be entered.

router/router_stub.go:17-17
type Guard func(map[string]string) bool
S
struct

Route

Route describes a route and its optional children, loader, and metadata.

router/router_stub.go:20-32
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
F
function

Singleton

Singleton marks a view instance for reuse between navigations.

Parameters

Returns

any
router/router_stub.go:35-37
func Singleton(v *types.View) any

{
	return v
}
S
struct

route

router/router_stub.go:39-54
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
S
struct

RegisteredRoute

RegisteredRoute is a serializable snapshot of a route.

router/router_stub.go:57-64
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"
F
function

Reset

Reset clears registered routes and navigation state.

router/router_stub.go:80-87
func Reset()

{
	routes = nil
	currentComponent = nil
	NotFoundComponent = nil
	NotFoundCallback = nil
	activePathSig.Set("/")
	resetNavigation()
}
F
function

RegisterRoute

RegisterRoute adds a route to the router.

Parameters

r
router/router_stub.go:90-92
func RegisterRoute(r Route)

{
	routes = append(routes, buildRoute(r))
}
F
function

buildRoute

Parameters

r

Returns

router/router_stub.go:94-96
func buildRoute(r Route) route

{
	return buildRouteAt(r, "")
}
F
function

buildRouteAt

Parameters

r
parent
string

Returns

router/router_stub.go:98-171
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
}
F
function

RegisteredRoutes

RegisteredRoutes returns snapshots of all registered routes.

Returns

router/router_stub.go:174-180
func RegisteredRoutes() []RegisteredRoute

{
	out := make([]RegisteredRoute, 0, len(routes))
	for i := range routes {
		out = append(out, snapshotRoute(&routes[i], ""))
	}
	return out
}
F
function

snapshotRoute

Parameters

r
parent
string

Returns

router/router_stub.go:182-198
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),
	}
}
F
function

resolveRoutePath

Parameters

parent
string
path
string

Returns

string
router/router_stub.go:200-218
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
}
I
interface

routeParamReceiver

router/router_stub.go:220-222
type routeParamReceiver interface

Methods

Parameters

map[string]string
func SetRouteParams(...)
I
interface

routeParamHandler

router/router_stub.go:224-226
type routeParamHandler interface

Methods

OnParams
Method

Parameters

map[string]string
func OnParams(...)
F
function

matchRoute

Parameters

routes
path
string

Returns

map[string]string
router/router_stub.go:228-253
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
}
F
function

decodeRouteParam

Parameters

value
string

Returns

string
router/router_stub.go:255-261
func decodeRouteParam(value string) string

{
	decoded, err := url.PathUnescape(value)
	if err != nil {
		return value
	}
	return decoded
}
F
function

Replace

Replace behaves like Navigate outside browser builds.

Parameters

fullPath
string
router/router_stub.go:405-407
func Replace(fullPath string)

{
	Navigate(fullPath)
}
F
function

SetScrollRestoration

SetScrollRestoration is a no-op outside browser builds.

Parameters

bool
router/router_stub.go:410-410
func SetScrollRestoration(bool)

{}
F
function

CanNavigate

CanNavigate reports whether a path matches a registered route.

Parameters

fullPath
string

Returns

bool
router/router_stub.go:413-420
func CanNavigate(fullPath string) bool

{
	path := fullPath
	if idx := strings.Index(fullPath, "?"); idx != -1 {
		path = fullPath[:idx]
	}
	r, _, _ := matchRoute(routes, path)
	return r != nil
}
F
function

Page

Page registers a route with an optional set of guards.

Parameters

path
string
component
any
guards
...Guard
router/router_stub.go:423-429
func Page(path string, component any, guards ...Guard)

{
	RegisterRoute(Route{
		Path:      path,
		Component: component,
		Guards:    guards,
	})
}
F
function

Group

Group registers a set of child routes below a path prefix.

Parameters

prefix
string
fn
func(*GroupBuilder)
router/router_stub.go:432-439
func Group(prefix string, fn func(*GroupBuilder))

{
	b := &GroupBuilder{prefix: prefix}
	fn(b)
	RegisterRoute(Route{
		Path:     prefix,
		Children: b.children,
	})
}
S
struct

GroupBuilder

GroupBuilder collects routes for Group.

router/router_stub.go:442-445
type GroupBuilder struct

Methods

Page
Method

Page adds a child route within a Group.

Parameters

path string
component any
guards ...Guard
func (*GroupBuilder) Page(path string, component any, guards ...Guard)
{
	g.children = append(g.children, Route{
		Path:      path,
		Component: component,
		Guards:    guards,
	})
}
Page
Method

Page adds a route to the group.

Parameters

path string
component any
guards ...Guard
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
F
function

ExposeNavigate

ExposeNavigate is a no-op outside browser builds.

router/router_stub.go:457-457
func ExposeNavigate()

{}
F
function

InitRouter

InitRouter is a no-op outside browser builds.

router/router_stub.go:460-460
func InitRouter()

{}
F
function

SetNavItems

SetNavItems registers the navigation items.

Parameters

items
router/router_stub.go:470-472
func SetNavItems(items []NavItem)

{
	navItems = items
}
F
function

RouterData

RouterData returns the values exposed to component templates.

Returns

map[string]any
router/router_stub.go:493-498
func RouterData() map[string]any

{
	return map[string]any{
		"ActivePath": activePathSig,
		"NavItems":   NavItemsMap(),
	}
}
F
function

TemplateData

TemplateData returns the values exposed to component templates.

Returns

map[string]any
router/router_stub.go:501-501
func TemplateData() map[string]any

{ return RouterData() }
F
function

ActivePath

ActivePath returns the reactive signal holding the current route path.

router/router_stub.go:504-506
func ActivePath() *state.Signal[string]

{
	return activePathSig
}
S
struct •

trailingComponent

router/router_trailing_test.go:11-11
type trailingComponent struct

Methods

Render
Method

Returns

string
func (*trailingComponent) Render() string
{ return "" }
Mount
Method
func (*trailingComponent) Mount()
{}
Unmount
Method
func (*trailingComponent) Unmount()
{}
OnMount
Method
func (*trailingComponent) OnMount()
{}
OnUnmount
Method
func (*trailingComponent) OnUnmount()
{}
GetName
Method

Returns

string
func (*trailingComponent) GetName() string
{ return "trailing" }
GetID
Method

Returns

string
func (*trailingComponent) GetID() string
{ return "" }
SetSlots
Method

Parameters

map[string]any
func (*trailingComponent) SetSlots(map[string]any)
{}
IsMounted
Method

Returns

bool
func (*trailingComponent) IsMounted() bool
{ return false }
OnParams
Method

Parameters

map[string]string
func (*trailingComponent) OnParams(map[string]string)
{}
F
function

TestNavigateTrailingSlash

Parameters

router/router_trailing_test.go:24-31
func TestNavigateTrailingSlash(t *testing.T)

{
	Reset()
	RegisterRoute(Route{Path: "/trail", Component: func() core.Component { return &trailingComponent{} }})
	Navigate("/trail/")
	if _, ok := currentComponent.(*trailingComponent); !ok {
		t.Fatalf("expected trailingComponent with trailing slash, got %T", currentComponent)
	}
}
F
function

TestNavigateTrailingSlashNotFound

Parameters

router/router_trailing_test.go:33-43
func TestNavigateTrailingSlashNotFound(t *testing.T)

{
	Reset()
	RegisterRoute(Route{Path: "/trail", Component: func() core.Component { return &trailingComponent{} }})
	called := false
	NotFoundCallback = func(string) { called = true }
	Navigate("/trail/extra")
	if !called {
		t.Fatalf("expected NotFoundCallback for extra path, got none")
	}
	NotFoundCallback = nil
}