diff --git a/CHANGELOG.md b/CHANGELOG.md index b612fa4..cc5dc8f 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(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` 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`. @@ -29,6 +31,7 @@ The router is now built around React's transition machinery: navigations run ins ### Fixed +- 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. diff --git a/docs/content/_index.md b/docs/content/_index.md index 732bc36..f7e0f0f 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({ id: +params.id })], }, ] @@ -174,7 +174,20 @@ 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. + +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. @@ -220,7 +233,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 +262,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(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(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 +314,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(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 +{ + 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 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 `data.prepare()` returns one `PreparedHandle`; a route-level `prepare(ctx)` returns an array of them or nothing: 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 b6c051a..cc8f502 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -20,9 +20,12 @@ import { import { createMatcher, createRouter, + getRouteRedirect, + type Guard, type Mode, type NavigationInfo, type NavigateTarget, + type Matcher, type Qs, type Redirect, type Route, @@ -69,30 +72,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 = Guard /** - * 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 +105,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 +766,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 +894,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({ @@ -931,23 +938,22 @@ 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, - transformed: Route = applyTransform(matched), - source: NavigationSource = 'navigation', - ) => { + (resolvedRoute: ResolvedRoute, 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], + [commit, data], ) const beginUnmatched = useCallback(() => { @@ -985,8 +991,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 +1037,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 +1049,10 @@ export function Router({ return } - beginNavigation(matched, transformed, source) + beginNavigation(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 @@ -1059,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 @@ -1088,9 +1094,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 +1200,8 @@ interface PreparedRoute { handles: PreparedHandle[] } +type ResolvedRoute = Pick + interface InitialPreparedRoute { prepared: PreparedRoute routes: RouteDefinition[] @@ -1273,6 +1281,37 @@ 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 = 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 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 redirect or guard targeted unmatched URL "${href}"`) + } + route = redirected + } + + throw new Error('react-space-router: failed to resolve route') +} + function requireAdapter(data: DataAdapter | undefined): DataAdapter { if (!data) { throw new Error( @@ -1282,6 +1321,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 +1340,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 +1366,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..51dbfdd --- /dev/null +++ b/test/guards.test.tsx @@ -0,0 +1,252 @@ +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 Route } 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 }: Route) => ({ + 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 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('parent guards run before child redirects during initial preparation', (t) => { + setup() + g.location.href = '/old' + g.location.pathname = '/old' + const { adapter, prepared } = makeAdapter() + + const routes = [ + { + 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('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' + 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..6233f7e 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)]) }, ) @@ -86,13 +87,17 @@ 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 = [ { 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 +112,17 @@ 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.is(requestCalls, 0, 'function-valued requests must remain opaque') 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]) + t.is(requestCalls, 0, 'the router must never invoke array items') }) test.serial('prefetchable:false vetoes speculation but still prepares on navigation', async (t) => { @@ -144,7 +148,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 +172,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 +182,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(() => {