From 670f6fbec75e105488f014c509b8fb1acfa5725e Mon Sep 17 00:00:00 2001 From: ice-rider Date: Sat, 7 Mar 2026 16:26:29 +0400 Subject: [PATCH 1/2] feat: add route guard functionality for access control Implements route guards to control navigation access based on custom conditions, similar to other popular routers. Guards can be defined per route and support both synchronous and asynchronous checks with redirect capabilities. - Add `guard` prop to RouteDefinition and Route components accepting boolean or async function - Implement `checkRouteGuard` in router context to evaluate all guards in a match chain - Create `useRouteGuard` hook for component-level guard access with pending state - Export `evaluateRouteGuard` and `normalizeGuardResult` utilities for custom implementations - Integrate guard checks with route matching to prevent rendering unauthorized routes - Add guard evaluation during preload to skip loading protected routes - Support redirect responses by automatically navigating when guard returns a string path This maintains Solid's reactive patterns while providing familiar protection mechanisms for authentication flows, feature flags, and role-based access control. --- src/index.tsx | 8 +- src/routers/components.tsx | 38 +++++++++- src/routing.ts | 145 ++++++++++++++++++++++++++++++++++++- src/types.ts | 29 ++++++++ 4 files changed, 216 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index d67cccf1..8de2b03f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,7 +12,10 @@ export { useResolvedPath, useSearchParams, useBeforeLeave, - usePreloadRoute + usePreloadRoute, + useRouteGuard, + evaluateRouteGuard, + normalizeGuardResult } from "./routing.js"; export { mergeSearchString as _mergeSearchString } from "./utils.js"; export * from "./data/index.js"; @@ -30,6 +33,9 @@ export type { RouteSectionProps, RoutePreloadFunc, RoutePreloadFuncArgs, + RouteGuardFunc, + RouteGuardFuncArgs, + RouteGuardResult, RouteDefinition, RouteDescription, RouteMatch, diff --git a/src/routers/components.tsx b/src/routers/components.tsx index 15cdaaaf..d2bf0c66 100644 --- a/src/routers/components.tsx +++ b/src/routers/components.tsx @@ -1,7 +1,7 @@ /*@refresh skip*/ import type {Component, JSX, Owner} from "solid-js"; -import {children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack} from "solid-js"; +import {children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack, createSignal, createEffect, onMount} from "solid-js"; import {getRequestEvent, isServer, type RequestEvent} from "solid-js/web"; import { createBranches, @@ -19,6 +19,7 @@ import type { RouteContext, RouteDefinition, RoutePreloadFunc, + RouteGuardFunc, RouterContext, RouterIntegration, RouteSectionProps @@ -110,9 +111,43 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) { const disposers: (() => void)[] = []; let root: RouteContext | undefined; + const [guardPending, setGuardPending] = createSignal(true); + const [guardResult, setGuardResult] = createSignal<{ allowed: boolean; redirect?: string }>({ allowed: true }); + + // Check guards when matches change + createEffect(() => { + const matches = props.routerState.matches(); + if (!matches.length) { + setGuardPending(false); + setGuardResult({ allowed: true }); + return; + } + + setGuardPending(true); + props.routerState.checkRouteGuard(matches).then(result => { + setGuardResult(result); + setGuardPending(false); + + // Handle redirect if guard failed with a redirect path + if (!result.allowed && result.redirect) { + props.routerState.navigatorFactory()(result.redirect!, { replace: true }); + } + }); + }); const routeStates = createMemo( on(props.routerState.matches, (nextMatches, prevMatches, prev: RouteContext[] | undefined) => { + // If guard check is pending or failed, don't create new route states + if (guardPending()) { + return prev || []; + } + + const result = guardResult(); + if (!result.allowed) { + // Guard failed without redirect, keep previous or return empty + return prev || []; + } + let equal = prevMatches && nextMatches.length === prevMatches.length; const next: RouteContext[] = []; for (let i = 0, len = nextMatches.length; i < len; i++) { @@ -166,6 +201,7 @@ export type RouteProps = { path?: S | S[]; children?: JSX.Element; preload?: RoutePreloadFunc; + guard?: RouteGuardFunc | boolean; matchFilters?: MatchFilters; component?: Component>; info?: Record; diff --git a/src/routing.ts b/src/routing.ts index e9ccc066..be524623 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -34,7 +34,10 @@ import type { SetParams, Submission, SearchParams, - SetSearchParams + SetSearchParams, + RouteGuardFunc, + RouteGuardFuncArgs, + RouteGuardResult } from "./types.js"; import { mockBase, @@ -273,14 +276,97 @@ export const useBeforeLeave = (listener: (e: BeforeLeaveEventArgs) => void) => { onCleanup(s); }; +/** + * A hook that evaluates a route guard and returns a reactive signal with the result. + * Useful for conditionally rendering content based on guard status. + * + * @param guard - The guard function or boolean to evaluate + * @returns A reactive signal with the guard result (allowed boolean and optional redirect) + * + * @example + * ```tsx + * function ProtectedComponent() { + * const [guardResult] = useRouteGuard(isAuthenticated); + * + * return ( + * }> + * + * + * ); + * } + * ``` + */ +export function useRouteGuard(guard: RouteGuardFunc | boolean) { + const location = useLocation(); + const params = useParams(); + const [result, setResult] = createSignal<{ allowed: boolean; redirect?: string }>({ + allowed: true + }); + const [pending, setPending] = createSignal(false); + + createRenderEffect(async () => { + setPending(true); + const guardResult = await evaluateRouteGuard(guard, { + params, + location, + intent: getIntent() || "initial" + }); + setResult(normalizeGuardResult(guardResult)); + setPending(false); + }); + + return [result, { pending }] as const; +} + +/** + * Evaluates a route guard function or boolean value. + * @param guard - The guard to evaluate (boolean or function) + * @param args - Arguments to pass to the guard function + * @returns A RouteGuardResult indicating whether access is allowed + */ +export async function evaluateRouteGuard( + guard: RouteGuardFunc | boolean | undefined, + args: RouteGuardFuncArgs +): Promise { + if (guard === undefined || guard === true) { + return true; + } + if (guard === false) { + return false; + } + if (typeof guard === "function") { + return await guard(args); + } + return guard; +} + +/** + * Normalizes a RouteGuardResult to a standardized format. + * @param result - The guard result to normalize + * @returns An object with `allowed` boolean and optional `redirect` string + */ +export function normalizeGuardResult(result: RouteGuardResult): { + allowed: boolean; + redirect?: string; +} { + if (typeof result === "boolean") { + return { allowed: result }; + } + if (typeof result === "string") { + return { allowed: false, redirect: result }; + } + return { allowed: result.allowed, redirect: result.redirect }; +} + export function createRoutes(routeDef: RouteDefinition, base: string = ""): RouteDescription[] { - const { component, preload, load, children, info } = routeDef; + const { component, preload, load, children, info, guard } = routeDef; const isLeaf = !children || (Array.isArray(children) && !children.length); const shared = { key: routeDef, component, preload: preload || load, + guard, info }; @@ -528,6 +614,29 @@ export function createRouterContext( // Create a native transition, when source updates createRenderEffect(on(source, source => transition("native", source), { defer: true })); + async function checkRouteGuard(matches: RouteMatch[]): Promise<{ allowed: boolean; redirect?: string }> { + // Evaluate all guards in the match chain (from root to leaf) + for (let i = 0; i < matches.length; i++) { + const { route, params } = matches[i]; + const guard = route.guard; + + if (guard !== undefined && guard !== true) { + const result = await evaluateRouteGuard(guard, { + params, + location, + intent: intent || "initial" + }); + + const normalized = normalizeGuardResult(result); + if (!normalized.allowed) { + return normalized; + } + } + } + + return { allowed: true }; + } + return { base: baseRoute, location, @@ -539,6 +648,7 @@ export function createRouterContext( matches, beforeLeave, preloadRoute, + checkRouteGuard, singleFlight: options.singleFlight === undefined ? true : options.singleFlight, submissions }; @@ -625,8 +735,39 @@ export function createRouterContext( const matches = getRouteMatches(branches(), url.pathname); const prevIntent = intent; intent = "preload"; + for (let match in matches) { const { route, params } = matches[match]; + + // Check guard before preloading + const guard = route.guard; + if (guard !== undefined && guard !== true) { + // For preload, we evaluate the guard but don't block if it's async + // We just skip preloading if the guard is synchronously false + const guardResult = evaluateRouteGuard(guard, { + params, + location: { + pathname: url.pathname, + search: url.search, + hash: url.hash, + query: extractSearchParams(url), + state: null, + key: "" + }, + intent: "preload" + }); + + // If guard result is synchronously false or a redirect string, skip preload + if (guardResult instanceof Promise) { + // For async guards, we still preload but the guard will be re-checked on navigation + } else { + const normalized = normalizeGuardResult(guardResult); + if (!normalized.allowed) { + continue; // Skip preloading this route + } + } + } + route.component && (route.component as MaybePreloadableComponent).preload && (route.component as MaybePreloadableComponent).preload!(); diff --git a/src/types.ts b/src/types.ts index a7b3ac63..f96ffc2e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,6 +83,32 @@ export interface RoutePreloadFuncArgs { export type RoutePreloadFunc = (args: RoutePreloadFuncArgs) => T; +/** + * Arguments passed to a route guard function. + */ +export interface RouteGuardFuncArgs { + params: Params; + location: Location; + intent: Intent; +} + +/** + * Return type for route guard functions. + * - `true`: Allow access to the route + * - `false`: Deny access (show nothing or stay on current route) + * - `string`: Redirect to the specified path + */ +export type RouteGuardResult = boolean | string | { allowed: boolean; redirect?: string }; + +/** + * Route guard function that determines if a route can be accessed. + * Can return a boolean, redirect path, or a RouteGuardResult object. + * Supports both synchronous and asynchronous guards. + */ +export type RouteGuardFunc = ( + args: RouteGuardFuncArgs +) => RouteGuardResult | Promise; + export interface RouteSectionProps { params: Params; location: Location; @@ -94,6 +120,7 @@ export type RouteDefinition = { path?: S; matchFilters?: MatchFilters; preload?: RoutePreloadFunc; + guard?: RouteGuardFunc | boolean; children?: RouteDefinition | RouteDefinition[]; component?: Component>; info?: Record; @@ -141,6 +168,7 @@ export interface RouteDescription { pattern: string; component?: Component; preload?: RoutePreloadFunc; + guard?: RouteGuardFunc | boolean; matcher: (location: string) => PathMatch | null; matchFilters?: MatchFilters; info?: Record; @@ -181,6 +209,7 @@ export interface RouterContext { parsePath(str: string): string; beforeLeave: BeforeLeaveLifecycle; preloadRoute: (url: URL, preloadData?: boolean) => void; + checkRouteGuard: (matches: RouteMatch[]) => Promise<{ allowed: boolean; redirect?: string }>; singleFlight: boolean; submissions: Signal[]>; } From 0cc6e4cee8572c28de3ba70ac9c031f1ff4562b1 Mon Sep 17 00:00:00 2001 From: ice-rider Date: Sat, 7 Mar 2026 16:46:30 +0400 Subject: [PATCH 2/2] chore: add changeset for route guards --- .changeset/dirty-ads-cry.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .changeset/dirty-ads-cry.md diff --git a/.changeset/dirty-ads-cry.md b/.changeset/dirty-ads-cry.md new file mode 100644 index 00000000..c49e0e48 --- /dev/null +++ b/.changeset/dirty-ads-cry.md @@ -0,0 +1,23 @@ +--- +"@solidjs/router": minor +--- + +Added support for route guards to control access to routes. + +Now, in route definitions, you can use the `guard` field, which accepts: + +- a boolean value; +- a synchronous or asynchronous function returning `boolean | string | { allowed: boolean; redirect?: string }`. + +**Key features:** + +- Flexible permission system with automatic redirect when returning a string or an object with `redirect`. +- Integration with preload — for protected routes, data is not loaded unnecessarily. +- New `useRouteGuard` hook for reactive access to the check status in components. +- Utilities `evaluateRouteGuard` and `normalizeGuardResult` for custom scenarios. + +**Motivation for changes:** +Previously, route protection was implemented manually inside components, leading to code duplication and not always working uniformly. Built-in guards provide a declarative and centralized way to manage access. + +**How to update code:** +For developers, this is a backward-compatible change. Existing routes will continue to work without modifications. To add protection, simply specify the `guard` prop in the `` component or in the route object (see examples in the documentation).