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). 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[]>; }