From 799467c793f8569290c1feec51725a31e1ef8fc3 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 15 Aug 2026 12:55:42 +0100 Subject: [PATCH 1/5] Prepare guarded routes with opaque data requests --- CHANGELOG.md | 3 + docs/content/_index.md | 28 ++++++- src/index.tsx | 129 ++++++++++++++++++++--------- test/guards.test.tsx | 180 +++++++++++++++++++++++++++++++++++++++++ test/prepare.test.tsx | 2 +- test/queries.test.tsx | 44 +++++----- 6 files changed, 322 insertions(+), 64 deletions(-) create mode 100644 test/guards.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b612fa4..a1b7069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ The router is now built around React's transition machinery: navigations run ins ### New +- Synchronous parent-first route `guard(ctx)` checks can redirect before resolver loading, data preparation, prefetching, or rendering. This lets applications resolve already-known admission policy without coupling the router to authentication or a data layer. +- Route `queries` are now flat arrays of opaque data-layer requests, declared statically or derived from route context. The router forwards each request unchanged to `data.prepare(request)` and `data.prefetch(request)`, leaving argument binding and validation to the adapter. - Suspense-aware navigation: the previous route stays on screen and interactive while the destination suspends. Pending state comes for free — `usePending()` for "is a navigation happening", `usePendingRoute()` for "where to", and a per-link `data-pending` attribute (plus `useLinkState(to)`) for "was it this link" — for clicks, programmatic navigation, and browser back/forward alike. - `usePreviousRoute()` for the route preceding the current successful commit. It is available on a destination's first render and ignores pending, suspended, superseded, and unmatched destinations. - Code-split routes via `resolver: () => import('./Page')`, preloaded at navigation time and rendered through `React.lazy`. @@ -29,6 +31,7 @@ The router is now built around React's transition machinery: navigations run ins ### Fixed +- Cold loads and speculative prefetches now resolve existing route redirects before preparing the redirect source, matching normal navigation behavior. - Navigation blocking now distinguishes hash-mode route traversal (`#/route`) from ordinary fragments, so Back/Forward guards work in hash-routed applications without taking ownership of `#section` links. - Rejected route resolvers no longer remain permanently cached; resetting an error boundary can retry transient chunk-load failures, while the documentation explains full-page reload recovery for stale deployments. - App-created navigation to a cross-page hash fragment now scrolls to the destination element after commit, falling back to the top when it is absent. Back/Forward and same-page hash links remain browser-owned. diff --git a/docs/content/_index.md b/docs/content/_index.md index 732bc36..5306d78 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -166,7 +166,7 @@ const routes = [ { path: '/issues/:id', resolver: () => import('./pages/IssueDetail'), - queries: ({ params }) => [[issueDetail, { id: +params.id }]], + queries: ({ params }) => [issueDetail.withArgs({ id: +params.id })], }, ] @@ -174,7 +174,7 @@ const routes = [ ``` -On navigation, each query runs through `data.prepare(def, args)` and the returned handles stay pinned until the route changes. On prefetch, the same query runs through `data.prefetch(def, args)` and the return value is ignored. Resolver chunks are warmed too. +On navigation, each opaque query request runs through `data.prepare(request)` and the returned handles stay pinned until the route changes. On prefetch, the same request runs through `data.prefetch(request)` and the return value is ignored. The data layer owns argument binding and validation; the router only owns when requests are prepared and released. Resolver chunks are warmed too. `` means cancellable hover intent (50ms by default) plus immediate focus/touch. Use `prefetch='visible'` for viewport-based prefetching, `` to make prefetching the default for all links, and `prefetch={false}` to opt one link out. Configure the hover delay with ``; `0` restores immediate hover prefetching. A route segment can set `prefetchable: false` to skip its own speculative work while still preparing normally on real navigation. Other matched segments still prefetch unless they also opt out. @@ -220,7 +220,7 @@ Props: - `sync` if `true`, the underlying space-router fires synchronous transitions (useful in tests). - `transformRoute(route)` an optional pure, synchronous route transform. See [Route transform](#route-transform). - `transformQuery(query, { to, sourceRoute, targetRoute })` an optional pure, synchronous mapping for the query of app-created destinations. It returns the query serialized by the configured `qs` codec, or `null` to remove the query. See [Query transform](#query-transform). -- `data` a data adapter of shape `{ prepare(def, args), prefetch(def, args) }` that bridges route `queries` to a data layer (see [Prefetching](#prefetching)). `prepare` returns a `PreparedHandle`; `prefetch` warms speculatively. figbird's kit satisfies this directly. Should be referentially stable; required only if a route uses `queries`. +- `data` a data adapter of shape `{ prepare(request), prefetch(request) }` that bridges route `queries` to a data layer (see [Prefetching](#prefetching)). Requests are opaque to the router; `prepare` returns a `PreparedHandle` and `prefetch` warms speculatively. Should be referentially stable; required only if a route uses `queries`. - `prefetchLinks` default prefetch trigger for every link: `true`/`'hover'` or `'visible'`. Individual links override with their own `prefetch` prop, including `prefetch={false}` to opt out. Off by default. - `prefetchHoverDelayMs` cancellable hover-intent delay for prefetching links. Focus and touchstart remain immediate. Default: `50`; set to `0` for immediate hover prefetching. - `pendingDelayMs` how long `` holds the previous route before rendering its fallback during an in-flight navigation. Default: `1000`. @@ -249,9 +249,10 @@ Pass the array to ``. Each definition can use these fiel - `path` an optional, complete URL pattern. See [Path patterns](#path-patterns). - `redirect` a navigation target, or `(route) => target`. Redirects replace the current history entry before the route reaches React. See [Redirects](#redirects). +- `guard(ctx)` a synchronous admission check. Return a navigation target to redirect before preparation, or `undefined` to admit the route. See [Route guards](#route-guards). - `component` a React component to render. It also accepts an ESM-default module shape such as `{ default: Component }`. - `resolver` a dynamic import such as `() => import('./Screen')`. The router preloads it at navigation time and renders it with `React.lazy`. A cold import suspends at the destination's Suspense boundary. -- `queries(ctx)` declares the route's data needs once as `[def, args]` pairs. The `` adapter prepares them on navigation and prefetches them during speculation. Requires a `data` adapter. See [Prefetching](#prefetching). +- `queries` declares the route's data needs once as a static array of opaque requests, or as `queries(ctx)` when requests depend on route context. The `` adapter prepares them on navigation and prefetches them during speculation. Requires a `data` adapter. See [Prefetching](#prefetching). - `prepare(ctx)` is the low-level alternative to `queries` for navigation. It receives `{ pathname, url, params, query }` and returns handles that stay pinned for the committed navigation. Setup is synchronous and must not throw; surface request errors later through the data cache's Suspense read path. - `prefetch(ctx)` is the low-level alternative to `queries` for prefetching. It runs when a prefetching link warms the route, may run at any frequency, and ignores its return value. - `prefetchable` set to `false` skips speculative resolver, `prefetch`, and query work for this segment while preserving normal preparation during navigation. Other matched segments still prefetch unless they also opt out. It overrides an explicit `` for this segment. @@ -300,6 +301,25 @@ A function receives the matched route and can preserve params, query, or other s Redirects are resolved before component loading, data preparation, or React rendering and always replace the current history entry. A redirect can be declared on any segment in a matched nested branch. Redirect loops throw after ten redirects. +#### Route guards + +Use `guard(ctx)` when admission depends on synchronous application state that is already known before the router mounts. Guards receive `{ pathname, url, params, query }` and run parent-first: + +```js +{ + guard: ({ url }) => session.user + ? undefined + : { pathname: '/login', query: { returnPath: url } }, + routes: [ + { path: '/settings', component: Settings }, + ], +} +``` + +Like redirects, guards resolve before resolver loading, route preparation, query preparation, speculative prefetching, or rendering. Redirected destinations are resolved through their own guards, with loops rejected after ten redirects. + +Guards may run during rendering and prefetching, so they must be pure, synchronous, and safe to repeat. Resolve asynchronous prerequisites such as restoring a persisted session before mounting ``; use guards only to apply the resulting synchronous policy. + #### Prepared handles `data.prepare()` returns one `PreparedHandle`; a route-level `prepare(ctx)` returns an array of them or nothing: diff --git a/src/index.tsx b/src/index.tsx index b6c051a..7fd437b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -23,6 +23,7 @@ import { type Mode, type NavigationInfo, type NavigateTarget, + type Matcher, type Qs, type Redirect, type Route, @@ -69,30 +70,30 @@ export type RoutePrepare = (ctx: RoutePrepareContext) => readonly PreparedHandle export type RoutePrefetch = (ctx: RoutePrepareContext) => unknown /** - * A query to warm: a `[definition, args]` pair. Opaque to the router — it - * flows straight through the `` adapter. `args` is optional for - * arg-less queries. + * Declares a route segment's data needs *once*, independent of lifecycle. The + * router forwards each opaque request through the `` adapter — + * `prepare` on navigation, `prefetch` on speculation — so a single declaration + * drives both. Static arrays cover fixed requests; a resolver can derive + * requests from route context. Requires a `data` adapter. */ -export type QueryDescriptor = readonly [def: unknown, args?: unknown] +export type RouteQueries = readonly unknown[] | ((ctx: RoutePrepareContext) => readonly unknown[]) /** - * Declares a route segment's data needs *once*, independent of lifecycle. The - * router runs each descriptor through the `` adapter — `prepare` - * on navigation, `prefetch` on speculation — so a single declaration drives - * both. Requires a `data` adapter; a `queries` route without one throws. + * Synchronous route admission check. Return a destination to redirect before + * resolver or data preparation begins; return `undefined` to admit the route. + * Guards run parent-first and may redirect only to another route in this router. */ -export type RouteQueries = (ctx: RoutePrepareContext) => readonly QueryDescriptor[] +export type RouteGuard = (ctx: RoutePrepareContext) => To | void /** - * Bridges route `queries` to a data layer, co-designed with figbird's kit the - * same way `PreparedHandle` was: `prepare(def, args)` returns a pinnable handle - * (caller-managed lease), `prefetch(def, args)` warms speculatively and its - * return is ignored. figbird's `prepare`/`prefetch` satisfy this shape as-is — - * ``. + * Bridges route `queries` to a data layer. Requests are deliberately opaque: + * the adapter owns their shape, argument binding, and validation. `prepare` + * returns a caller-managed lease; `prefetch` warms speculatively and its return + * is ignored. */ export interface DataAdapter { - prepare(def: unknown, args: unknown): PreparedHandle - prefetch(def: unknown, args: unknown): unknown + prepare(request: unknown): PreparedHandle + prefetch(request: unknown): unknown } export type ResolverModule = { default: ComponentType } @@ -102,6 +103,7 @@ export type RouteResolver = () => Promise export interface RouteData { path?: string redirect?: Redirect + guard?: RouteGuard component?: ComponentType | { default: ComponentType } | null resolver?: RouteResolver prepare?: RoutePrepare @@ -762,11 +764,10 @@ export interface RouterProps { */ transformQuery?: TransformQuery /** - * Data adapter bridging route `queries` to a data layer. `prepare(def, - * args)` returns a pinnable `PreparedHandle`, `prefetch(def, args)` warms - * speculatively. figbird's kit satisfies this directly: `data={{ prepare, - * prefetch }}`. Should be referentially stable (a module-level object or - * the figbird instance). Required only if any route uses `queries`. + * Data adapter bridging route `queries` to a data layer. Each opaque request + * is passed to `prepare(request)` or `prefetch(request)` unchanged. Should be + * referentially stable (a module-level object or data-layer instance). + * Required only if any route uses `queries`. */ data?: DataAdapter /** @@ -891,6 +892,10 @@ export function Router({ }, []) const matcher = useMemo(() => createMatcher(routes, { qs }), [routes, qs]) + const resolveRoute = useCallback( + (matched: Route) => resolveRouteBeforePrepare(matched, matcher, router, applyTransform), + [matcher, router, applyTransform], + ) const blockers = useMemo(() => createNavigationBlockerRegistry(router), [router]) const targetRouter = useTargetRouter({ @@ -936,18 +941,21 @@ export function Router({ const beginNavigation = useCallback( ( matched: Route, - transformed: Route = applyTransform(matched), + resolvedRoute: ResolvedRoute = resolveRoute(matched), source: NavigationSource = 'navigation', ) => { const superseded = pendingPrepared.current pendingPrepared.current = null if (superseded) releaseHandles(superseded.handles) - const prepared = { route: transformed, matched, handles: prepareRoute(transformed, data) } + const prepared = { + ...resolvedRoute, + handles: prepareRoute(resolvedRoute.route, data), + } pendingPrepared.current = prepared commit(prepared, source) }, - [applyTransform, commit, data], + [resolveRoute, commit, data], ) const beginUnmatched = useCallback(() => { @@ -985,8 +993,8 @@ export function Router({ const initialRoute = useMemo | null>(() => { if (resolved) return null const matched = matcher.match(router.getUrl()) - return matched ? { route: applyTransform(matched), matched } : null - }, [resolved, router, matcher, applyTransform]) + return matched ? resolveRoute(matched) : null + }, [resolved, router, matcher, resolveRoute]) // Prepare the initial destination during render so its components can read // seeded data on their first render and code/data loading overlaps. The @@ -1031,11 +1039,11 @@ export function Router({ // Transform fresh on every navigation because application state may // change the result even when the matched URL is identical. - const transformed = applyTransform(matched) + const resolvedRoute = resolveRoute(matched) // The listener's first emit adopts the preparation created during the // initial render. Every later same-URL emit is a real navigation. - if (!hasResolvedRoute.current && committed.current?.route.url === transformed.url) { + if (!hasResolvedRoute.current && committed.current?.route.url === resolvedRoute.route.url) { const superseded = pendingPrepared.current pendingPrepared.current = null if (superseded) releaseHandles(superseded.handles) @@ -1043,10 +1051,10 @@ export function Router({ return } - beginNavigation(matched, transformed, source) + beginNavigation(matched, resolvedRoute, source) } return router.listen(routes, transition) - }, [router, routes, applyTransform, commit, beginNavigation, beginUnmatched]) + }, [router, routes, resolveRoute, commit, beginNavigation, beginUnmatched]) useEffect(() => { if (previousRoutes.current === routes) return @@ -1088,9 +1096,9 @@ export function Router({ const prefetchResolved = useCallback( (target: ResolvedTarget) => { const matched = target.routeUrl === null ? undefined : matcher.match(target.routeUrl) - if (matched) prefetchRoute(applyTransform(matched), data) + if (matched) prefetchRoute(resolveRoute(matched).route, data) }, - [matcher, applyTransform, data], + [matcher, resolveRoute, data], ) const targets = useMemo( @@ -1194,6 +1202,8 @@ interface PreparedRoute { handles: PreparedHandle[] } +type ResolvedRoute = Pick + interface InitialPreparedRoute { prepared: PreparedRoute routes: RouteDefinition[] @@ -1273,6 +1283,49 @@ function routePrepareContext(route: Route): RoutePrepareContext { return { pathname: route.pathname, url: route.url, params: route.params, query: route.query } } +const MAX_ROUTE_REDIRECTS = 10 + +function resolveRouteBeforePrepare( + initiallyMatched: Route, + matcher: Matcher, + router: SpaceRouter, + transform: (route: Route) => Route, +): ResolvedRoute { + let route = initiallyMatched + + for (let redirects = 0; redirects <= MAX_ROUTE_REDIRECTS; redirects++) { + const target = routeRedirect(route) + if (target === undefined) { + return { route: transform(route), matched: initiallyMatched } + } + if (redirects === MAX_ROUTE_REDIRECTS) { + throw new Error('react-space-router: too many route redirects or guards') + } + + const href = router.href(target, route) + const routeUrl = router.routeUrl(href) + const redirected = routeUrl === null ? undefined : matcher.match(routeUrl) + if (!redirected) { + throw new Error(`react-space-router: route guard redirected to unmatched URL "${href}"`) + } + route = redirected + } + + throw new Error('react-space-router: failed to resolve route') +} + +function routeRedirect(route: Route): To | undefined { + const ctx = routePrepareContext(route) + for (const segment of route.data) { + if (segment.redirect) { + return typeof segment.redirect === 'function' ? segment.redirect(route) : segment.redirect + } + const guarded = segment.guard?.(ctx) + if (guarded !== undefined) return guarded + } + return undefined +} + function requireAdapter(data: DataAdapter | undefined): DataAdapter { if (!data) { throw new Error( @@ -1282,6 +1335,10 @@ function requireAdapter(data: DataAdapter | undefined): DataAdapter { return data } +function resolveRouteQueries(queries: RouteQueries, ctx: RoutePrepareContext): readonly unknown[] { + return typeof queries === 'function' ? queries(ctx) : queries +} + function prepareRoute(route: Route, data: DataAdapter | undefined): PreparedHandle[] { const ctx = routePrepareContext(route) const handles: PreparedHandle[] = [] @@ -1297,8 +1354,8 @@ function prepareRoute(route: Route, data: DataAdapter | undefined): P } if (segment.queries) { const adapter = requireAdapter(data) - for (const [def, args] of segment.queries(ctx)) { - handles.push(adapter.prepare(def, args)) + for (const request of resolveRouteQueries(segment.queries, ctx)) { + handles.push(adapter.prepare(request)) } } } @@ -1323,8 +1380,8 @@ function prefetchRoute(route: Route, data: DataAdapter | undefined) { segment.prefetch?.(ctx) if (segment.queries) { const adapter = requireAdapter(data) - for (const [def, args] of segment.queries(ctx)) { - adapter.prefetch(def, args) + for (const request of resolveRouteQueries(segment.queries, ctx)) { + adapter.prefetch(request) } } } diff --git a/test/guards.test.tsx b/test/guards.test.tsx new file mode 100644 index 0000000..0c3175e --- /dev/null +++ b/test/guards.test.tsx @@ -0,0 +1,180 @@ +import test from 'ava' +import { act } from 'react' +import ReactDOM from 'react-dom/client' +import { renderToString } from 'react-dom/server' +import { Link, Router, Routes, useSpaceRouter, type DataAdapter, type RoutePrepareContext } from '../src/index.tsx' +import { g, setup } from './helpers.ts' + +function makeAdapter() { + const prepared: unknown[] = [] + const prefetched: unknown[] = [] + const adapter: DataAdapter = { + prepare(definition) { + prepared.push(definition) + return { release() {} } + }, + prefetch(definition) { + prefetched.push(definition) + }, + } + return { adapter, prepared, prefetched } +} + +test.serial('guards redirect the initial route before resolver and query preparation', (t) => { + setup() + g.location.href = '/private' + g.location.pathname = '/private' + let privateResolverCalls = 0 + const { adapter, prepared } = makeAdapter() + + const routes = [ + { + guard: ({ url }: RoutePrepareContext) => ({ + pathname: '/login', + query: { returnPath: url }, + }), + routes: [ + { + path: '/private', + resolver: () => { + privateResolverCalls++ + return Promise.resolve({ default: () =>
Private
}) + }, + queries: ['private'], + }, + ], + }, + { + path: '/login', + component: () =>
Login
, + queries: ['login'], + }, + ] + + const html = renderToString( + + + , + ) + + t.is(html, '
Login
') + t.is(privateResolverCalls, 0) + t.deepEqual(prepared, ['login']) +}) + +test.serial('guards are re-evaluated for navigation after application state changes', async (t) => { + setup() + const root = document.getElementById('root') + const { adapter, prepared } = makeAdapter() + let admitted = false + let router: ReturnType + + function CaptureRouter() { + router = useSpaceRouter() + return null + } + + const routes = [ + { path: '/', component: () =>
Home
}, + { + guard: () => (admitted ? undefined : '/login'), + routes: [ + { + path: '/private', + component: () =>
Private
, + queries: ['private'], + }, + ], + }, + { path: '/login', component: () =>
Login
}, + ] + + await act(async () => { + ReactDOM.createRoot(root).render( + + + + , + ) + }) + + await act(async () => router.navigate('/private')) + t.is(root.textContent, 'Login') + t.deepEqual(prepared, []) + + admitted = true + await act(async () => router.navigate('/private')) + t.is(root.textContent, 'Private') + t.deepEqual(prepared, ['private']) +}) + +test.serial('prefetch resolves guards before warming route data', async (t) => { + setup() + const root = document.getElementById('root') + const { adapter, prefetched } = makeAdapter() + + const routes = [ + { + path: '/', + component: () => ( + + Private + + ), + }, + { + guard: () => '/login', + routes: [ + { + path: '/private', + component: () =>
Private
, + queries: ['private'], + }, + ], + }, + { + path: '/login', + component: () =>
Login
, + queries: ['login'], + }, + ] + + await act(async () => { + ReactDOM.createRoot(root).render( + + + , + ) + }) + + act(() => { + root.querySelector('a')!.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true })) + }) + + t.deepEqual(prefetched, ['login']) +}) + +test.serial('initial static redirects resolve before source preparation', (t) => { + setup() + g.location.href = '/old' + g.location.pathname = '/old' + const { adapter, prepared } = makeAdapter() + + const routes = [ + { path: '/old', redirect: '/new', queries: ['old'] }, + { + path: '/new', + component: () =>
New
, + queries: ['new'], + }, + ] + + const html = renderToString( + + + , + ) + + t.is(html, '
New
') + t.deepEqual(prepared, ['new']) +}) diff --git a/test/prepare.test.tsx b/test/prepare.test.tsx index 113e2ec..0248a82 100644 --- a/test/prepare.test.tsx +++ b/test/prepare.test.tsx @@ -50,7 +50,7 @@ test.serial('Router releases acquired handles when later route preparation fails routes: [ { path: '/broken', - queries: () => [['missing-adapter']], + queries: ['missing-adapter'], component: () => null, }, ], diff --git a/test/queries.test.tsx b/test/queries.test.tsx index 7419716..b2f61d7 100644 --- a/test/queries.test.tsx +++ b/test/queries.test.tsx @@ -11,18 +11,18 @@ function hover(el: Element) { el.dispatchEvent(new window.MouseEvent('mouseover', { bubbles: true })) } -// A minimal figbird-shaped adapter that records how it was driven. +// A minimal data adapter that records the opaque requests it receives. function makeAdapter() { - const prepared: Array<[unknown, unknown]> = [] - const prefetched: Array<[unknown, unknown]> = [] - const released: Array<[unknown, unknown]> = [] + const prepared: unknown[] = [] + const prefetched: unknown[] = [] + const released: unknown[] = [] const adapter: DataAdapter = { - prepare(def, args) { - prepared.push([def, args]) - return { promise: Promise.resolve(), release: () => released.push([def, args]) } + prepare(request) { + prepared.push(request) + return { promise: Promise.resolve(), release: () => released.push(request) } }, - prefetch(def, args) { - prefetched.push([def, args]) + prefetch(request) { + prefetched.push(request) }, } return { adapter, prepared, prefetched, released } @@ -41,6 +41,7 @@ test.serial( const root = document.getElementById('root') const { adapter, prepared, prefetched } = makeAdapter() const issueDetail = { name: 'issueDetail' } + const issueRequest = (id: number) => ({ definition: issueDetail, args: { id } }) const routes = [ { @@ -54,7 +55,7 @@ test.serial( { path: '/issues/:id', component: () =>
Issue
, - queries: ({ params }: RoutePrepareContext) => [[issueDetail, { id: +params.id }]], + queries: ({ params }: RoutePrepareContext) => [issueRequest(+params.id)], }, ] @@ -71,14 +72,14 @@ test.serial( act(() => { hover(window.document.querySelector('a')!) }) - t.deepEqual(prefetched, [[issueDetail, { id: 42 }]]) + t.deepEqual(prefetched, [issueRequest(42)]) t.deepEqual(prepared, []) // committing the navigation runs the same declaration through prepare await act(async () => { currentRouter.navigate('/issues/42') }) - t.deepEqual(prepared, [[issueDetail, { id: 42 }]]) + t.deepEqual(prepared, [issueRequest(42)]) }, ) @@ -91,8 +92,8 @@ test.serial('queries handles are pinned on navigation and released on the next', const routes = [ { path: '/', component: () =>
Home
}, - { path: '/a', component: () =>
A
, queries: () => [[a, { k: 1 }]] }, - { path: '/b', component: () =>
B
, queries: () => [[b, { k: 2 }]] }, + { path: '/a', component: () =>
A
, queries: [a] }, + { path: '/b', component: () =>
B
, queries: [b] }, ] await act(async () => { @@ -107,18 +108,15 @@ test.serial('queries handles are pinned on navigation and released on the next', await act(async () => { currentRouter.navigate('/a') }) - t.deepEqual(prepared, [[a, { k: 1 }]]) + t.deepEqual(prepared, [a]) t.deepEqual(released, []) await act(async () => { currentRouter.navigate('/b') }) - t.deepEqual(prepared, [ - [a, { k: 1 }], - [b, { k: 2 }], - ]) + t.deepEqual(prepared, [a, b]) // the /a lease is released once /b commits - t.deepEqual(released, [[a, { k: 1 }]]) + t.deepEqual(released, [a]) }) test.serial('prefetchable:false vetoes speculation but still prepares on navigation', async (t) => { @@ -144,7 +142,7 @@ test.serial('prefetchable:false vetoes speculation but still prepares on navigat resolverCalls++ return Promise.resolve({ default: () =>
Heavy
}) }, - queries: () => [[heavy, { big: true }]], + queries: [heavy], }, ] @@ -168,7 +166,7 @@ test.serial('prefetchable:false vetoes speculation but still prepares on navigat await act(async () => { currentRouter.navigate('/heavy') }) - t.deepEqual(prepared, [[heavy, { big: true }]]) + t.deepEqual(prepared, [heavy]) t.is(resolverCalls, 1) }) @@ -178,7 +176,7 @@ test.serial('a queries route without a data adapter throws loudly', (t) => { g.location.pathname = '/x' const root = document.getElementById('root') - const routes = [{ path: '/x', component: () =>
X
, queries: () => [[{}, {}]] }] + const routes = [{ path: '/x', component: () =>
X
, queries: [{}] }] const err = t.throws(() => { act(() => { From 9b67c4a93510a6461552cfac33ac1c6cc2ba2170 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 15 Aug 2026 13:34:27 +0100 Subject: [PATCH 2/5] Refine route admission and opaque query contracts --- CHANGELOG.md | 2 +- docs/content/_index.md | 28 +++++++++++++------- src/index.tsx | 18 +++---------- test/queries.test.tsx | 8 +++++- test/{guards.test.tsx => redirects.test.tsx} | 23 +++++++++------- 5 files changed, 43 insertions(+), 36 deletions(-) rename test/{guards.test.tsx => redirects.test.tsx} (83%) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b7069..5fe6c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The router is now built around React's transition machinery: navigations run ins ### New -- Synchronous parent-first route `guard(ctx)` checks can redirect before resolver loading, data preparation, prefetching, or rendering. This lets applications resolve already-known admission policy without coupling the router to authentication or a data layer. +- Functional route redirects may return `undefined` to admit a route, enabling synchronous parent-first admission checks before resolver loading, data preparation, prefetching, or rendering. - Route `queries` are now flat arrays of opaque data-layer requests, declared statically or derived from route context. The router forwards each request unchanged to `data.prepare(request)` and `data.prefetch(request)`, leaving argument binding and validation to the adapter. - Suspense-aware navigation: the previous route stays on screen and interactive while the destination suspends. Pending state comes for free — `usePending()` for "is a navigation happening", `usePendingRoute()` for "where to", and a per-link `data-pending` attribute (plus `useLinkState(to)`) for "was it this link" — for clicks, programmatic navigation, and browser back/forward alike. - `usePreviousRoute()` for the route preceding the current successful commit. It is available on a destination's first render and ignores pending, suspended, superseded, and unmatched destinations. diff --git a/docs/content/_index.md b/docs/content/_index.md index 5306d78..5174b11 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -166,7 +166,7 @@ const routes = [ { path: '/issues/:id', resolver: () => import('./pages/IssueDetail'), - queries: ({ params }) => [issueDetail.withArgs({ id: +params.id })], + queries: ({ params }) => [issueDetail({ id: +params.id })], }, ] @@ -176,6 +176,19 @@ const routes = [ On navigation, each opaque query request runs through `data.prepare(request)` and the returned handles stay pinned until the route changes. On prefetch, the same request runs through `data.prefetch(request)` and the return value is ignored. The data layer owns argument binding and validation; the router only owns when requests are prepared and released. Resolver chunks are warmed too. +The router invokes the outer `queries(ctx)` resolver, never the values inside its returned array. Function-valued requests remain opaque. This lets a data layer such as Figbird accept argumentless definitions directly while application code binds route-dependent definitions itself: + +```js +// Static, argumentless definitions are forwarded as-is. +queries: [customFieldsQuery, rolesQuery] + +// Only this outer resolver is invoked by the router. +queries: ({ params }) => [ + personQuery({ personId: params.id }), + permissionsQuery({ personId: params.id }), +] +``` + `` means cancellable hover intent (50ms by default) plus immediate focus/touch. Use `prefetch='visible'` for viewport-based prefetching, `` to make prefetching the default for all links, and `prefetch={false}` to opt one link out. Configure the hover delay with ``; `0` restores immediate hover prefetching. A route segment can set `prefetchable: false` to skip its own speculative work while still preparing normally on real navigation. Other matched segments still prefetch unless they also opt out. For unusual cases, use route-level `prepare(ctx)` / `prefetch(ctx)` directly, or call `usePrefetch()` from your own trigger. @@ -248,8 +261,7 @@ const routes = [ Pass the array to ``. Each definition can use these fields: - `path` an optional, complete URL pattern. See [Path patterns](#path-patterns). -- `redirect` a navigation target, or `(route) => target`. Redirects replace the current history entry before the route reaches React. See [Redirects](#redirects). -- `guard(ctx)` a synchronous admission check. Return a navigation target to redirect before preparation, or `undefined` to admit the route. See [Route guards](#route-guards). +- `redirect` a navigation target, or `(route) => target | undefined`. Returning `undefined` admits the segment and continues checking its children. Redirects replace the current history entry before the route reaches React. See [Redirects](#redirects). - `component` a React component to render. It also accepts an ESM-default module shape such as `{ default: Component }`. - `resolver` a dynamic import such as `() => import('./Screen')`. The router preloads it at navigation time and renders it with `React.lazy`. A cold import suspends at the destination's Suspense boundary. - `queries` declares the route's data needs once as a static array of opaque requests, or as `queries(ctx)` when requests depend on route context. The `` adapter prepares them on navigation and prefetches them during speculation. Requires a `data` adapter. See [Prefetching](#prefetching). @@ -301,13 +313,11 @@ A function receives the matched route and can preserve params, query, or other s Redirects are resolved before component loading, data preparation, or React rendering and always replace the current history entry. A redirect can be declared on any segment in a matched nested branch. Redirect loops throw after ten redirects. -#### Route guards - -Use `guard(ctx)` when admission depends on synchronous application state that is already known before the router mounts. Guards receive `{ pathname, url, params, query }` and run parent-first: +For synchronous admission policy, return `undefined` from a functional redirect to admit the route. Redirects run parent-first, so a parent can protect an entire nested branch: ```js { - guard: ({ url }) => session.user + redirect: ({ url }) => session.user ? undefined : { pathname: '/login', query: { returnPath: url } }, routes: [ @@ -316,9 +326,7 @@ Use `guard(ctx)` when admission depends on synchronous application state that is } ``` -Like redirects, guards resolve before resolver loading, route preparation, query preparation, speculative prefetching, or rendering. Redirected destinations are resolved through their own guards, with loops rejected after ten redirects. - -Guards may run during rendering and prefetching, so they must be pure, synchronous, and safe to repeat. Resolve asynchronous prerequisites such as restoring a persisted session before mounting ``; use guards only to apply the resulting synchronous policy. +Functional redirects may run during rendering and prefetching, so they must be pure, synchronous, and safe to repeat. Resolve asynchronous prerequisites such as restoring a persisted session before mounting ``; use the redirect only to apply the resulting synchronous policy. Redirected destinations are checked in turn, with loops rejected after ten redirects. #### Prepared handles diff --git a/src/index.tsx b/src/index.tsx index 7fd437b..0bedfee 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -78,13 +78,6 @@ export type RoutePrefetch = (ctx: RoutePrepareContext) => unknown */ export type RouteQueries = readonly unknown[] | ((ctx: RoutePrepareContext) => readonly unknown[]) -/** - * Synchronous route admission check. Return a destination to redirect before - * resolver or data preparation begins; return `undefined` to admit the route. - * Guards run parent-first and may redirect only to another route in this router. - */ -export type RouteGuard = (ctx: RoutePrepareContext) => To | void - /** * Bridges route `queries` to a data layer. Requests are deliberately opaque: * the adapter owns their shape, argument binding, and validation. `prepare` @@ -103,7 +96,6 @@ export type RouteResolver = () => Promise export interface RouteData { path?: string redirect?: Redirect - guard?: RouteGuard component?: ComponentType | { default: ComponentType } | null resolver?: RouteResolver prepare?: RoutePrepare @@ -1299,14 +1291,14 @@ function resolveRouteBeforePrepare( return { route: transform(route), matched: initiallyMatched } } if (redirects === MAX_ROUTE_REDIRECTS) { - throw new Error('react-space-router: too many route redirects or guards') + throw new Error('react-space-router: too many route redirects') } const href = router.href(target, route) const routeUrl = router.routeUrl(href) const redirected = routeUrl === null ? undefined : matcher.match(routeUrl) if (!redirected) { - throw new Error(`react-space-router: route guard redirected to unmatched URL "${href}"`) + throw new Error(`react-space-router: route redirected to unmatched URL "${href}"`) } route = redirected } @@ -1315,13 +1307,11 @@ function resolveRouteBeforePrepare( } function routeRedirect(route: Route): To | undefined { - const ctx = routePrepareContext(route) for (const segment of route.data) { if (segment.redirect) { - return typeof segment.redirect === 'function' ? segment.redirect(route) : segment.redirect + const target = typeof segment.redirect === 'function' ? segment.redirect(route) : segment.redirect + if (target !== undefined) return target } - const guarded = segment.guard?.(ctx) - if (guarded !== undefined) return guarded } return undefined } diff --git a/test/queries.test.tsx b/test/queries.test.tsx index b2f61d7..6233f7e 100644 --- a/test/queries.test.tsx +++ b/test/queries.test.tsx @@ -87,7 +87,11 @@ test.serial('queries handles are pinned on navigation and released on the next', setup() const root = document.getElementById('root') const { adapter, prepared, released } = makeAdapter() - const a = { name: 'a' } + let requestCalls = 0 + const a = () => { + requestCalls++ + return { name: 'unexpected' } + } const b = { name: 'b' } const routes = [ @@ -109,6 +113,7 @@ test.serial('queries handles are pinned on navigation and released on the next', currentRouter.navigate('/a') }) t.deepEqual(prepared, [a]) + t.is(requestCalls, 0, 'function-valued requests must remain opaque') t.deepEqual(released, []) await act(async () => { @@ -117,6 +122,7 @@ test.serial('queries handles are pinned on navigation and released on the next', t.deepEqual(prepared, [a, b]) // the /a lease is released once /b commits t.deepEqual(released, [a]) + t.is(requestCalls, 0, 'the router must never invoke array items') }) test.serial('prefetchable:false vetoes speculation but still prepares on navigation', async (t) => { diff --git a/test/guards.test.tsx b/test/redirects.test.tsx similarity index 83% rename from test/guards.test.tsx rename to test/redirects.test.tsx index 0c3175e..2bbc77e 100644 --- a/test/guards.test.tsx +++ b/test/redirects.test.tsx @@ -2,7 +2,7 @@ import test from 'ava' import { act } from 'react' import ReactDOM from 'react-dom/client' import { renderToString } from 'react-dom/server' -import { Link, Router, Routes, useSpaceRouter, type DataAdapter, type RoutePrepareContext } from '../src/index.tsx' +import { Link, Router, Routes, useSpaceRouter, type DataAdapter, type Route } from '../src/index.tsx' import { g, setup } from './helpers.ts' function makeAdapter() { @@ -20,7 +20,7 @@ function makeAdapter() { return { adapter, prepared, prefetched } } -test.serial('guards redirect the initial route before resolver and query preparation', (t) => { +test.serial('conditional redirects resolve the initial route before resolver and query preparation', (t) => { setup() g.location.href = '/private' g.location.pathname = '/private' @@ -29,7 +29,7 @@ test.serial('guards redirect the initial route before resolver and query prepara const routes = [ { - guard: ({ url }: RoutePrepareContext) => ({ + redirect: ({ url }: Route) => ({ pathname: '/login', query: { returnPath: url }, }), @@ -62,7 +62,7 @@ test.serial('guards redirect the initial route before resolver and query prepara t.deepEqual(prepared, ['login']) }) -test.serial('guards are re-evaluated for navigation after application state changes', async (t) => { +test.serial('conditional redirects are re-evaluated after application state changes', async (t) => { setup() const root = document.getElementById('root') const { adapter, prepared } = makeAdapter() @@ -77,7 +77,7 @@ test.serial('guards are re-evaluated for navigation after application state chan const routes = [ { path: '/', component: () =>
Home
}, { - guard: () => (admitted ? undefined : '/login'), + redirect: () => (admitted ? undefined : '/login'), routes: [ { path: '/private', @@ -91,7 +91,7 @@ test.serial('guards are re-evaluated for navigation after application state chan await act(async () => { ReactDOM.createRoot(root).render( - + , @@ -108,7 +108,7 @@ test.serial('guards are re-evaluated for navigation after application state chan t.deepEqual(prepared, ['private']) }) -test.serial('prefetch resolves guards before warming route data', async (t) => { +test.serial('prefetch resolves conditional redirects before warming route data', async (t) => { setup() const root = document.getElementById('root') const { adapter, prefetched } = makeAdapter() @@ -123,7 +123,7 @@ test.serial('prefetch resolves guards before warming route data', async (t) => { ), }, { - guard: () => '/login', + redirect: () => '/login', routes: [ { path: '/private', @@ -154,14 +154,17 @@ test.serial('prefetch resolves guards before warming route data', async (t) => { t.deepEqual(prefetched, ['login']) }) -test.serial('initial static redirects resolve before source preparation', (t) => { +test.serial('an admitted parent continues to a child redirect before initial preparation', (t) => { setup() g.location.href = '/old' g.location.pathname = '/old' const { adapter, prepared } = makeAdapter() const routes = [ - { path: '/old', redirect: '/new', queries: ['old'] }, + { + redirect: () => undefined, + routes: [{ path: '/old', redirect: '/new', queries: ['old'] }], + }, { path: '/new', component: () =>
New
, From d95e9e0bcd5d84741daf8b463e9c815d29741cf1 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 15 Aug 2026 14:12:12 +0100 Subject: [PATCH 3/5] Adopt Space Router route guards --- CHANGELOG.md | 2 +- docs/content/_index.md | 13 ++++-- package-lock.json | 8 ++-- package.json | 2 +- src/index.tsx | 26 +++++------ test/{redirects.test.tsx => guards.test.tsx} | 46 ++++++++++++++++---- 6 files changed, 66 insertions(+), 31 deletions(-) rename test/{redirects.test.tsx => guards.test.tsx} (76%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe6c4f..a968be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The router is now built around React's transition machinery: navigations run ins ### New -- Functional route redirects may return `undefined` to admit a route, enabling synchronous parent-first admission checks before resolver loading, data preparation, prefetching, or rendering. +- Synchronous parent-first route `guard(route)` checks can redirect before resolver loading, data preparation, prefetching, or rendering. This lets applications resolve already-known admission policy without coupling the router to authentication or a data layer. - Route `queries` are now flat arrays of opaque data-layer requests, declared statically or derived from route context. The router forwards each request unchanged to `data.prepare(request)` and `data.prefetch(request)`, leaving argument binding and validation to the adapter. - Suspense-aware navigation: the previous route stays on screen and interactive while the destination suspends. Pending state comes for free — `usePending()` for "is a navigation happening", `usePendingRoute()` for "where to", and a per-link `data-pending` attribute (plus `useLinkState(to)`) for "was it this link" — for clicks, programmatic navigation, and browser back/forward alike. - `usePreviousRoute()` for the route preceding the current successful commit. It is available on a destination's first render and ignores pending, suspended, superseded, and unmatched destinations. diff --git a/docs/content/_index.md b/docs/content/_index.md index 5174b11..f7e0f0f 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -261,7 +261,8 @@ const routes = [ Pass the array to ``. Each definition can use these fields: - `path` an optional, complete URL pattern. See [Path patterns](#path-patterns). -- `redirect` a navigation target, or `(route) => target | undefined`. Returning `undefined` admits the segment and continues checking its children. Redirects replace the current history entry before the route reaches React. See [Redirects](#redirects). +- `redirect` a navigation target, or `(route) => target`. Redirects replace the current history entry before the route reaches React. See [Redirects](#redirects). +- `guard(route)` a synchronous admission check. Return a navigation target to redirect before preparation, or `undefined` to admit the route. See [Route guards](#route-guards). - `component` a React component to render. It also accepts an ESM-default module shape such as `{ default: Component }`. - `resolver` a dynamic import such as `() => import('./Screen')`. The router preloads it at navigation time and renders it with `React.lazy`. A cold import suspends at the destination's Suspense boundary. - `queries` declares the route's data needs once as a static array of opaque requests, or as `queries(ctx)` when requests depend on route context. The `` adapter prepares them on navigation and prefetches them during speculation. Requires a `data` adapter. See [Prefetching](#prefetching). @@ -313,11 +314,13 @@ A function receives the matched route and can preserve params, query, or other s Redirects are resolved before component loading, data preparation, or React rendering and always replace the current history entry. A redirect can be declared on any segment in a matched nested branch. Redirect loops throw after ten redirects. -For synchronous admission policy, return `undefined` from a functional redirect to admit the route. Redirects run parent-first, so a parent can protect an entire nested branch: +#### Route guards + +Use `guard(route)` when admission depends on synchronous application state that is already known before the router mounts. Guards receive the matched route, run parent-first, and can protect an entire nested branch: ```js { - redirect: ({ url }) => session.user + guard: ({ url }) => session.user ? undefined : { pathname: '/login', query: { returnPath: url } }, routes: [ @@ -326,7 +329,9 @@ For synchronous admission policy, return `undefined` from a functional redirect } ``` -Functional redirects may run during rendering and prefetching, so they must be pure, synchronous, and safe to repeat. Resolve asynchronous prerequisites such as restoring a persisted session before mounting ``; use the redirect only to apply the resulting synchronous policy. Redirected destinations are checked in turn, with loops rejected after ten redirects. +Like redirects, guards resolve before resolver loading, route preparation, query preparation, speculative prefetching, or rendering. Redirected destinations are resolved through their own guards, with loops rejected after ten redirects or guards. + +Guards may run during rendering and prefetching, so they must be pure, synchronous, and safe to repeat. Resolve asynchronous prerequisites such as restoring a persisted session before mounting ``; use guards only to apply the resulting synchronous policy. #### Prepared handles diff --git a/package-lock.json b/package-lock.json index 0a2f33b..6e1c117 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0-pre.7", "license": "ISC", "dependencies": { - "space-router": "^2.0.0" + "space-router": "^2.1.0" }, "devDependencies": { "@playwright/test": "^1.61.1", @@ -4592,9 +4592,9 @@ } }, "node_modules/space-router": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/space-router/-/space-router-2.0.0.tgz", - "integrity": "sha512-yBMjsaoY47NdlfancgbWw/8WZ23QPPhb9sYdQP0JgLh2lXiobv1roPCyNn8TACyL2sTPAKAqh/zAybQMLM61fA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/space-router/-/space-router-2.1.0.tgz", + "integrity": "sha512-R5CtixEFBIZdZ8rqLhJl+qw4OXvnQ2ljwnYwnk3yz0+DrQtiGANK8XHwqRHZcXR0lUL64Tk2/KVZbZuH/X2PJA==", "license": "ISC", "engines": { "node": ">=18" diff --git a/package.json b/package.json index ef6a29e..aa8dbfd 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "release:docs": "npm run docs:build && gh-pages -d docs/public" }, "dependencies": { - "space-router": "^2.0.0" + "space-router": "^2.1.0" }, "devDependencies": { "@playwright/test": "^1.61.1", diff --git a/src/index.tsx b/src/index.tsx index 0bedfee..81907c4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,6 +20,8 @@ import { import { createMatcher, createRouter, + getRouteRedirect, + type Guard, type Mode, type NavigationInfo, type NavigateTarget, @@ -78,6 +80,13 @@ export type RoutePrefetch = (ctx: RoutePrepareContext) => unknown */ export type RouteQueries = readonly unknown[] | ((ctx: RoutePrepareContext) => readonly unknown[]) +/** + * Synchronous route admission check. Return a destination to redirect before + * resolver or data preparation begins; return `undefined` to admit the route. + * Guards run parent-first and may redirect only to another route in this router. + */ +export type RouteGuard = Guard + /** * Bridges route `queries` to a data layer. Requests are deliberately opaque: * the adapter owns their shape, argument binding, and validation. `prepare` @@ -96,6 +105,7 @@ export type RouteResolver = () => Promise export interface RouteData { path?: string redirect?: Redirect + guard?: RouteGuard component?: ComponentType | { default: ComponentType } | null resolver?: RouteResolver prepare?: RoutePrepare @@ -1286,19 +1296,19 @@ function resolveRouteBeforePrepare( let route = initiallyMatched for (let redirects = 0; redirects <= MAX_ROUTE_REDIRECTS; redirects++) { - const target = routeRedirect(route) + const target = getRouteRedirect(route) if (target === undefined) { return { route: transform(route), matched: initiallyMatched } } if (redirects === MAX_ROUTE_REDIRECTS) { - throw new Error('react-space-router: too many route redirects') + throw new Error('react-space-router: too many route redirects or guards') } const href = router.href(target, route) const routeUrl = router.routeUrl(href) const redirected = routeUrl === null ? undefined : matcher.match(routeUrl) if (!redirected) { - throw new Error(`react-space-router: route redirected to unmatched URL "${href}"`) + throw new Error(`react-space-router: route redirect or guard targeted unmatched URL "${href}"`) } route = redirected } @@ -1306,16 +1316,6 @@ function resolveRouteBeforePrepare( throw new Error('react-space-router: failed to resolve route') } -function routeRedirect(route: Route): To | undefined { - for (const segment of route.data) { - if (segment.redirect) { - const target = typeof segment.redirect === 'function' ? segment.redirect(route) : segment.redirect - if (target !== undefined) return target - } - } - return undefined -} - function requireAdapter(data: DataAdapter | undefined): DataAdapter { if (!data) { throw new Error( diff --git a/test/redirects.test.tsx b/test/guards.test.tsx similarity index 76% rename from test/redirects.test.tsx rename to test/guards.test.tsx index 2bbc77e..f3131c7 100644 --- a/test/redirects.test.tsx +++ b/test/guards.test.tsx @@ -20,7 +20,7 @@ function makeAdapter() { return { adapter, prepared, prefetched } } -test.serial('conditional redirects resolve the initial route before resolver and query preparation', (t) => { +test.serial('guards redirect the initial route before resolver and query preparation', (t) => { setup() g.location.href = '/private' g.location.pathname = '/private' @@ -29,7 +29,7 @@ test.serial('conditional redirects resolve the initial route before resolver and const routes = [ { - redirect: ({ url }: Route) => ({ + guard: ({ url }: Route) => ({ pathname: '/login', query: { returnPath: url }, }), @@ -62,7 +62,7 @@ test.serial('conditional redirects resolve the initial route before resolver and t.deepEqual(prepared, ['login']) }) -test.serial('conditional redirects are re-evaluated after application state changes', async (t) => { +test.serial('guards are re-evaluated after application state changes', async (t) => { setup() const root = document.getElementById('root') const { adapter, prepared } = makeAdapter() @@ -77,7 +77,7 @@ test.serial('conditional redirects are re-evaluated after application state chan const routes = [ { path: '/', component: () =>
Home
}, { - redirect: () => (admitted ? undefined : '/login'), + guard: () => (admitted ? undefined : '/login'), routes: [ { path: '/private', @@ -108,7 +108,7 @@ test.serial('conditional redirects are re-evaluated after application state chan t.deepEqual(prepared, ['private']) }) -test.serial('prefetch resolves conditional redirects before warming route data', async (t) => { +test.serial('prefetch resolves guards before warming route data', async (t) => { setup() const root = document.getElementById('root') const { adapter, prefetched } = makeAdapter() @@ -123,7 +123,7 @@ test.serial('prefetch resolves conditional redirects before warming route data', ), }, { - redirect: () => '/login', + guard: () => '/login', routes: [ { path: '/private', @@ -154,7 +154,7 @@ test.serial('prefetch resolves conditional redirects before warming route data', t.deepEqual(prefetched, ['login']) }) -test.serial('an admitted parent continues to a child redirect before initial preparation', (t) => { +test.serial('parent guards run before child redirects during initial preparation', (t) => { setup() g.location.href = '/old' g.location.pathname = '/old' @@ -162,9 +162,39 @@ test.serial('an admitted parent continues to a child redirect before initial pre const routes = [ { - redirect: () => undefined, + guard: () => '/login', routes: [{ path: '/old', redirect: '/new', queries: ['old'] }], }, + { + path: '/login', + component: () =>
Login
, + queries: ['login'], + }, + { + path: '/new', + component: () =>
New
, + queries: ['new'], + }, + ] + + const html = renderToString( + + + , + ) + + t.is(html, '
Login
') + t.deepEqual(prepared, ['login']) +}) + +test.serial('initial static redirects resolve before source preparation', (t) => { + setup() + g.location.href = '/old' + g.location.pathname = '/old' + const { adapter, prepared } = makeAdapter() + + const routes = [ + { path: '/old', redirect: '/new', queries: ['old'] }, { path: '/new', component: () =>
New
, From b351c8188cb55a3a5f6cbb593302d5900147057b Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 15 Aug 2026 14:21:19 +0100 Subject: [PATCH 4/5] Simplify guarded navigation --- src/index.tsx | 18 +++++++----------- test/guards.test.tsx | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 81907c4..cc8f502 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -938,14 +938,10 @@ export function Router({ ) // Begin a fresh navigation: release the superseded pending preparation, - // prepare the transformed destination, and commit that exact prepared - // object. URL identity is never used to transfer lease ownership. + // prepare the resolved destination, and commit that exact prepared object. + // URL identity is never used to transfer lease ownership. const beginNavigation = useCallback( - ( - matched: Route, - resolvedRoute: ResolvedRoute = resolveRoute(matched), - source: NavigationSource = 'navigation', - ) => { + (resolvedRoute: ResolvedRoute, source: NavigationSource = 'navigation') => { const superseded = pendingPrepared.current pendingPrepared.current = null if (superseded) releaseHandles(superseded.handles) @@ -957,7 +953,7 @@ export function Router({ pendingPrepared.current = prepared commit(prepared, source) }, - [resolveRoute, commit, data], + [commit, data], ) const beginUnmatched = useCallback(() => { @@ -1053,7 +1049,7 @@ export function Router({ return } - beginNavigation(matched, resolvedRoute, source) + beginNavigation(resolvedRoute, source) } return router.listen(routes, transition) }, [router, routes, resolveRoute, commit, beginNavigation, beginUnmatched]) @@ -1069,9 +1065,9 @@ export function Router({ const currentUrl = currRoute?.url ?? committed.current?.route.url ?? router.getUrl() const matched = currentUrl ? matcher.match(currentUrl) : undefined - if (matched) beginNavigation(matched) + if (matched) beginNavigation(resolveRoute(matched)) else beginUnmatched() - }, [routes, router, routerOpts.mode, matcher, beginNavigation, beginUnmatched, currRoute?.url]) + }, [routes, router, routerOpts.mode, matcher, resolveRoute, beginNavigation, beginUnmatched, currRoute?.url]) useEffect(() => { const prepared = pendingPrepared.current diff --git a/test/guards.test.tsx b/test/guards.test.tsx index f3131c7..51dbfdd 100644 --- a/test/guards.test.tsx +++ b/test/guards.test.tsx @@ -187,6 +187,45 @@ test.serial('parent guards run before child redirects during initial preparation t.deepEqual(prepared, ['login']) }) +test.serial('guards reject unmatched destinations before preparation', (t) => { + setup() + g.location.href = '/private' + g.location.pathname = '/private' + + const routes = [{ path: '/private', guard: () => '/missing', component: () =>
Private
}] + + const error = t.throws(() => + renderToString( + + + , + ), + ) + + t.regex(error!.message, /guard targeted unmatched URL/) +}) + +test.serial('guard cycles fail before preparation', (t) => { + setup() + g.location.href = '/a' + g.location.pathname = '/a' + + const routes = [ + { path: '/a', guard: () => '/b' }, + { path: '/b', guard: () => '/a' }, + ] + + const error = t.throws(() => + renderToString( + + + , + ), + ) + + t.regex(error!.message, /too many route redirects or guards/) +}) + test.serial('initial static redirects resolve before source preparation', (t) => { setup() g.location.href = '/old' From 339b7870838692828ea2c5f91c8b4b380aac3761 Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 15 Aug 2026 14:28:59 +0100 Subject: [PATCH 5/5] Reframe 1.0 changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a968be1..cc5dc8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ The router is now built around React's transition machinery: navigations run ins ### New - Synchronous parent-first route `guard(route)` checks can redirect before resolver loading, data preparation, prefetching, or rendering. This lets applications resolve already-known admission policy without coupling the router to authentication or a data layer. -- Route `queries` are now flat arrays of opaque data-layer requests, declared statically or derived from route context. The router forwards each request unchanged to `data.prepare(request)` and `data.prefetch(request)`, leaving argument binding and validation to the adapter. +- Route `queries` accept flat arrays of opaque data-layer requests, declared statically or derived from route context. The router forwards each request unchanged to `data.prepare(request)` and `data.prefetch(request)`, leaving argument binding and validation to the adapter. - Suspense-aware navigation: the previous route stays on screen and interactive while the destination suspends. Pending state comes for free — `usePending()` for "is a navigation happening", `usePendingRoute()` for "where to", and a per-link `data-pending` attribute (plus `useLinkState(to)`) for "was it this link" — for clicks, programmatic navigation, and browser back/forward alike. - `usePreviousRoute()` for the route preceding the current successful commit. It is available on a destination's first render and ignores pending, suspended, superseded, and unmatched destinations. - Code-split routes via `resolver: () => import('./Page')`, preloaded at navigation time and rendered through `React.lazy`. @@ -31,7 +31,7 @@ The router is now built around React's transition machinery: navigations run ins ### Fixed -- Cold loads and speculative prefetches now resolve existing route redirects before preparing the redirect source, matching normal navigation behavior. +- Cold loads and speculative prefetches resolve route redirects before preparing the redirect source, matching normal navigation behavior. - Navigation blocking now distinguishes hash-mode route traversal (`#/route`) from ordinary fragments, so Back/Forward guards work in hash-routed applications without taking ownership of `#section` links. - Rejected route resolvers no longer remain permanently cached; resetting an error boundary can retry transient chunk-load failures, while the documentation explains full-page reload recovery for stale deployments. - App-created navigation to a cross-page hash fragment now scrolls to the destination element after commit, falling back to the top when it is absent. Back/Forward and same-page hash links remain browser-owned.