From 17894259c1690f5a452d5c2de3a04a0a413ac125 Mon Sep 17 00:00:00 2001 From: smikhalevski Date: Fri, 12 Jul 2024 21:33:04 +0300 Subject: [PATCH] Refactored outlets --- src/main/Link.tsx | 4 +- src/main/Navigation.ts | 24 +- src/main/Outlet.tsx | 331 +++++++++++++++--- src/main/OutletController.tsx | 110 ------ src/main/PathnameAdapter.ts | 86 +++-- src/main/Route.ts | 192 ++++++---- src/main/Router.tsx | 112 +++--- src/main/createRoute.ts | 15 +- .../{ => history}/createBrowserHistory.ts | 39 +-- src/main/{ => history}/createMemoryHistory.ts | 28 +- src/main/history/types.ts | 65 ++++ .../{ => history}/urlSearchParamsAdapter.ts | 4 +- src/main/history/useHistorySubscription.ts | 24 ++ src/main/history/utils.ts | 21 ++ src/main/hooks.ts | 98 ------ src/main/index.ts | 37 +- src/main/matchRoutes.ts | 10 +- src/main/notFound.ts | 4 +- src/main/redirect.ts | 13 +- src/main/types.ts | 152 +++----- src/main/useNavigation.ts | 18 + src/main/utils.ts | 41 --- src/test/PathnameAdapter.test.ts | 68 ++-- src/test/createRoute.test.ts | 16 +- .../urlSearchParamsAdapter.test.ts | 2 +- src/test/matchRoutes.test.ts | 2 +- typedoc.json | 10 +- 27 files changed, 861 insertions(+), 665 deletions(-) delete mode 100644 src/main/OutletController.tsx rename src/main/{ => history}/createBrowserHistory.ts (62%) rename src/main/{ => history}/createMemoryHistory.ts (53%) create mode 100644 src/main/history/types.ts rename src/main/{ => history}/urlSearchParamsAdapter.ts (90%) create mode 100644 src/main/history/useHistorySubscription.ts create mode 100644 src/main/history/utils.ts delete mode 100644 src/main/hooks.ts create mode 100644 src/main/useNavigation.ts rename src/test/{ => history}/urlSearchParamsAdapter.test.ts (91%) diff --git a/src/main/Link.tsx b/src/main/Link.tsx index f8a937f..cb71e0a 100644 --- a/src/main/Link.tsx +++ b/src/main/Link.tsx @@ -1,6 +1,6 @@ import React, { HTMLAttributes, MouseEvent, ReactElement, useEffect } from 'react'; -import { useNavigation } from './hooks'; import { LocationOptions, To } from './types'; +import { useNavigation } from './useNavigation'; /** * Props of the {@link Link} component. @@ -62,3 +62,5 @@ export function Link(props: LinkProps): ReactElement { /> ); } + +Link.displayName = 'Link'; diff --git a/src/main/Navigation.ts b/src/main/Navigation.ts index ba80c13..f618f53 100644 --- a/src/main/Navigation.ts +++ b/src/main/Navigation.ts @@ -1,7 +1,13 @@ -import { toLocation } from './utils'; import { matchRoutes } from './matchRoutes'; import { Router, RouterProps } from './Router'; import { To } from './types'; +import { toLocation } from './utils'; + +// export interface RouteSnapshot { +// params: Params; +// data: Data; +// isLoading: boolean; +// } /** * Provides components a way to trigger router navigation. @@ -15,6 +21,18 @@ export class Navigation { */ constructor(private _router: Router) {} + // get isLoading(): boolean { + // return false; + // } + // + // isRendered(route: Route): boolean { + // return false; + // } + // + // get(route: Route): RouteSnapshot { + // return null!; + // } + /** * Triggers {@link RouterProps.onPush} with the requested location. * @@ -49,7 +67,7 @@ export class Navigation { */ prefetch(to: To): boolean { const location = toLocation(to); - const { routes, context } = this._router.props as RouterProps; + const { routes, context } = this._router.props; const routeMatches = matchRoutes(location.pathname, location.searchParams, routes); @@ -57,7 +75,7 @@ export class Navigation { return false; } for (const routeMatch of routeMatches) { - routeMatch.route.prefetch(routeMatch.params, context); + routeMatch.route.loader(routeMatch.params, context); } return true; } diff --git a/src/main/Outlet.tsx b/src/main/Outlet.tsx index 068ddeb..80ae320 100644 --- a/src/main/Outlet.tsx +++ b/src/main/Outlet.tsx @@ -1,22 +1,174 @@ -import React, { Component, createContext, FC, ReactNode, Suspense } from 'react'; -import { OutletController } from './OutletController'; +import React, { + Component, + ComponentType, + createContext, + createElement, + memo, + ReactElement, + ReactNode, + Suspense, +} from 'react'; +import { NotFoundError } from './notFound'; +import { Route } from './Route'; +import { RouterProps } from './Router'; +import { LoadingAppearance } from './types'; +import { isPromiseLike } from './utils'; /** - * The current outlet controller. Hooks use this context to access route matches. + * A content rendered in an {@link Outlet}. */ -export const OutletControllerContext = createContext(null); +export interface OutletContent { + /** + * A content of an {@link Outlet} that is rendered by a {@link component}. + */ + child: OutletContent | null; + + /** + * A component to render. + */ + component: ComponentType | undefined; + loadingComponent: ComponentType | undefined; + errorComponent: ComponentType | undefined; + notFoundComponent: ComponentType | undefined; + loadingAppearance: LoadingAppearance | undefined; + route: Route | null; + params: unknown; + data: unknown; + + /** + * Triggered before an {@link Outlet} renders a {@link component}. Throw a promise or a loading error here to trigger + * a suspense boundary. + */ + suspend?(): void; + + /** + * Abort the content loading to prevent UI from changing. + */ + abort?(): void; +} + +export class NotFoundOutletContent implements OutletContent { + child: OutletContent | null = null; + + component; + loadingComponent; + errorComponent; + notFoundComponent; + loadingAppearance: LoadingAppearance | undefined; + route: Route | null = null; + params: unknown; + data: unknown; + + constructor(props: RouterProps) { + this.loadingComponent = props.loadingComponent; + this.errorComponent = props.errorComponent; + this.component = this.notFoundComponent = props.notFoundComponent; + } +} + +export class RouteOutletContent implements OutletContent { + component; + loadingComponent; + errorComponent; + notFoundComponent; + loadingAppearance; + data; + + protected _promise; + protected _hasError; + protected _error; + + constructor( + public child: OutletContent | null, + public route: Route, + public params: unknown, + context: unknown + ) { + this.component = undefined; + this.loadingComponent = route.loadingComponent; + this.errorComponent = route.errorComponent; + this.notFoundComponent = route.notFoundComponent; + this.loadingAppearance = route.loadingAppearance; + + this.data = this._promise = this._error = undefined; + this._hasError = false; + + let content; + + try { + content = route.loader(params, context); + } catch (error) { + this._hasError = true; + this._error = error; + return; + } + + if (isPromiseLike(content)) { + const promise = content.then( + content => { + if (this._promise === promise) { + this._promise = undefined; + this.component = content.component; + this.data = content.data; + } + }, + error => { + if (this._promise === promise) { + this._promise = undefined; + this._hasError = true; + this._error = error; + } + } + ); -OutletControllerContext.displayName = 'OutletControllerContext'; + this._promise = promise; + } else { + this.component = content.component; + this.data = content.data; + } + } -export const NestedOutletControllerContext = createContext(null); + suspend(): void { + if (this._promise !== undefined) { + throw this._promise; + } + if (this._hasError) { + throw this._error; + } + } -NestedOutletControllerContext.displayName = 'NestedOutletControllerContext'; + abort(): void { + this._promise = undefined; + } +} +/** + * The content of the current outlet. Used by hooks. + */ +export const OutletContentContext = createContext(null); + +OutletContentContext.displayName = 'OutletContentContext'; + +/** + * The content of the child outlet. Used by outlets. + */ +export const ChildOutletContentContext = createContext(null); + +ChildOutletContentContext.displayName = 'ChildOutletContentContext'; + +/** + * Props of an {@link Outlet}. + */ export interface OutletProps { /** - * Children that are rendered if an {@link Outlet} doesn't have any content to render. + * A fallback that is rendered if there's no content to render in an {@link Outlet}. */ children?: ReactNode; + + /** + * @internal + */ + ref?: never; } interface OutletState { @@ -25,96 +177,167 @@ interface OutletState { } /** - * Renders a {@link Route} provided by an enclosing {@link Router}. + * Renders a content of a route provided by a {@link Router}. */ export class Outlet extends Component { /** * @internal */ - static contextType = NestedOutletControllerContext; + static displayName = 'Outlet'; /** * @internal */ - static getDerivedStateFromError(error: unknown): Partial | null { - return { hasError: true, error }; - } + static contextType = ChildOutletContentContext; + + /** + * @internal + */ + declare context: OutletContent | null; /** + * A content that is currently rendered on the screen. + * * @internal */ - declare context: OutletController | null; + _renderedContent; - private _prevController; - private _controller; + /** + * A content that an outlet must render. + * + * @internal + */ + _content; /** + * `true` if an error must be rendered. + * * @internal */ - constructor(props: OutletProps, context: OutletController | null) { + _hasError = false; + + /** + * @internal + */ + constructor(props: OutletProps) { super(props); this.state = { hasError: false, error: undefined }; - this._prevController = this._controller = context; - this._OutletContent.displayName = 'OutletContent'; + this._renderedContent = this._content = this.context; } /** * @internal */ - componentDidUpdate(_prevProps: Readonly, _prevState: Readonly, _snapshot?: unknown): void { - if (this.state.hasError) { - this.setState({ hasError: false, error: undefined }); - } + static getDerivedStateFromError(error: unknown): Partial | null { + return { hasError: true, error }; } /** * @internal */ - render() { - if (this.context !== this._controller) { - this._controller?.abort(); - this._controller = this.context; + componentDidUpdate(_prevProps: Readonly, _prevState: Readonly, _snapshot?: any): void { + if (this._hasError || !this.state.hasError) { + return; } + this.setState({ hasError: false, error: undefined }); + } - if (this._controller === null) { - this._prevController = null; - return this.props.children; - } + /** + * @internal + */ + render(): ReactElement { + this._hasError = this.state.hasError; - if (this.state.hasError) { - this._controller.setError(this.state.error); + if (this._content !== this.context) { + // A new content was provided + this._content = this.context; + + // Prevent a rendered content from being updated + this._renderedContent?.abort?.(); + + if (this._content === null || this._content.loadingAppearance === 'loading') { + // Use new content to render a loading component + this._renderedContent = this._content; + this._hasError = false; + } } return ( - }> - + + } + > + ); } +} - private _OutletContent: FC<{ isSuspendable: boolean }> = ({ isSuspendable }) => { - let controller = this._controller!; +interface OutletSuspenseProps { + outlet: Outlet; + isSuspendable: boolean; +} - if (isSuspendable) { - controller.suspend(); - this._prevController = controller; - } else { - const prevController = controller.route?.['_pendingBehavior'] === 'fallback' ? controller : this._prevController; +function OutletSuspense({ outlet, isSuspendable }: OutletSuspenseProps): ReactNode { + let content = outlet._renderedContent; - if (prevController === null) { - return this.props.children; - } - controller = prevController; - } + if (content === null) { + return outlet.props.children; + } - return ( - - - {controller.node} - - + if (isSuspendable) { + content = outlet._content!; + + content.suspend?.(); + + outlet._renderedContent = content; + outlet._hasError = false; + } + + if (outlet._hasError) { + return renderContent( + content, + null, + outlet.state.error instanceof NotFoundError ? content.notFoundComponent : content.errorComponent ); - }; + } + + if (content.component === undefined) { + // Component is being loaded + return renderContent(content, null, content.loadingComponent); + } + + return renderContent(content, content.child, content.component); } + +OutletSuspense.displayName = 'OutletSuspense'; + +function renderContent( + content: OutletContent | null, + childContent: OutletContent | null, + component: ComponentType | undefined +): ReactElement | null { + return component === undefined ? null : ( + + + + + + ); +} + +const Memo = memo<{ component: ComponentType }>( + props => createElement(props.component), + (prevProps, nextProps) => prevProps.component === nextProps.component +); + +Memo.displayName = 'Memo'; diff --git a/src/main/OutletController.tsx b/src/main/OutletController.tsx deleted file mode 100644 index a8072d3..0000000 --- a/src/main/OutletController.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { ReactNode } from 'react'; -import { isPromiseLike } from './utils'; -import { NotFoundError } from './notFound'; -import { Route } from './Route'; -import { Location } from './types'; - -export class OutletController { - nestedController: OutletController | null = null; - route: Route | null = null; - params: any = undefined; - node: ReactNode = undefined; - data: any = undefined; - error: any = undefined; - hasError = false; - - /** - * A promise that is resolved when route content and data are loaded. - */ - protected _promise: Promise | null = null; - - constructor(readonly location: Location) {} - - /** - * Suspends rendering if route content and data are still being loaded. - */ - suspend(): void { - if (this._promise !== null) { - throw this._promise; - } - } - - /** - * Aborts the loading of the route content and data. - */ - abort(): void { - this._promise = null; - } - - /** - * Loads a route content and data. - */ - load( - route: Route, - params: Params, - context: Context - ): void { - this.abort(); - - this.route = route; - this.params = params; - - let node; - let data; - - try { - node = route['_contentRenderer'](); - data = route['_dataLoader']?.(params, context); - } catch (error) { - this.setError(error); - return; - } - - if (isPromiseLike(node) || isPromiseLike(data)) { - this.node = route['_pendingNode']; - this.data = this.error = undefined; - this.hasError = false; - - const promise = Promise.all([node, data]).then( - ([node, data]) => { - if (this._promise !== promise) { - return; - } - this.node = node; - this.data = data; - this.error = undefined; - this.hasError = false; - this._promise = null; - }, - error => { - if (this._promise !== promise) { - return; - } - this.setError(error); - this._promise = null; - } - ); - - this._promise = promise; - return; - } - - this.node = node; - this.data = data; - this.error = undefined; - this.hasError = false; - } - - setError(error: unknown): void { - if (this.hasError && this.error === error) { - return; - } - - this.abort(); - - this.node = this.route?.[error instanceof NotFoundError ? '_notFoundNode' : '_errorNode']; - this.data = undefined; - this.error = error; - this.hasError = true; - } -} diff --git a/src/main/PathnameAdapter.ts b/src/main/PathnameAdapter.ts index 938cff3..1e72d64 100644 --- a/src/main/PathnameAdapter.ts +++ b/src/main/PathnameAdapter.ts @@ -1,15 +1,18 @@ import { Dict } from './types'; +/** + * A result returned by {@link PathnameAdapter.match} on a successful pathname match. + */ export interface PathnameMatch { /** - * A pathname that was matched, beginning with a "/". + * A pathname that was matched, beginning with a `/`. */ pathname: string; /** - * A pathname that should be matched by a nested route, beginning with a "/". + * A pathname that should be matched by a child route, beginning with a `/`. */ - nestedPathname: string; + childPathname: string; /** * Params extracted from the pathname, or `undefined` if pathname doesn't have params. @@ -26,9 +29,20 @@ export class PathnameAdapter { */ readonly paramNames: ReadonlySet; - protected _parts; - protected _flags; - protected _regExp; + /** + * An array that contains segments and param names. + */ + protected _segments: string[]; + + /** + * An array of {@link _segments segment}-specific bitwise flags. + */ + protected _flags: number[]; + + /** + * A regex that matches the pathname. + */ + protected _regExp: RegExp; /** * Creates a new {@link PathnameAdapter} instance. @@ -40,15 +54,15 @@ export class PathnameAdapter { const template = parsePathname(pathname); const paramNames = new Set(); - for (let i = 0; i < template.parts.length; ++i) { + for (let i = 0; i < template.segments.length; ++i) { if ((template.flags[i] & FLAG_PARAM) === FLAG_PARAM) { - paramNames.add(template.parts[i]); + paramNames.add(template.segments[i]); } } this.paramNames = paramNames; - this._parts = template.parts; + this._segments = template.segments; this._flags = template.flags; this._regExp = createPathnameRegExp(template, isCaseSensitive); } @@ -57,7 +71,7 @@ export class PathnameAdapter { * Matches a pathname against a pathname pattern. */ match(pathname: string): PathnameMatch | null { - const { _parts, _flags } = this; + const { _segments, _flags } = this; const match = this._regExp.exec(pathname); @@ -67,25 +81,25 @@ export class PathnameAdapter { let params: Dict | undefined; - if (_parts.length !== 1) { + if (_segments.length !== 1) { params = {}; - for (let i = 0, j = 1, value; i < _parts.length; ++i) { + for (let i = 0, j = 1, value; i < _segments.length; ++i) { if ((_flags[i] & FLAG_PARAM) !== FLAG_PARAM) { continue; } value = match[j++]; - params[_parts[i]] = value && decodeURIComponent(value); + params[_segments[i]] = value && decodeURIComponent(value); } } const m = match[0]; - const nestedPathname = pathname.substring(m.length); + const childPathname = pathname.substring(m.length); return { pathname: m === '' ? '/' : m, - nestedPathname: - nestedPathname.length === 0 || nestedPathname.charCodeAt(0) !== 47 ? '/' + nestedPathname : nestedPathname, + childPathname: + childPathname.length === 0 || childPathname.charCodeAt(0) !== 47 ? '/' + childPathname : childPathname, params, }; } @@ -94,26 +108,28 @@ export class PathnameAdapter { * Creates a pathname from a template by substituting params, beginning with a "/". */ toPathname(params: Dict | undefined): string { - const { _parts, _flags } = this; + const { _segments, _flags } = this; let pathname = ''; - for (let i = 0, part, flag, value; i < _parts.length; i++) { - part = _parts[i]; + for (let i = 0, segment, flag, value; i < _segments.length; i++) { + segment = _segments[i]; flag = _flags[i]; if ((flag & FLAG_PARAM) !== FLAG_PARAM) { - pathname += '/' + part; + pathname += '/' + segment; continue; } + if ( - (params === undefined || (value = params[part]) === undefined || value === null || value === '') && + (params === undefined || (value = params[segment]) === undefined || value === null || value === '') && (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ) { continue; } + if (typeof value !== 'string') { - throw new Error('Param must be a string: ' + part); + throw new Error('Param must be a string: ' + segment); } pathname += @@ -141,10 +157,10 @@ interface Template { /** * A non-empty array of segments and param names extracted from a pathname pattern. */ - parts: string[]; + segments: string[]; /** - * An array of bitmasks that holds {@link parts} metadata. + * An array of bitmasks that holds {@link segments} metadata. */ flags: number[]; } @@ -153,7 +169,7 @@ interface Template { * Parses pathname pattern as a template. */ export function parsePathname(pathname: string): Template { - const parts = []; + const segments = []; const flags = []; let stage = STAGE_SEPARATOR; @@ -183,7 +199,7 @@ export function parsePathname(pathname: string): Template { throw new SyntaxError('Param must have a name at ' + i); } - parts.push(pathname.substring(paramIndex, i)); + segments.push(pathname.substring(paramIndex, i)); flags.push(FLAG_PARAM); stage = STAGE_PARAM; break; @@ -199,23 +215,25 @@ export function parsePathname(pathname: string): Template { case 63 /* ? */: if (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT) { - parts.push(pathname.substring(segmentIndex, i)); + segments.push(pathname.substring(segmentIndex, i)); flags.push(FLAG_OPTIONAL); stage = STAGE_OPTIONAL; ++i; break; } + if (stage === STAGE_PARAM || stage === STAGE_WILDCARD) { flags[flags.length - 1] |= FLAG_OPTIONAL; stage = STAGE_OPTIONAL; ++i; break; } + throw new SyntaxError('Unexpected optional flag at ' + i); case 47 /* / */: if (i !== 0 && (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT)) { - parts.push(pathname.substring(segmentIndex, i)); + segments.push(pathname.substring(segmentIndex, i)); flags.push(0); } stage = STAGE_SEPARATOR; @@ -233,27 +251,27 @@ export function parsePathname(pathname: string): Template { } if (stage === STAGE_SEPARATOR || stage === STAGE_SEGMENT) { - parts.push(pathname.substring(segmentIndex)); + segments.push(pathname.substring(segmentIndex)); flags.push(0); } - return { parts, flags }; + return { segments, flags }; } /** * Creates a {@link !RegExp} that matches a pathname template. */ export function createPathnameRegExp(template: Template, isCaseSensitive = false): RegExp { - const { parts, flags } = template; + const { segments, flags } = template; let pattern = '^'; - for (let i = 0, part, flag, segmentPattern; i < parts.length; ++i) { - part = parts[i]; + for (let i = 0, segment, flag, segmentPattern; i < segments.length; ++i) { + segment = segments[i]; flag = flags[i]; if ((flag & FLAG_PARAM) !== FLAG_PARAM) { - segmentPattern = part.length === 0 ? '/' : '/' + escapeRegExp(part); + segmentPattern = segment.length === 0 ? '/' : '/' + escapeRegExp(segment); pattern += (flag & FLAG_OPTIONAL) === FLAG_OPTIONAL ? '(?:' + segmentPattern + ')?' : segmentPattern; continue; diff --git a/src/main/Route.ts b/src/main/Route.ts index b79c724..8245e19 100644 --- a/src/main/Route.ts +++ b/src/main/Route.ts @@ -1,18 +1,36 @@ -import { ReactNode } from 'react'; +import { ComponentType } from 'react'; import { Outlet } from './Outlet'; import { PathnameAdapter } from './PathnameAdapter'; -import { Dict, Location, LocationOptions, RouteContent, RouteOptions } from './types'; -import { memoizeNode } from './utils'; +import { Dict, LoadingAppearance, Location, LocationOptions, ParamsAdapter, RouteOptions } from './types'; +import { isPromiseLike } from './utils'; type Squash = { [K in keyof T]: T[K] } & {}; +/** + * A content returned by {@link Route.loader} and rendered by the {@link Outlet} when a route is matched by + * a {@link Router}. + * + * @template Data Data loaded by a route. + */ +export interface RouteContent { + /** + * A route {@link RouteOptions.component component} . + */ + component: ComponentType; + + /** + * Data loaded by a {@link RouteOptions.loader}. + */ + data: Data; +} + /** * A route that can be rendered by a router. * * @template Parent A parent route or `null` if there is no parent. * @template Params Route params. * @template Data Data loaded by a route. - * @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}. + * @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}. */ export class Route< Parent extends Route | null = any, @@ -39,14 +57,45 @@ export class Route< */ readonly parent: Parent; - protected _pathnameAdapter; - protected _paramsAdapter; - protected _pendingNode; - protected _errorNode; - protected _notFoundNode; - protected _pendingBehavior; - protected _contentRenderer; - protected _dataLoader; + /** + * Parses a pathname pattern, matches a pathname against this pattern, and creates a pathname from params and + * a pattern. + */ + pathnameAdapter: PathnameAdapter; + + /** + * An adapter that can validate and transform params extracted from the {@link Location.pathname} and + * {@link Location.searchParams}. + */ + paramsAdapter: ParamsAdapter | undefined; + + /** + * A component that is rendered when a {@link loader} is pending. + */ + loadingComponent: ComponentType | undefined; + + /** + * A component that is rendered when an error was thrown during route rendering. + */ + errorComponent: ComponentType | undefined; + + /** + * A component that is rendered if {@link notFound} was called during route rendering. + */ + notFoundComponent: ComponentType | undefined; + + /** + * What to render when a {@link loader} is pending. + */ + loadingAppearance: LoadingAppearance; + + /** + * Loads a component and data that are rendered in an {@link Outlet}. + * + * @param params Route params. + * @param context A context provided by a {@link Router} for a {@link RouteOptions.loader}. + */ + loader: (params: Params, context: Context) => Promise> | RouteContent; /** * Creates a new instance of a {@link Route}. @@ -56,21 +105,19 @@ export class Route< * @template Parent A parent route or `null` if there is no parent. * @template Params Route params. * @template Data Data loaded by a route. - * @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}. + * @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}. */ - constructor(parent: Parent, options: RouteOptions) { - const { paramsAdapter } = options; + constructor(parent: Parent, options: RouteOptions = {}) { + const { pathname = '/', paramsAdapter, loadingAppearance = 'auto' } = options; this.parent = parent; - - this._pathnameAdapter = new PathnameAdapter(options.pathname, options.isCaseSensitive); - this._paramsAdapter = typeof paramsAdapter === 'function' ? { parse: paramsAdapter } : paramsAdapter; - this._pendingNode = memoizeNode(options.pendingFallback); - this._errorNode = memoizeNode(options.errorFallback); - this._notFoundNode = memoizeNode(options.notFoundFallback); - this._pendingBehavior = options.pendingBehavior; - this._contentRenderer = createContentRenderer(options.content); - this._dataLoader = options.dataLoader; + this.pathnameAdapter = new PathnameAdapter(pathname, options.isCaseSensitive); + this.paramsAdapter = typeof paramsAdapter === 'function' ? { parse: paramsAdapter } : paramsAdapter; + this.loadingComponent = options.loadingComponent; + this.errorComponent = options.errorComponent; + this.notFoundComponent = options.notFoundComponent; + this.loadingAppearance = loadingAppearance; + this.loader = createLoader(options); } /** @@ -80,29 +127,31 @@ export class Route< * @param options Location options. */ getLocation(params: this['_params'], options?: LocationOptions): Location { - const { parent, _pathnameAdapter, _paramsAdapter } = this; + const { parent, pathnameAdapter, paramsAdapter } = this; let pathname; let searchParams: Dict = {}; + let hash; + let state; if (params === undefined) { // No params = no search params searchParams = {}; - pathname = _pathnameAdapter.toPathname(undefined); + pathname = pathnameAdapter.toPathname(undefined); } else { - if (_paramsAdapter === undefined || _paramsAdapter.toSearchParams === undefined) { + if (paramsAdapter === undefined || paramsAdapter.toSearchParams === undefined) { // Search params = params omit pathname params for (const name in params) { - if (params.hasOwnProperty(name) && !_pathnameAdapter.paramNames.has(name)) { + if (params.hasOwnProperty(name) && !pathnameAdapter.paramNames.has(name)) { searchParams[name] = params[name]; } } } else { - searchParams = _paramsAdapter.toSearchParams(params); + searchParams = paramsAdapter.toSearchParams(params); } - pathname = _pathnameAdapter.toPathname( - _paramsAdapter === undefined || _paramsAdapter.toPathnameParams === undefined ? params : undefined + pathname = pathnameAdapter.toPathname( + paramsAdapter === undefined || paramsAdapter.toPathnameParams === undefined ? params : undefined ); } @@ -117,81 +166,72 @@ export class Route< return location; } - let hash; - hash = - options === undefined || (hash = options.hash) === undefined || hash === '' || hash === '#' + options === undefined || + ((state = options.state), (hash = options.hash)) === undefined || + hash === '' || + hash === '#' ? '' : hash.charAt(0) === '#' ? hash : '#' + encodeURIComponent(hash); - return { - pathname, - searchParams, - hash, - state: options?.state, - }; + return { pathname, searchParams, hash, state }; } /** - * Prefetches route content and data of this route and its ancestors. + * Prefetches a component and data of this route and its ancestors. * * @param params Route params. - * @param context A context provided to a {@link RouteOptions.dataLoader}. + * @param context A context provided to a {@link RouteOptions.loader}. */ prefetch(params: this['_params'], context: Context): void { for (let route: Route | null = this; route !== null; route = route.parent) { - try { - route['_contentRenderer'](); - route['_dataLoader']?.(params, context); - } catch { - // noop - } + route.loader(params, context); } } } /** - * Create a function that loads the component and returns a node to render. The component is loaded only once, - * if an error occurs during loading, then loading is retried the next time the returned render is called. + * Creates a function that loads the component and its data. The component is loaded only once, if an error occurs + * during loading, then component is loaded the next time the loader is called. */ -function createContentRenderer(content: RouteContent): () => Promise | ReactNode { - let node: Promise | ReactNode | undefined; - - if (content === undefined) { - node = memoizeNode(Outlet); - } +function createLoader(options: RouteOptions): Route['loader'] { + const { lazyComponent, loader } = options; - return () => { - if (node !== undefined) { - return node; - } + let component: PromiseLike | ComponentType | undefined = options.component; - if (typeof content !== 'function') { - node = memoizeNode(content); - return node; - } + if (component !== undefined && lazyComponent !== undefined) { + throw new Error('Route must have either component or lazyComponent'); + } - const promiseOrComponent = content(); + if (component === undefined && lazyComponent === undefined) { + component = Outlet; + } - if (typeof promiseOrComponent === 'function') { - node = memoizeNode(promiseOrComponent); - return node; - } + return (params, context) => { + component ||= lazyComponent!().then( + module => { + component = module.default; - node = Promise.resolve(promiseOrComponent).then( - moduleOrComponent => { - const component = 'default' in moduleOrComponent ? moduleOrComponent.default : moduleOrComponent; - node = memoizeNode(component); - return node; + if (typeof component === 'function') { + return component; + } + component = undefined; + throw new TypeError('Module must default-export a component'); }, error => { - node = undefined; + component = undefined; throw error; } ); - return node; + const data = loader?.(params, context); + + if (isPromiseLike(component) || isPromiseLike(data)) { + return Promise.all([component, data]).then(([component, data]) => ({ component, data })); + } + + return { component, data }; }; } diff --git a/src/main/Router.tsx b/src/main/Router.tsx index dfd8cd0..a422bab 100644 --- a/src/main/Router.tsx +++ b/src/main/Router.tsx @@ -1,20 +1,16 @@ -import React, { Component, createContext, ReactNode } from 'react'; -import { Navigation } from './Navigation'; -import { isArrayEqual } from './utils'; +import React, { Component, ComponentType, ReactNode } from 'react'; import { matchRoutes } from './matchRoutes'; -import { NestedOutletControllerContext, Outlet } from './Outlet'; -import { OutletController } from './OutletController'; +import { Navigation } from './Navigation'; +import { ChildOutletContentContext, NotFoundOutletContent, Outlet, OutletContent, RouteOutletContent } from './Outlet'; import { Route } from './Route'; import { Location } from './types'; - -export const NavigationContext = createContext(null); - -NavigationContext.displayName = 'NavigationContext'; +import { NavigationContext } from './useNavigation'; +import { isArrayEqual } from './utils'; /** * Props of the {@link Router} component. * - * @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}. + * @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}. */ export interface RouterProps { /** @@ -28,17 +24,17 @@ export interface RouterProps { routes: Route[]; /** - * An arbitrary context provided to {@link RouteOptions.dataLoader}. + * An arbitrary context provided to {@link RouteOptions.loader}. */ context: Context; /** - * Triggered when a router location must be changed. + * Triggered when a new location must be added to a history stack. */ onPush?: (location: Location) => void; /** - * Triggered when a router location must be changed. + * Triggered when a new location must replace the current history entry. */ onReplace?: (location: Location) => void; @@ -53,69 +49,91 @@ export interface RouterProps { children?: ReactNode; /** - * A fallback that is rendered in the {@link Outlet} if there is no route in {@link routes} that matches + * A component that is rendered when a {@link RouteOptions.lazyComponent} or {@link RouteOptions.loader} are being + * loaded. Render a skeleton or a spinner in this component to notify user that a new route is being loaded. + * + * The {@link Router}-level {@link loadingComponent} is used only for root routes. Child routes must specify their own + * {@link RouteOptions.loadingComponent}. + */ + loadingComponent?: ComponentType; + + /** + * A component that is rendered when an error was thrown during route rendering. + * + * The {@link Router}-level {@link errorComponent} is used only for root routes. Child routes must specify their own + * {@link RouteOptions.errorComponent}. + */ + errorComponent?: ComponentType; + + /** + * A component that is rendered in the {@link Outlet} if there is no route in {@link routes} that matches * the {@link location}. */ - notFoundFallback?: ReactNode; + notFoundComponent?: ComponentType; } /** - * Options of a {@link Router} that doesn't provide any context for a {@link RouteOptions.dataLoader}. + * Options of a {@link Router} that doesn't provide any context for a {@link RouteOptions.loader}. */ -export interface NoContextRouterProps extends Omit, 'context'> {} +interface NoContextRouterProps extends Omit, 'context'> { + /** + * An arbitrary context provided to {@link RouteOptions.loader}. + */ + context?: undefined; +} interface RouterState { + location: Location | null; routes: Route[]; - controller: OutletController | null; - router: Router; + content: OutletContent | null; } /** * A router that renders a route that matches the provided location. * - * @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}. + * @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}. */ export class Router extends Component, RouterState> { /** * @internal */ - static getDerivedStateFromProps(props: RouterProps, state: RouterState): Partial | null { - if ( - state.controller !== null && - state.controller.location === props.location && - isArrayEqual(state.routes, props.routes) - ) { + static displayName = 'Router'; + + /** + * @internal + */ + static getDerivedStateFromProps(props: RouterProps, state: RouterState): Partial | null { + if (state.location === props.location && isArrayEqual(state.routes, props.routes)) { return null; } const routeMatches = matchRoutes(props.location.pathname, props.location.searchParams, props.routes); - let controller: OutletController | null = null; + let content: OutletContent | null = null; if (routeMatches === null) { - controller = new OutletController(props.location); - controller.node = props.notFoundFallback; - - return { - routes: props.routes, - controller, - }; - } - - for (const routeMatch of routeMatches) { - const prevController = new OutletController(props.location); - prevController.load(routeMatch.route, routeMatch.params, props.context); - prevController.nestedController = controller; - controller = prevController; + content = new NotFoundOutletContent(props); + } else { + for (const routeMatch of routeMatches) { + content = new RouteOutletContent(content, routeMatch.route, routeMatch.params, props.context); + } + if (content === null) { + throw new Error('Expected at least one route match'); + } + + content.loadingComponent ||= props.loadingComponent; + content.errorComponent ||= props.errorComponent; + content.notFoundComponent ||= props.notFoundComponent; } return { + location: props.location, routes: props.routes, - controller: controller, + content, }; } - private readonly _navigation = new Navigation(this); + private _navigation = new Navigation(this); /** * @internal @@ -124,9 +142,9 @@ export class Router extends Component extends Component - + {this.props.children === undefined ? : this.props.children} - + ); } diff --git a/src/main/createRoute.ts b/src/main/createRoute.ts index f32601e..b1d7f74 100644 --- a/src/main/createRoute.ts +++ b/src/main/createRoute.ts @@ -2,15 +2,15 @@ import { Route } from './Route'; import { RouteOptions } from './types'; /** - * Creates a route. + * Creates a route that is rendered in the {@link Outlet} of a {@link Router}. * * @param options Route options. * @template Params Route params. * @template Data Data loaded by a route. - * @template Context A context provided by a {@link Router} for a {@link RouteOptions.dataLoader}. + * @template Context A context provided by a {@link Router} for a {@link RouteOptions.loader}. */ export function createRoute( - options: RouteOptions + options?: RouteOptions ): Route; /** @@ -21,13 +21,16 @@ export function createRoute( parent: Parent, - options: RouteOptions + options?: RouteOptions ): Route; -export function createRoute(parentOrOptions: Route | RouteOptions, options?: any) { +export function createRoute( + parentOrOptions?: Route | RouteOptions, + options?: RouteOptions +) { return parentOrOptions instanceof Route ? new Route(parentOrOptions, options) : new Route(null, parentOrOptions); } diff --git a/src/main/createBrowserHistory.ts b/src/main/history/createBrowserHistory.ts similarity index 62% rename from src/main/createBrowserHistory.ts rename to src/main/history/createBrowserHistory.ts index 29418ec..1fef8df 100644 --- a/src/main/createBrowserHistory.ts +++ b/src/main/history/createBrowserHistory.ts @@ -1,34 +1,23 @@ import { PubSub } from 'parallel-universe'; -import { Dict, History } from './types'; +import { toLocation } from '../utils'; +import { History, SearchParamsAdapter } from './types'; import { urlSearchParamsAdapter } from './urlSearchParamsAdapter'; -import { parseURL, toLocation, toURL } from './utils'; +import { parseURL, toURL } from './utils'; /** - * Extracts params from a URL search string and stringifies them back. + * Options of {@link createBrowserHistory}. */ -export interface SearchParamsAdapter { +export interface BrowserHistoryOptions { /** - * Extract params from a URL search string. + * A URL base used by {@link History.toURL}. * - * @param search The URL search string to extract params from. + * @default window.location.origin */ - parse(search: string): Dict; + base?: URL | string; /** - * Stringifies params as a search string. - * - * @param params Params to stringify. - */ - stringify(params: Dict): string; -} - -/** - * Options of {@link createBrowserHistory}. - */ -export interface BrowserHistoryOptions { - /** - * An adapter that extracts params from a URL search string and stringifies them back. By default, - * an adapter that relies on {@link !URLSearchParams} is used. + * An adapter that extracts params from a URL search string and stringifies them back. By default, an adapter that + * relies on {@link !URLSearchParams} is used. */ searchParamsAdapter?: SearchParamsAdapter; } @@ -38,9 +27,9 @@ export interface BrowserHistoryOptions { * * @param options History options. */ -export function createBrowserHistory(options?: BrowserHistoryOptions): History { +export function createBrowserHistory(options: BrowserHistoryOptions = {}): History { + const { base: defaultBase = window.location.origin, searchParamsAdapter = urlSearchParamsAdapter } = options; const pubSub = new PubSub(); - const searchParamsAdapter = options?.searchParamsAdapter || urlSearchParamsAdapter; let location = parseURL(window.location.href, searchParamsAdapter); @@ -54,6 +43,10 @@ export function createBrowserHistory(options?: BrowserHistoryOptions): History { return location; }, + toURL(location, base = defaultBase) { + return new URL(toURL(location, searchParamsAdapter), base); + }, + push(to) { location = toLocation(to); history.pushState(location.state, '', toURL(location, searchParamsAdapter)); diff --git a/src/main/createMemoryHistory.ts b/src/main/history/createMemoryHistory.ts similarity index 53% rename from src/main/createMemoryHistory.ts rename to src/main/history/createMemoryHistory.ts index 360871d..43304f9 100644 --- a/src/main/createMemoryHistory.ts +++ b/src/main/history/createMemoryHistory.ts @@ -1,6 +1,9 @@ import { PubSub } from 'parallel-universe'; -import { History, Location } from './types'; -import { toLocation } from './utils'; +import { Location } from '../types'; +import { History, SearchParamsAdapter } from './types'; +import { urlSearchParamsAdapter } from './urlSearchParamsAdapter'; +import { toLocation } from '../utils'; +import { toURL } from './utils'; /** * Options of {@link createMemoryHistory}. @@ -10,6 +13,19 @@ export interface MemoryHistoryOptions { * A non-empty array of initial history entries. */ initialEntries: Location[]; + + /** + * A URL base used by {@link History.toURL}. + * + * If omitted a base should be specified with each {@link History.toURL} call, otherwise an error is thrown. + */ + base?: URL | string; + + /** + * An adapter that extracts params from a URL search string and stringifies them back. By default, an adapter that + * relies on {@link !URLSearchParams} is used. + */ + searchParamsAdapter?: SearchParamsAdapter; } /** @@ -18,6 +34,7 @@ export interface MemoryHistoryOptions { * @param options History options. */ export function createMemoryHistory(options: MemoryHistoryOptions): History { + const { base: defaultBase, searchParamsAdapter = urlSearchParamsAdapter } = options; const pubSub = new PubSub(); const entries = options.initialEntries.slice(0); @@ -32,6 +49,13 @@ export function createMemoryHistory(options: MemoryHistoryOptions): History { return entries[cursor]; }, + toURL(location, base = defaultBase) { + if (base === undefined) { + throw new Error('No base URL provided'); + } + return new URL(toURL(location, searchParamsAdapter), base); + }, + push(to) { entries.push(toLocation(to)); pubSub.publish(); diff --git a/src/main/history/types.ts b/src/main/history/types.ts new file mode 100644 index 0000000..d7f27b2 --- /dev/null +++ b/src/main/history/types.ts @@ -0,0 +1,65 @@ +import { Dict, Location, To } from '../types'; + +/** + * A history abstraction. + */ +export interface History { + /** + * The current history location. + */ + readonly location: Location; + + /** + * Creates a {@link !URL} for a given location. + * + * @param location A location to create a URL for. + * @param base A string representing the base URL. + */ + toURL(location: Location, base?: URL | string): URL; + + /** + * Adds an entry to the history stack. + * + * @param to A location to navigate to. + */ + push(to: To): void; + + /** + * Modifies the current history entry, replacing it with the state object and URL passed in the method parameters. + * + * @param to A location to navigate to. + */ + replace(to: To): void; + + /** + * Move back to the previous history entry. + */ + back(): void; + + /** + * Subscribe to location changes. + * + * @param listener A listener to subscribe. + * @returns A callback to unsubscribe a listener. + */ + subscribe(listener: () => void): () => void; +} + +/** + * Extracts params from a URL search string and stringifies them back. + */ +export interface SearchParamsAdapter { + /** + * Extract params from a URL search string. + * + * @param search The URL search string to extract params from. + */ + parse(search: string): Dict; + + /** + * Stringifies params as a search string. + * + * @param params Params to stringify. + */ + stringify(params: Dict): string; +} diff --git a/src/main/urlSearchParamsAdapter.ts b/src/main/history/urlSearchParamsAdapter.ts similarity index 90% rename from src/main/urlSearchParamsAdapter.ts rename to src/main/history/urlSearchParamsAdapter.ts index b946523..ab911e1 100644 --- a/src/main/urlSearchParamsAdapter.ts +++ b/src/main/history/urlSearchParamsAdapter.ts @@ -1,5 +1,5 @@ -import { SearchParamsAdapter } from './createBrowserHistory'; -import { Dict } from './types'; +import { Dict } from '../types'; +import { SearchParamsAdapter } from './types'; /** * Parses URL search params using {@link !URLSearchParams}. diff --git a/src/main/history/useHistorySubscription.ts b/src/main/history/useHistorySubscription.ts new file mode 100644 index 0000000..eb0a58e --- /dev/null +++ b/src/main/history/useHistorySubscription.ts @@ -0,0 +1,24 @@ +import React from 'react'; +import { History } from './types'; + +/** + * Subscribes component to updates of a history adapter and triggers re-render when history location is changed. + * + * @param history The history to subscribe to. + */ +export function useHistorySubscription(history: History): void { + if (typeof React.useSyncExternalStore === 'function') { + React.useSyncExternalStore(history.subscribe, () => history.location); + return; + } + + const [, setLocation] = React.useState(history.location); + + React.useEffect( + () => + history.subscribe(() => { + setLocation(history.location); + }), + [history] + ); +} diff --git a/src/main/history/utils.ts b/src/main/history/utils.ts new file mode 100644 index 0000000..15dad36 --- /dev/null +++ b/src/main/history/utils.ts @@ -0,0 +1,21 @@ +import { Location } from '../types'; +import { SearchParamsAdapter } from './types'; + +export function toURL(location: Location, searchParamsAdapter: SearchParamsAdapter): string { + let search = searchParamsAdapter.stringify(location.searchParams); + + search = search === '' || search === '?' ? '' : search.charAt(0) === '?' ? search : '?' + search; + + return location.pathname + search + location.hash; +} + +export function parseURL(url: string, searchParamsAdapter: SearchParamsAdapter): Location { + const { pathname, search, hash } = new URL(url, 'http://undefined'); + + return { + pathname, + searchParams: searchParamsAdapter.parse(search), + hash, + state: undefined, + }; +} diff --git a/src/main/hooks.ts b/src/main/hooks.ts deleted file mode 100644 index 9a6ac80..0000000 --- a/src/main/hooks.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { useContext, useSyncExternalStore } from 'react'; -import { Navigation } from './Navigation'; -import { OutletControllerContext } from './Outlet'; -import { OutletController } from './OutletController'; -import { Route } from './Route'; -import { NavigationContext } from './Router'; -import { History, Location } from './types'; - -/** - * Subscribes component to updates of a history adapter and triggers re-render when history location is changed. - * - * @param history The history to subscribe to. - */ -export function useHistorySubscription(history: History): void { - useSyncExternalStore(history.subscribe, () => history.location); -} - -/** - * Provides components a way to trigger router navigation. - */ -export function useNavigation(): Navigation { - const navigation = useContext(NavigationContext); - - if (navigation === null) { - throw new Error('Forbidden outside of a router'); - } - return navigation; -} - -function useOutletController(): OutletController { - const controller = useContext(OutletControllerContext); - - if (controller === null) { - throw new Error('Forbidden outside of a route'); - } - return controller; -} - -/** - * Returns the currently rendered location. - */ -export function useLocation(): Location { - return useOutletController().location; -} - -/** - * Returns the currently rendered route. - * - * @returns A rendered route, or `null` if {@link RouterProps.notFoundFallback} is rendered. - */ -export function useRoute(): Route | null { - return useOutletController().route; -} - -/** - * Returns params of the rendered route. - * - * @param route A route to retrieve params for. - * @template Params Route params. - */ -export function useRouteParams(route: Route): Params { - const controller = useOutletController(); - - for (let r = controller.route; r !== null; r = r.parent) { - if (r === route) { - return controller.params; - } - } - - throw new Error("Cannot retrieve params of a route that isn't rendered"); -} - -/** - * Returns the data loaded for the rendered route. - * - * @param route A route to retrieve data for. - * @template Data Data loaded by a route. - */ -export function useRouteData(route: Route): Data { - const controller = useOutletController(); - - for (let r = controller.route; r !== null; r = r.parent) { - if (r === route) { - return controller.data; - } - } - - throw new Error("Cannot retrieve data of a route that isn't rendered"); -} - -/** - * Returns the error thrown during data loading or route rendering. - * - * @returns An error, or `undefined` if there's no error. - */ -export function useRouteError(): any { - return useOutletController().error; -} diff --git a/src/main/index.ts b/src/main/index.ts index 62d7d81..e3b8bd7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,35 +1,24 @@ -export { createBrowserHistory } from './createBrowserHistory'; -export { createMemoryHistory } from './createMemoryHistory'; +export { createBrowserHistory } from './history/createBrowserHistory'; +export { createMemoryHistory } from './history/createMemoryHistory'; +export { useHistorySubscription } from './history/useHistorySubscription'; export { createRoute } from './createRoute'; -export { - useHistorySubscription, - useNavigation, - useLocation, - useRoute, - useRouteData, - useRouteParams, - useRouteError, -} from './hooks'; export { Link } from './Link'; export { Navigation } from './Navigation'; export { notFound, NotFoundError } from './notFound'; export { Outlet } from './Outlet'; +export { PathnameAdapter } from './PathnameAdapter'; export { redirect, Redirect } from './redirect'; export { Route } from './Route'; export { Router } from './Router'; +export { useNavigation } from './useNavigation'; -export type { BrowserHistoryOptions } from './createBrowserHistory'; -export type { MemoryHistoryOptions } from './createMemoryHistory'; +export type { BrowserHistoryOptions } from './history/createBrowserHistory'; +export type { MemoryHistoryOptions } from './history/createMemoryHistory'; +export type { History, SearchParamsAdapter } from './history/types'; export type { LinkProps } from './Link'; export type { OutletProps } from './Outlet'; -export type { RouterProps, NoContextRouterProps } from './Router'; -export type { - Dict, - To, - Location, - LocationOptions, - RouteOptions, - ParamsAdapter, - RouteContent, - RouteFallback, -} from './types'; +export type { PathnameMatch } from './PathnameAdapter'; +export type { RouterProps } from './Router'; +export type { RouteContent } from './Route'; +export type { RedirectOptions } from './redirect'; +export type { Dict, To, Location, LocationOptions, RouteOptions, ParamsAdapter, LoadingAppearance } from './types'; diff --git a/src/main/matchRoutes.ts b/src/main/matchRoutes.ts index 0fdb12f..ace74c1 100644 --- a/src/main/matchRoutes.ts +++ b/src/main/matchRoutes.ts @@ -28,7 +28,7 @@ export function matchRoutes(pathname: string, searchParams: Dict, routes: Route[ for (const route of routes) { const match = matchPathname(pathname, route, cache); - if (match === null || match.nestedPathname !== '/') { + if (match === null || match.childPathname !== '/') { // No match or pathname cannot be consumed by a route continue; } @@ -51,10 +51,10 @@ function matchPathname(pathname: string, route: Route, cache: Map import('./UserPage') - */ -export type RouteContent = (() => PromiseLike<{ default: ComponentType } | ComponentType> | ComponentType) | ReactNode; - -/** - * A fallback rendered by the {@link Outlet} when {@link RouteContent} cannot be rendered for some reason. - */ -export type RouteFallback = ComponentType | ReactNode; - /** * An adapter that can validate and transform route params. * @@ -99,40 +82,54 @@ export interface ParamsAdapter { toSearchParams?(params: Params): Dict; } +/** + * What to render when {@link RouteOptions.lazyComponent} or {@link RouteOptions.loader} are being loaded. + * + *
+ *
"loading"
+ *
A {@link RouteOptions.loadingComponent} is always rendered if a route is matched and component or loader are + * being loaded.
+ * + *
"auto"
+ *
If another route is currently rendered then it would be preserved until component and loader of a newly + * matched route are being loaded. Otherwise, a {@link RouteOptions.loadingComponent} is rendered.
+ *
+ */ +export type LoadingAppearance = 'loading' | 'auto'; + /** * Options of a {@link Route}. * * @template Params Route params. * @template Data Data loaded by a route. - * @template Context A context provided by a {@link Router} for a {@link dataLoader}. + * @template Context A context provided by a {@link Router} for a {@link loader}. */ export interface RouteOptions { /** * A URL pathname pattern. * - * Pattern can include params that conform `:[A-Za-z$_][A-Za-z0-9$_]+`, for example `:teamId`. - * - * Params match the whole segment and cannot be partial: + * Pattern can include params that conform `:[A-Za-z$_][A-Za-z0-9$_]+`. For example `"/:userId"`. * - * - 🚫`"/teams-:teamId"` - * - ✅`"/teams/:teamId"` - * - 🚫`"/:category--:productId"` - * - ✅`"/:productSlug"` + * Params match a whole segment and cannot be partial. For example, `"/teams--:teamId"` is invalid and would throw + * a {@link !SyntaxError}, while `"/teams/:teamId"` is valid. * - * By default, a param matches a non-empty pathname substring. To make a param optional (so it can match zero - * characters) follow it by a `?` flag. For example: `":userId?"`. + * By default, a param matches a non-empty pathname segment. To make a param optional (so it can match an absent + * segment) follow it by a `?` flag. For example: `"/user/:userId?"` matches both `"/user"` and `"/user/37"`. * - * You can make a static pathname segment optional as well: `"/project/task?/:taskId"`. + * Static pathname segments can be optional as well: `"/project/task?/:taskId"`. * - * By default, a param matches a pathname segment: all characters except a `/`. Follow a param with a `*` flag to make - * it match multiple segments. For example: `":slug*"`. Such params are called wildcard params. + * By default, a param matches a single pathname segment. Follow a param with a `*` flag to make it match multiple + * segments. For example: `"/:slug*"` matches `"/watch"` and `"/watch/a/movie"`. Such params are called wildcard + * params. * - * To make param both wildcard and optional, combine `*` and `?` flags: `":slug*?"` + * To make param both wildcard and optional, combine `*` and `?` flags: `"/:slug*?"`. * * To use `:` as a character in a pathname pattern, replace it with an {@link !encodeURIComponent encoded} * representation: `%3A`. + * + * @default "/" */ - pathname: string; + pathname?: string; /** * If `true` then {@link pathname} is matched in a case-sensitive manner. @@ -142,90 +139,55 @@ export interface RouteOptions { isCaseSensitive?: boolean; /** - * A content rendered by a route. If `undefined` then route implicitly renders {@link Outlet}. + * A component that is rendered by a route. + * + * If both {@link component} and {@link lazyComponent} are omitted then a route implicitly renders an {@link Outlet}. */ - content?: RouteContent; + component?: ComponentType; + + /** + * A lazy-loaded component that is rendered by a route. A component cached forever if a returned {@link !Promise} + * is fulfilled. + * + * @example + * () => import('./UserPage') + */ + lazyComponent?: () => PromiseLike<{ default: ComponentType }>; /** * An adapter that can validate and transform params extracted from the {@link Location.pathname} and - * {@link Location.searchParams}. Params are available inside the {@link content} through {@link useRouteParams} hook. + * {@link Location.searchParams}. */ paramsAdapter?: ParamsAdapter | ParamsAdapter['parse']; /** - * Loads data required to render a route. The loaded data is synchronously available inside the {@link content} - * through {@link useRouteData} hook. + * Loads data required to render a route. * * @param params Route params extracted from a location. * @param context A {@link RouterProps.context} provided to a {@link Router}. */ - dataLoader?: (params: Params, context: Context) => PromiseLike | Data; + loader?: (params: Params, context: Context) => PromiseLike | Data; /** - * A fallback that is rendered when the route {@link content} or {@link dataLoader data} are being loaded. + * A component that is rendered when a {@link lazyComponent} or {@link loader} are being loaded. Render a skeleton or + * a spinner in this component to notify user that a new route is being loaded. */ - pendingFallback?: RouteFallback; + loadingComponent?: ComponentType; /** - * A fallback that is rendered when an error was thrown during route rendering. An error is available through - * {@link useRouteError} hook. + * A component that is rendered when an error was thrown during route rendering. */ - errorFallback?: RouteFallback; + errorComponent?: ComponentType; /** - * A fallback that is rendered if {@link notFound} was called during route rendering. + * A component that is rendered if {@link notFound} was called during route rendering. */ - notFoundFallback?: RouteFallback; + notFoundComponent?: ComponentType; /** - * What to render when route is being loaded. - * - *
- *
"fallback"
- *
A {@link pendingFallback} is always rendered if a route is matched and content or data are being loaded.
- *
"auto"
- *
If another route is currently rendered then it would be preserved until content and data of a newly matched - * route are being loaded. Otherwise, a {@link pendingFallback} is rendered.
- *
+ * What to render when {@link lazyComponent} or {@link loader} are being loaded. * * @default "auto" */ - pendingBehavior?: 'fallback' | 'auto'; -} - -/** - * A history abstraction. - */ -export interface History { - /** - * The current history location. - */ - readonly location: Location; - - /** - * Adds an entry to the history stack. - * - * @param to A location to navigate to. - */ - push(to: To): void; - - /** - * Modifies the current history entry, replacing it with the state object and URL passed in the method parameters. - * - * @param to A location to navigate to. - */ - replace(to: To): void; - - /** - * Move back to the previous history entry. - */ - back(): void; - - /** - * Subscribe to location changes. - * - * @param listener A listener to subscribe. - * @returns A callback to unsubscribe a listener. - */ - subscribe(listener: () => void): () => void; + loadingAppearance?: LoadingAppearance; } diff --git a/src/main/useNavigation.ts b/src/main/useNavigation.ts new file mode 100644 index 0000000..ca2d4a0 --- /dev/null +++ b/src/main/useNavigation.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; +import { Navigation } from './Navigation'; + +export const NavigationContext = createContext(null); + +NavigationContext.displayName = 'NavigationContext'; + +/** + * Returns the navigation that controls the enclosing router. + */ +export function useNavigation(): Navigation { + const navigation = useContext(NavigationContext); + + if (navigation === null) { + throw new Error('Forbidden outside of a router'); + } + return navigation; +} diff --git a/src/main/utils.ts b/src/main/utils.ts index 77e35e8..81c861b 100644 --- a/src/main/utils.ts +++ b/src/main/utils.ts @@ -1,27 +1,5 @@ -import { ComponentType, createElement, isValidElement, memo, ReactElement, ReactNode } from 'react'; -import { SearchParamsAdapter } from './createBrowserHistory'; import { Location, To } from './types'; -const cache = new WeakMap(); - -export function memoizeNode(value: ComponentType | ReactNode): ReactNode { - if (typeof value !== 'function' && !isValidElement(value)) { - return value; - } - let node = cache.get(value); - - if (node === undefined) { - node = createElement(memo(typeof value !== 'function' ? () => value : value, propsAreEqual)); - cache.set(value, node); - } - return node; -} - -// Memoized components don't receive any props, so props are always equal -function propsAreEqual(_prevProps: unknown, _nextProps: unknown): boolean { - return true; -} - export function isPromiseLike(value: unknown): value is PromiseLike { return value !== null && typeof value === 'object' && 'then' in value; } @@ -44,22 +22,3 @@ export function isArrayEqual(a: any[], b: any[]): boolean { export function toLocation(to: To): Location { return 'getLocation' in to ? to.getLocation() : to; } - -export function toURL(location: Location, searchParamsAdapter: SearchParamsAdapter): string { - let search = searchParamsAdapter.stringify(location.searchParams); - - search = search === '' || search === '?' ? '' : search.charAt(0) === '?' ? search : '?' + search; - - return location.pathname + search + location.hash; -} - -export function parseURL(url: string, searchParamsAdapter: SearchParamsAdapter): Location { - const { pathname, search, hash } = new URL(url, 'http://undefined'); - - return { - pathname, - searchParams: searchParamsAdapter.parse(search), - hash, - state: undefined, - }; -} diff --git a/src/test/PathnameAdapter.test.ts b/src/test/PathnameAdapter.test.ts index b6e4a1f..074d75f 100644 --- a/src/test/PathnameAdapter.test.ts +++ b/src/test/PathnameAdapter.test.ts @@ -6,27 +6,27 @@ describe('parsePathname', () => { const FLAG_OPTIONAL = 1 << 2; test('parses pathname as a template', () => { - expect(parsePathname('')).toEqual({ parts: [''], flags: [0] }); - expect(parsePathname('/')).toEqual({ parts: [''], flags: [0] }); - expect(parsePathname('//')).toEqual({ parts: ['', ''], flags: [0, 0] }); - expect(parsePathname('///')).toEqual({ parts: ['', '', ''], flags: [0, 0, 0] }); - expect(parsePathname('aaa')).toEqual({ parts: ['aaa'], flags: [0] }); - expect(parsePathname('/aaa')).toEqual({ parts: ['aaa'], flags: [0] }); - expect(parsePathname('/aaa/bbb')).toEqual({ parts: ['aaa', 'bbb'], flags: [0, 0] }); - expect(parsePathname('/aaa?')).toEqual({ parts: ['aaa'], flags: [FLAG_OPTIONAL] }); - expect(parsePathname('/aaa?/')).toEqual({ parts: ['aaa', ''], flags: [FLAG_OPTIONAL, 0] }); - expect(parsePathname('/aaa?/bbb?')).toEqual({ parts: ['aaa', 'bbb'], flags: [FLAG_OPTIONAL, FLAG_OPTIONAL] }); - expect(parsePathname(':xxx')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM] }); - expect(parsePathname('/:xxx')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM] }); - expect(parsePathname('/:xxx')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM] }); - expect(parsePathname('/:xxx?')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM | FLAG_OPTIONAL] }); - expect(parsePathname('/:xxx*')).toEqual({ parts: ['xxx'], flags: [FLAG_PARAM | FLAG_WILDCARD] }); + expect(parsePathname('')).toEqual({ segments: [''], flags: [0] }); + expect(parsePathname('/')).toEqual({ segments: [''], flags: [0] }); + expect(parsePathname('//')).toEqual({ segments: ['', ''], flags: [0, 0] }); + expect(parsePathname('///')).toEqual({ segments: ['', '', ''], flags: [0, 0, 0] }); + expect(parsePathname('aaa')).toEqual({ segments: ['aaa'], flags: [0] }); + expect(parsePathname('/aaa')).toEqual({ segments: ['aaa'], flags: [0] }); + expect(parsePathname('/aaa/bbb')).toEqual({ segments: ['aaa', 'bbb'], flags: [0, 0] }); + expect(parsePathname('/aaa?')).toEqual({ segments: ['aaa'], flags: [FLAG_OPTIONAL] }); + expect(parsePathname('/aaa?/')).toEqual({ segments: ['aaa', ''], flags: [FLAG_OPTIONAL, 0] }); + expect(parsePathname('/aaa?/bbb?')).toEqual({ segments: ['aaa', 'bbb'], flags: [FLAG_OPTIONAL, FLAG_OPTIONAL] }); + expect(parsePathname(':xxx')).toEqual({ segments: ['xxx'], flags: [FLAG_PARAM] }); + expect(parsePathname('/:xxx')).toEqual({ segments: ['xxx'], flags: [FLAG_PARAM] }); + expect(parsePathname('/:xxx')).toEqual({ segments: ['xxx'], flags: [FLAG_PARAM] }); + expect(parsePathname('/:xxx?')).toEqual({ segments: ['xxx'], flags: [FLAG_PARAM | FLAG_OPTIONAL] }); + expect(parsePathname('/:xxx*')).toEqual({ segments: ['xxx'], flags: [FLAG_PARAM | FLAG_WILDCARD] }); expect(parsePathname('/:xxx*?')).toEqual({ - parts: ['xxx'], + segments: ['xxx'], flags: [FLAG_PARAM | FLAG_WILDCARD | FLAG_OPTIONAL], }); expect(parsePathname('/:xxx*?/:yyy?')).toEqual({ - parts: ['xxx', 'yyy'], + segments: ['xxx', 'yyy'], flags: [FLAG_PARAM | FLAG_WILDCARD | FLAG_OPTIONAL, FLAG_PARAM | FLAG_OPTIONAL], }); }); @@ -69,14 +69,14 @@ describe('createPathnameRegExp', () => { describe('PathnameAdapter', () => { test('matches a pathname without params', () => { - expect(new PathnameAdapter('').match('/')).toEqual({ pathname: '/', nestedPathname: '/' }); - expect(new PathnameAdapter('').match('/aaa')).toEqual({ pathname: '/', nestedPathname: '/aaa' }); - expect(new PathnameAdapter('/aaa').match('/aaa')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); - expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); - expect(new PathnameAdapter('/AAA').match('/aaa')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); - expect(new PathnameAdapter('/aaa').match('/AAA')).toEqual({ pathname: '/AAA', nestedPathname: '/' }); - expect(new PathnameAdapter('/aaa').match('/aaa/bbb')).toEqual({ pathname: '/aaa', nestedPathname: '/bbb' }); - expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', nestedPathname: '/' }); + expect(new PathnameAdapter('').match('/')).toEqual({ pathname: '/', childPathname: '/' }); + expect(new PathnameAdapter('').match('/aaa')).toEqual({ pathname: '/', childPathname: '/aaa' }); + expect(new PathnameAdapter('/aaa').match('/aaa')).toEqual({ pathname: '/aaa', childPathname: '/' }); + expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', childPathname: '/' }); + expect(new PathnameAdapter('/AAA').match('/aaa')).toEqual({ pathname: '/aaa', childPathname: '/' }); + expect(new PathnameAdapter('/aaa').match('/AAA')).toEqual({ pathname: '/AAA', childPathname: '/' }); + expect(new PathnameAdapter('/aaa').match('/aaa/bbb')).toEqual({ pathname: '/aaa', childPathname: '/bbb' }); + expect(new PathnameAdapter('/aaa').match('/aaa/')).toEqual({ pathname: '/aaa', childPathname: '/' }); }); test('does not match a pathname without params', () => { @@ -90,13 +90,13 @@ describe('PathnameAdapter', () => { test('matches a pathname with params', () => { expect(new PathnameAdapter('/aaa/:xxx').match('/aaa/yyy')).toEqual({ pathname: '/aaa/yyy', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'yyy' }, }); expect(new PathnameAdapter('/aaa/:xxx*').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'bbb/ccc' }, }); }); @@ -124,19 +124,19 @@ describe('PathnameAdapter', () => { test('matches a wildcard param', () => { expect(new PathnameAdapter('/aaa/:xxx*').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'bbb/ccc' }, }); expect(new PathnameAdapter('/aaa/:xxx*/ccc').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'bbb' }, }); expect(new PathnameAdapter('/aaa/:xxx*/ddd').match('/aaa/bbb/ccc/ddd')).toEqual({ pathname: '/aaa/bbb/ccc/ddd', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'bbb/ccc' }, }); }); @@ -144,7 +144,7 @@ describe('PathnameAdapter', () => { test('matches an optional wildcard param', () => { expect(new PathnameAdapter('/aaa/:xxx*?').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'bbb/ccc' }, }); }); @@ -152,19 +152,19 @@ describe('PathnameAdapter', () => { test('matches an optional param', () => { expect(new PathnameAdapter('/aaa/:xxx?').match('/aaa')).toEqual({ pathname: '/aaa', - nestedPathname: '/', + childPathname: '/', params: { xxx: undefined }, }); expect(new PathnameAdapter('/aaa/:xxx?/ccc').match('/aaa/ccc')).toEqual({ pathname: '/aaa/ccc', - nestedPathname: '/', + childPathname: '/', params: { xxx: undefined }, }); expect(new PathnameAdapter('/aaa/:xxx?/ccc').match('/aaa/bbb/ccc')).toEqual({ pathname: '/aaa/bbb/ccc', - nestedPathname: '/', + childPathname: '/', params: { xxx: 'bbb' }, }); }); diff --git a/src/test/createRoute.test.ts b/src/test/createRoute.test.ts index abd3897..3ea263d 100644 --- a/src/test/createRoute.test.ts +++ b/src/test/createRoute.test.ts @@ -1,5 +1,17 @@ -import { createRoute } from '../main/createRoute'; -import { ParamsAdapter } from '../main/types'; +import { createRoute, ParamsAdapter, Route } from '../main'; + +describe('createRoute', () => { + test('creates a route without a parent', () => { + expect(createRoute()).toBeInstanceOf(Route); + expect(createRoute().parent).toBeNull(); + }); + + test('creates a route with a parent', () => { + const aaaRoute = createRoute(); + + expect(createRoute(aaaRoute).parent).toBe(aaaRoute); + }); +}); describe('Route', () => { describe('getLocation', () => { diff --git a/src/test/urlSearchParamsAdapter.test.ts b/src/test/history/urlSearchParamsAdapter.test.ts similarity index 91% rename from src/test/urlSearchParamsAdapter.test.ts rename to src/test/history/urlSearchParamsAdapter.test.ts index 3de2ac2..6b243a5 100644 --- a/src/test/urlSearchParamsAdapter.test.ts +++ b/src/test/history/urlSearchParamsAdapter.test.ts @@ -1,4 +1,4 @@ -import { urlSearchParamsAdapter } from '../main/urlSearchParamsAdapter'; +import { urlSearchParamsAdapter } from '../../main/history/urlSearchParamsAdapter'; describe('urlSearchParamsAdapter', () => { test('parses params', () => { diff --git a/src/test/matchRoutes.test.ts b/src/test/matchRoutes.test.ts index 47ef5e4..5b826a3 100644 --- a/src/test/matchRoutes.test.ts +++ b/src/test/matchRoutes.test.ts @@ -1,4 +1,4 @@ -import { createRoute } from '../main/createRoute'; +import { createRoute } from '../main'; import { matchRoutes } from '../main/matchRoutes'; describe('matchRoutes', () => { diff --git a/typedoc.json b/typedoc.json index 44c7aa5..b822c38 100644 --- a/typedoc.json +++ b/typedoc.json @@ -23,5 +23,13 @@ ], "plugin": [ "typedoc-plugin-mdn-links" - ] + ], + "externalSymbolLinkMappings": { + "react": { + "ComponentType": "https://react.dev/reference/react/createElement" + }, + "typescript": { + "PromiseLike": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise" + } + } }