Skip to content

Commit

Permalink
Error handling in slots
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Jul 15, 2024
1 parent 9c63cb6 commit a7e177a
Show file tree
Hide file tree
Showing 12 changed files with 238 additions and 202 deletions.
15 changes: 0 additions & 15 deletions src/main/NotFoundSlotContent.ts

This file was deleted.

19 changes: 12 additions & 7 deletions src/main/Outlet.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import React, { ReactNode, useContext } from 'react';
import { ChildSlotContentContext, Slot } from './Slot';
import { RouteSlotContent } from './RouteSlotContent';
import { Slot } from './Slot';

export const RouteSlotContentContext = React.createContext<RouteSlotContent | undefined>(undefined);

/**
* Props of an {@link Outlet}.
Expand All @@ -12,15 +15,17 @@ export interface OutletProps {
}

/**
* Renders route provided be an enclosing {@link Router}.
* Renders a route provided by an enclosing {@link Router}.
*/
export function Outlet(props: OutletProps): ReactNode {
const content = useContext(ChildSlotContentContext);
const content = useContext(RouteSlotContentContext);

if (content === undefined) {
return props.children;
}
return (
<Slot
content={content}
children={props.children}
/>
<RouteSlotContentContext.Provider value={content.childContent}>
<Slot content={content} />
</RouteSlotContentContext.Provider>
);
}
15 changes: 7 additions & 8 deletions src/main/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ import { isPromiseLike } from './utils';
type Squash<T> = { [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}.
* A content returned by {@link Route.loader}.
*
* @template Data Data loaded by a route.
*/
export interface RouteContent<Data> {
export interface RouteContent<Data = any> {
/**
* A route {@link RouteOptions.component component} .
*/
Expand Down Expand Up @@ -70,14 +69,14 @@ export class Route<
paramsAdapter: ParamsAdapter<Params> | undefined;

/**
* A component that is rendered when a {@link loader} is pending.
* A component that is rendered when an error was thrown during route rendering.
*/
loadingComponent: ComponentType | undefined;
errorComponent: ComponentType | undefined;

/**
* A component that is rendered when an error was thrown during route rendering.
* A component that is rendered when a {@link loader} is pending.
*/
errorComponent: ComponentType | undefined;
loadingComponent: ComponentType | undefined;

/**
* A component that is rendered if {@link notFound} was called during route rendering.
Expand Down Expand Up @@ -113,8 +112,8 @@ export class Route<
this.parent = parent;
this.pathnameAdapter = new PathnameAdapter(pathname, options.isCaseSensitive);
this.paramsAdapter = typeof paramsAdapter === 'function' ? { parse: paramsAdapter } : paramsAdapter;
this.loadingComponent = options.loadingComponent;
this.errorComponent = options.errorComponent;
this.loadingComponent = options.loadingComponent;
this.notFoundComponent = options.notFoundComponent;
this.loadingAppearance = loadingAppearance;
this.loader = createLoader(options);
Expand Down
170 changes: 115 additions & 55 deletions src/main/RouteSlotContent.ts
Original file line number Diff line number Diff line change
@@ -1,81 +1,141 @@
import { ComponentType } from 'react';
import { NotFoundError } from './notFound';
import { Route } from './Route';
import { SlotContent, SlotContentComponents } from './Slot';
import { Route, RouteContent } from './Route';
import { SlotContent } from './Slot';
import { isPromiseLike } from './utils';

export interface RouterContentOptions {
context: unknown;
errorComponent?: ComponentType;
loadingComponent?: ComponentType;
notFoundComponent?: ComponentType;
}

export interface RouteSlotContentOptions extends RouterContentOptions {
/**
* A previous content that was rendered in this slot.
*/
prevContent: RouteSlotContent | undefined;

/**
* A content rendered in the child slot.
*/
childContent: RouteSlotContent | undefined;
route: Route | undefined;
params: unknown;
}

export class RouteSlotContent implements SlotContent {
childContent: RouteSlotContent | undefined;
promise: Promise<void> | undefined;
renderedComponent: ComponentType | undefined;
loadingComponent: ComponentType | undefined;
errorComponent: ComponentType | undefined;
notFoundComponent: ComponentType | undefined;
component: ComponentType | undefined;
payload: unknown;
route: Route | undefined;
params: unknown;
data: unknown;
error: unknown;

constructor(
readonly prevContent: RouteSlotContent | undefined,
readonly childContent: RouteSlotContent | undefined,
readonly route: Route,
readonly params: unknown,
context: unknown,
options: SlotContentComponents
) {
prevContent?.freeze();
protected _options: RouteSlotContentOptions;
protected _prevContent: RouteSlotContent | undefined;

constructor(options: RouteSlotContentOptions) {
options.prevContent?._freeze();

if (options.route === undefined) {
this._options = options;
this._prevContent = this;
this.component = options.notFoundComponent;
return;
}

Object.assign(this, options.prevContent);
this._options = options;
this._prevContent = options.prevContent;

this.loadingComponent = route.loadingComponent || options.loadingComponent;
this.errorComponent = route.errorComponent || options.errorComponent;
this.notFoundComponent = route.notFoundComponent || options.notFoundComponent;
this._load();
}

setError(error: unknown): void {
const options = this._prevContent === undefined ? this._options : this._prevContent._options;

this.childContent = options.childContent;
this.component = error instanceof NotFoundError ? options.notFoundComponent : options.errorComponent;
this.payload = { error };
this.route = options.route;
this.params = options.params;
this.data = undefined;
this.error = error;
}

this.promise = this.data = this.error = undefined;
protected _setRouteContent(content: RouteContent): void {
this.childContent = this._options.childContent;
this.component = content.component;
this.payload = { data: content.data };
this.route = this._options.route;
this.params = this._options.params;
this.data = content.data;
this.error = undefined;
}

protected _freeze(): void {
this.promise = undefined;

if (this._prevContent === undefined || this._prevContent === this) {
return;
}
this._prevContent._freeze();
}

protected _load(): void {
if (this._options.route === undefined) {
return;
}

this.promise = undefined;

let content;

try {
content = route.loader(params, context);
content = this._options.route.loader(this._options.params, this._options.context);
} catch (error) {
this._prevContent = this;
this.setError(error);
return;
}

if (isPromiseLike(content)) {
const promise = content.then(
content => {
if (this.promise === promise) {
this.promise = undefined;
this.renderedComponent = content.component;
this.data = content.data;
}
},
error => {
if (this.promise === promise) {
this.promise = undefined;
this.setError(error);
}
if (!isPromiseLike(content)) {
this._prevContent = this;
this._setRouteContent(content);
return;
}

const promise = content.then(
content => {
if (this.promise === promise) {
this.promise = undefined;
this._prevContent = this;
this._setRouteContent(content);
}
},
error => {
if (this.promise === promise) {
this.promise = undefined;
this._prevContent = this;
this.setError(error);
}
);

if (prevContent === undefined || route.loadingAppearance === 'loading') {
this.renderedComponent = this.loadingComponent;
this.data = undefined;
} else {
this.renderedComponent = prevContent.renderedComponent;
this.data = prevContent.data;
this.params = prevContent.params;
this.error = prevContent.error;
}
} else {
this.renderedComponent = content.component;
this.data = content.data;
}
}
);

setError(error: unknown): void {
this.renderedComponent = error instanceof NotFoundError ? this.notFoundComponent : this.errorComponent;
this.error = error;
}
this.promise = promise;

freeze(): void {
this.promise = undefined;
if (this._prevContent === undefined || this._options.route.loadingAppearance === 'loading') {
this.childContent = this._options.childContent;
this.component = this._options.loadingComponent;
this.payload = undefined;
this.route = this._options.route;
this.params = this._options.params;
this.data = undefined;
this.error = undefined;
}
}
}
38 changes: 17 additions & 21 deletions src/main/Router.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React, { Component, ComponentType, ReactNode } from 'react';
import { deriveSlotContent } from './deriveSlotContent';
import { matchRoutes } from './matchRoutes';
import { Navigation } from './Navigation';
import { NotFoundSlotContent } from './NotFoundSlotContent';
import { Outlet } from './Outlet';
import { Outlet, RouteSlotContentContext } from './Outlet';
import { Route } from './Route';
import { ChildSlotContentContext, SlotContent } from './Slot';
import { Location } from './types';
import { createRouteSlotContent } from './createRouteSlotContent';
import { Navigation } from './Navigation';
import { RouteSlotContent } from './RouteSlotContent';
import { NavigationContext } from './useNavigation';
import { isArrayEqual } from './utils';

Expand Down Expand Up @@ -51,6 +50,14 @@ export interface RouterProps<Context> {
*/
children?: ReactNode;

/**
* 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 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.
Expand All @@ -60,14 +67,6 @@ export interface RouterProps<Context> {
*/
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}.
Expand All @@ -89,7 +88,7 @@ interface RouterState {
navigation: Navigation;
location: Location | null;
routes: Route[];
content: SlotContent | undefined;
contents: RouteSlotContent[];
}

/**
Expand All @@ -116,10 +115,7 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
return {
location: props.location,
routes: props.routes,
content:
routeMatches === null
? new NotFoundSlotContent(props.notFoundComponent, props)
: deriveSlotContent(state.content, routeMatches, props),
contents: createRouteSlotContent(state.contents, routeMatches, props),
};
}

Expand All @@ -133,7 +129,7 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
navigation: new Navigation(this),
location: null,
routes: props.routes,
content: undefined,
contents: [],
};
}

Expand All @@ -143,9 +139,9 @@ export class Router<Context = void> extends Component<NoContextRouterProps | Rou
render() {
return (
<NavigationContext.Provider value={this.state.navigation}>
<ChildSlotContentContext.Provider value={this.state.content}>
<RouteSlotContentContext.Provider value={this.state.contents[0]}>
{this.props.children === undefined ? <Outlet /> : this.props.children}
</ChildSlotContentContext.Provider>
</RouteSlotContentContext.Provider>
</NavigationContext.Provider>
);
}
Expand Down
Loading

0 comments on commit a7e177a

Please sign in to comment.