Client navigation
<Link> keeps progressive enhancement: before hydration it is an anchor, and
after hydration a plain in-app click fetches page data and swaps the page tree.
Use navigateTo() for a path, href() to resolve a named route, and refresh()
to fetch the current App, Layout, and Page loaders again without pushing history.
Show pending navigation state
Section titled “Show pending navigation state”useIsNavigating() returns the router’s current boolean state. It is true
through a client navigation, refresh(), or a locale change, and false during
SSR. A component mounted after a transition starts reads the current state
immediately; there is no delay. When a newer transition supersedes an earlier
one, completion of the earlier work cannot clear the newer pending state.
A page action that redirects keeps useIsSubmitting() true through this same
navigation, until the redirected page is on screen.
Use it for a pending indicator, not a percent-complete claim:
import { useIsNavigating } from "@warlock.js/web";import "./navigation-progress.css";
export function NavigationProgress() { const isNavigating = useIsNavigating();
return ( <div aria-hidden={!isNavigating} aria-label="Loading next page" className={ isNavigating ? "navigation-progress navigation-progress--active" : "navigation-progress" } /> );}.navigation-progress { position: fixed; inset: 0 0 auto; height: 3px; transform: scaleX(0); transform-origin: left;}
.navigation-progress--active { transform: scaleX(0.7); transition: transform 160ms ease-out;}The boolean means that router work is active. It does not expose network or rendering percentage, so do not turn it into a numeric progress meter.
Events remain for analytics
Section titled “Events remain for analytics”routerEvents remains available for analytics and post-navigation observation.
Use useIsNavigating() for reactive UI state; do not infer pending state from
events, because an event callback can miss a transition that began before the
component subscribed.
Programmatic navigation
Section titled “Programmatic navigation”import { apiHref, href, navigateTo, refresh } from "@warlock.js/web";
navigateTo({ name: "products.details", params: { id: "42" }, query: { tab: "reviews" } });navigateTo("/cart", { replace: true });
const productUrl = href("products.details", { params: { id: "42" }, query: { tab: "reviews" } });const reviewsEndpoint = apiHref("products.reviews", { params: { id: "42" }, query: { page: 2 } });
await refresh();navigateTo() accepts a path or a { name, params, query } route target.
Before the client runtime is connected (for example, a click before hydration
finishes), it falls back to a real browser navigation, so you never need to
write window.location.assign yourself. It returns false only on the server,
or when the runtime declines a same-origin URL.
href() resolves named page routes through the server-published route table.
It takes either positional arguments (href(name, params, query)) or an
object (href(name, { params, query })). apiHref() does the same for named
API routes, which is useful for fetch URLs. Both accept only the names in the
current page’s route table; see
Form submission. Generated declarations in
.warlock/typings/web-routes.d.ts make page names and required path parameters
type-safe when they are available; runtime validation remains active before
generation. refresh() leaves history unchanged
and resolves false when fresh data did not reach the screen.
Type a Link wrapper
Section titled “Type a Link wrapper”LinkProps is exported from @warlock.js/web when a shared component should
accept the exact same destination contract as <Link>:
import { Link, type LinkProps } from "@warlock.js/web";
export function NavigationItem(props: LinkProps) { return <Link {...props} />;}This keeps generated named-route and parameter checking intact. Do not redeclare destination props as broad strings in a wrapper.
To take props away, use LinkPropsWithout<Keys> rather than
Omit<LinkProps, Keys>. LinkProps is a union (route name with its params,
literal URL, email, tel), and a plain Omit collapses it to the keys the
members share, losing the required-params check. LinkPropsWithout omits from
each member (DistributiveOmit<T, Keys> is exported for other unions):
import { Link, type LinkPropsWithout } from "@warlock.js/web";
export function MenuLink(props: LinkPropsWithout<"className">) { return <Link {...props} className="menu-link" />;}A route name that only exists at runtime (a menu entry, a shortcut descriptor
read from data) narrows with isRouteName(name), which checks the published
route table, so it can go to <Link to> or href() without a cast:
import { isRouteName, Link } from "@warlock.js/web";
function ShortcutLink({ route, label }: { route: string; label: string }) { return isRouteName(route) ? <Link to={route}>{label}</Link> : null;}Refresh triggers and shared shell data
Section titled “Refresh triggers and shared shell data”Refresh lifecycle events (onNavigating, onNavigated, onNavigationError)
carry reason: "refresh" for an explicit refresh(), "live" for live-data
invalidation, "poll" for polling/reconnect catch-up, and "focus" for window
focus catch-up on live pages. Ordinary route navigations omit reason.
import { refresh, routerEvents } from "@warlock.js/web";
const stop = routerEvents.onNavigated((event) => { if (event.reason === "refresh") console.log("Refresh complete");});await refresh(); // explicit/user refreshawait refresh({ reason: "poll" }); // app-owned background revalidationstop();Background reasons bypass page caches. Built-in live updates still wait for
navigation and unsent form edits; explicit refreshes remain user requests.
RefreshReason and RefreshOptions are exported types. Filter background
reasons explicitly instead of treating every history replace as background.
Each successful client page/shell swap installs and recursively freezes that
payload’s shared snapshot before rendering. Retained components using
useShared() subscribe to the new snapshot, including across consecutive shell
switches. Write shared values in server middleware on each request; client
writes remain refused.