Skip to content

Commit

Permalink
Introduced slots
Browse files Browse the repository at this point in the history
  • Loading branch information
smikhalevski committed Jul 13, 2024
1 parent 107c864 commit 742f799
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 359 deletions.
15 changes: 15 additions & 0 deletions src/main/NotFoundSlotContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ComponentType } from 'react';
import { SlotContent, SlotContentComponents } from './Slot';

export class NotFoundSlotContent implements SlotContent {
errorComponent: ComponentType | undefined;
notFoundComponent: ComponentType | undefined;

constructor(
readonly renderedComponent: ComponentType | undefined,
options: SlotContentComponents
) {
this.errorComponent = options.errorComponent;
this.notFoundComponent = options.notFoundComponent;
}
}
345 changes: 12 additions & 333 deletions src/main/Outlet.tsx
Original file line number Diff line number Diff line change
@@ -1,347 +1,26 @@
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';

/**
* A content rendered in an {@link Outlet}.
*/
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<any>) {
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;
}
}
);

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

suspend(): void {
if (this._promise !== undefined) {
throw this._promise;
}
if (this._hasError) {
throw this._error;
}
}

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

/**
* The content of the current outlet. Used by hooks.
*/
export const OutletContentContext = createContext<OutletContent | null>(null);

OutletContentContext.displayName = 'OutletContentContext';

/**
* The content of the child outlet. Used by outlets.
*/
export const ChildOutletContentContext = createContext<OutletContent | null>(null);

ChildOutletContentContext.displayName = 'ChildOutletContentContext';
import React, { ReactNode, useContext } from 'react';
import { ChildSlotContentContext, Slot } from './Slot';

/**
* Props of an {@link Outlet}.
*/
export interface OutletProps {
/**
* A fallback that is rendered if there's no content to render in an {@link Outlet}.
* A content that is rendered if there's nothing render.
*/
children?: ReactNode;

/**
* @internal
*/
ref?: never;
}

interface OutletState {
hasError: boolean;
error: unknown;
}

/**
* Renders a content of a route provided by a {@link Router}.
* Renders route provided be an enclosing {@link Router}.
*/
export class Outlet extends Component<OutletProps, OutletState> {
/**
* @internal
*/
static displayName = 'Outlet';

/**
* @internal
*/
static contextType = ChildOutletContentContext;

/**
* @internal
*/
declare context: OutletContent | null;

/**
* A content that is currently rendered on the screen.
*
* @internal
*/
_renderedContent;

/**
* A content that an outlet must render.
*
* @internal
*/
_content;

/**
* `true` if an error must be rendered.
*
* @internal
*/
_hasError = false;

/**
* @internal
*/
constructor(props: OutletProps) {
super(props);

this.state = { hasError: false, error: undefined };

this._renderedContent = this._content = this.context;
}

/**
* @internal
*/
static getDerivedStateFromError(error: unknown): Partial<OutletState> | null {
return { hasError: true, error };
}

/**
* @internal
*/
componentDidUpdate(_prevProps: Readonly<OutletProps>, _prevState: Readonly<OutletState>, _snapshot?: any): void {
if (this._hasError || !this.state.hasError) {
return;
}
this.setState({ hasError: false, error: undefined });
}

/**
* @internal
*/
render(): ReactElement {
this._hasError = this.state.hasError;

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 (
<Suspense
fallback={
<OutletSuspense
outlet={this}
isSuspendable={false}
/>
}
>
<OutletSuspense
outlet={this}
isSuspendable={true}
/>
</Suspense>
);
}
}

interface OutletSuspenseProps {
outlet: Outlet;
isSuspendable: boolean;
}

function OutletSuspense({ outlet, isSuspendable }: OutletSuspenseProps): ReactNode {
let content = outlet._renderedContent;

if (content === null) {
return outlet.props.children;
}

if (isSuspendable) {
content = outlet._content!;

content.suspend?.();

if (outlet._renderedContent !== content) {
outlet._renderedContent = content;

// Reset the error status after a content was loaded
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 : (
<OutletContentContext.Provider value={content}>
<ChildOutletContentContext.Provider value={childContent}>
<Memo component={component} />
</ChildOutletContentContext.Provider>
</OutletContentContext.Provider>
export function Outlet(props: OutletProps): ReactNode {
const content = useContext(ChildSlotContentContext);

return (
<Slot
content={content}
children={props.children}
/>
);
}

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

Memo.displayName = 'Memo';
Loading

0 comments on commit 742f799

Please sign in to comment.