Skip to content

Commit

Permalink
Refactored outlets
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Jul 12, 2024
1 parent 2e3014b commit a665ce0
Show file tree
Hide file tree
Showing 27 changed files with 796 additions and 674 deletions.
2 changes: 1 addition & 1 deletion src/main/Link.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/main/Navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@ export class Navigation {
*/
prefetch(to: To): boolean {
const location = toLocation(to);
const { routes, context } = this._router.props as RouterProps<unknown>;
const { routes, context } = this._router.props;

const routeMatches = matchRoutes(location.pathname, location.searchParams, routes);

if (routeMatches === null) {
return false;
}
for (const routeMatch of routeMatches) {
routeMatch.route.prefetch(routeMatch.params, context);
routeMatch.route.loader(routeMatch.params, context);
}
return true;
}
Expand Down
310 changes: 239 additions & 71 deletions src/main/Outlet.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,153 @@
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 { LoadingAppearance } from './types';
import { RouterProps } from './Router';
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<OutletController | null>(null);
export interface OutletContent {
parent: OutletContent | null;

OutletControllerContext.displayName = 'OutletControllerContext';
/**
* A content of an {@link Outlet} that is rendered by a {@link component}.
*/
child: OutletContent | null;
isLoading: boolean;
route: Route | null;
params: unknown;
data: unknown;
loadingComponent: ComponentType | undefined;
errorComponent: ComponentType | undefined;
notFoundComponent: ComponentType | undefined;
loadingAppearance: LoadingAppearance;

getComponentOrSuspend(): ComponentType;

abort(): void;
}

export class NotFoundOutletContent implements OutletContent {
parent = null;
child = null;
isLoading = false;
route = null;
params = undefined;
data = undefined;
loadingComponent;
errorComponent;
notFoundComponent;
loadingAppearance: LoadingAppearance = 'auto';

constructor(props: RouterProps<any>) {
this.loadingComponent = props.loadingComponent;
this.errorComponent = props.errorComponent;
this.notFoundComponent = props.notFoundComponent;
}

abort(): void {}

getComponentOrSuspend(): ComponentType {
return this.notFoundComponent!;
}
}

export class RouteOutletContent implements OutletContent {
parent: OutletContent | null = null;
child: OutletContent | null = null;
data;
loadingComponent;
errorComponent;
notFoundComponent;
loadingAppearance;

protected _hasError = false;
protected _error;
protected _promise;
protected _component;

get isLoading(): boolean {
return this._promise !== undefined;
}

constructor(
readonly route: Route,
readonly params: unknown,
context: unknown
) {
this.loadingComponent = route.loadingComponent;
this.errorComponent = route.errorComponent;
this.notFoundComponent = route.notFoundComponent;
this.loadingAppearance = route.loadingAppearance;

this.data = this._error = this._promise = this._component = undefined;

let content;

export const NestedOutletControllerContext = createContext<OutletController | null>(null);
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;
}
}
);

this._promise = promise;
} else {
this._component = content.component;
this.data = content.data;
}
}

NestedOutletControllerContext.displayName = 'NestedOutletControllerContext';
getComponentOrSuspend(): ComponentType {
if (this.isLoading) {
throw this._promise;
}
if (this._hasError) {
throw this._error;
}
return this._component!;
}

abort() {
this._promise = undefined;
}
}

export const OutletContentContent = createContext<OutletContent | null>(null);

export const ChildOutletContentContent = createContext<OutletContent | null>(null);

export interface OutletProps {
/**
* Children that are rendered if an {@link Outlet} doesn't have any content to render.
*/
children?: ReactNode;
}

Expand All @@ -24,97 +156,133 @@ interface OutletState {
error: unknown;
}

/**
* Renders a {@link Route} provided by an enclosing {@link Router}.
*/
export class Outlet extends Component<OutletProps, OutletState> {
/**
* @internal
*/
static contextType = NestedOutletControllerContext;
static contextType = ChildOutletContentContent;

declare context: OutletContent | null;

/**
* @internal
* A content that is currently rendered on the screen.
*/
static getDerivedStateFromError(error: unknown): Partial<OutletState> | null {
return { hasError: true, error };
}
_renderedContent;

/**
* @internal
* A content that an outlet must render.
*/
declare context: OutletController | null;

private _prevController;
private _controller;
_content;

/**
* @internal
* `true` if an error must be rendered.
*/
constructor(props: OutletProps, context: OutletController | null) {
_hasError = false;

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<OutletProps>, _prevState: Readonly<OutletState>, _snapshot?: unknown): void {
if (this.state.hasError) {
this.setState({ hasError: false, error: undefined });
}
static getDerivedStateFromError(error: unknown): Partial<OutletState> | null {
return { hasError: true, error };
}

/**
* @internal
*/
render() {
if (this.context !== this._controller) {
this._controller?.abort();
this._controller = this.context;
componentDidUpdate(_prevProps: Readonly<OutletProps>, _prevState: Readonly<OutletState>, _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;
}
render(): ReactElement {
this._hasError = this.state.hasError;

if (this._content !== this.context) {
// A new content was provided
this._content = this.context;

if (this.state.hasError) {
this._controller.setError(this.state.error);
// 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 (
<Suspense fallback={<this._OutletContent isSuspendable={false} />}>
<this._OutletContent isSuspendable={true} />
<Suspense
fallback={
<OutletSuspense
outlet={this}
isSuspendable={false}
/>
}
>
<OutletSuspense
outlet={this}
isSuspendable={true}
/>
</Suspense>
);
}
}

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 (
<OutletControllerContext.Provider value={controller}>
<NestedOutletControllerContext.Provider value={controller.nestedController}>
{controller.node}
</NestedOutletControllerContext.Provider>
</OutletControllerContext.Provider>
if (isSuspendable) {
content = outlet._content!;

content.getComponentOrSuspend();

outlet._renderedContent = content;
outlet._hasError = false;
}

if (outlet._hasError) {
return renderContent(
content,
null,
outlet.state.error instanceof NotFoundError ? content.notFoundComponent : content.errorComponent
);
};
}

if (content.isLoading) {
return renderContent(content, null, content.loadingComponent);
}

return renderContent(content, content.child, content.getComponentOrSuspend());
}

function renderContent(
content: OutletContent | null,
childContent: OutletContent | null,
component: ComponentType | undefined
): ReactElement | null {
return component === undefined ? null : (
<OutletContentContent.Provider value={content}>
<ChildOutletContentContent.Provider value={childContent}>
<Memo component={component} />
</ChildOutletContentContent.Provider>
</OutletContentContent.Provider>
);
}

const Memo = memo<{ component: ComponentType }>(
props => createElement(props.component),
(prevProps, nextProps) => prevProps.component === nextProps.component
);

Memo.displayName = 'Memo';
Loading

0 comments on commit a665ce0

Please sign in to comment.