Skip to content

Commit

Permalink
feat: add client only route fallback
Browse files Browse the repository at this point in the history
  • Loading branch information
MatthewPattell committed Jan 9, 2025
1 parent c1eafd9 commit 1133e36
Show file tree
Hide file tree
Showing 6 changed files with 73 additions and 26 deletions.
10 changes: 6 additions & 4 deletions __mocks__/route-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@ const routes: TRouteObject[] = [
{
path: RouteManager.path('nestedSuspense'),
lazy: () => import('@pages/nested-suspense'),
isOnlyClient: true,
onlyClient: true,
},
{
path: RouteManager.path('redirect'),
lazy: () => import('@pages/redirect'),
onlyClient: <Spinner />,
},
{
path: RouteManager.path('redirect'),
Expand Down Expand Up @@ -93,12 +94,13 @@ const routes: TRouteObject[] = [
},
{
path: RouteManager.path('nestedSuspense'),
lazy: n(() => import('@pages/nested-suspense'), true), pathId: "@pages/nested-suspense"
lazy: n(() => import('@pages/nested-suspense'),
true), pathId: "@pages/nested-suspense"
},
{
path: RouteManager.path('redirect'),
lazy: n(() => import('@pages/redirect')), pathId: "@pages/redirect"
lazy: n(() => import('@pages/redirect'),
<Spinner />), pathId: "@pages/redirect"
},
{
path: RouteManager.path('redirect'),
Expand Down
11 changes: 10 additions & 1 deletion __tests__/helpers/import-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,16 @@ describe('importRoute', () => {

const result = await importRoute(getDynamicRoute(), true)();

expect(result.element).to.equal(null);
expect(result.Component).to.equal(null);
});

it('should return Fallback component for client only rendering', async () => {
sandbox.stub(COMMON_CONSTANTS, 'IS_SERVER').value(true);

const Fallback = () => null;
const result = await importRoute(getDynamicRoute(), Fallback)();

expect(result.Component).to.equal(Fallback);
});

it('should handle dynamic route wrap Component with renderClient', async () => {
Expand Down
11 changes: 10 additions & 1 deletion src/components/render-client.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import hoistNonReactStatics from 'hoist-non-react-statics';
import type { FC } from 'react';
import type { FC, ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import type { FCAny } from '@interfaces/fc';

Expand All @@ -8,6 +8,7 @@ import type { FCAny } from '@interfaces/fc';
*/
const renderClient = <T extends Record<string, any>>(
Component: FCAny<T> | null | undefined,
Fallback?: { element: ReactNode } | { Component: FC | null },
): FC<T> => {
const Element: FC<T> = (props) => {
const [shouldRender, setShouldRender] = useState(false);
Expand All @@ -20,6 +21,14 @@ const renderClient = <T extends Record<string, any>>(
return <Component {...props} />;
}

if (Fallback && 'element' in Fallback) {
return Fallback.element;
}

if (Fallback && 'Component' in Fallback && Fallback.Component) {
return <Fallback.Component />;
}

return null;
};

Expand Down
39 changes: 32 additions & 7 deletions src/helpers/import-route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { FC, ReactNode } from 'react';
import { isValidElement } from 'react';
import type { IndexRouteObject, NonIndexRouteObject } from 'react-router';
import renderClient from '@components/render-client';
import withSuspense from '@components/with-suspense';
import { IS_SERVER } from '@constants/common';
import type { FCCRoute, FCRoute } from '@interfaces/fc-route';
import { keys } from '@interfaces/fc-route';
import type { TOnlyClientProp } from '@interfaces/route-object';

export type IDynamicRoute = () => Promise<{ default: FCRoute | FCCRoute<any> }>;

Expand All @@ -14,21 +17,43 @@ export type IAsyncRoute = { pathId?: string } & (
| Omit<NonIndexRouteObject, ImmutableRouteKey>
);

/**
* Return right fallback syntax
*/
const getFallback = (
Fallback?: TOnlyClientProp,
): { element: ReactNode } | { Component: FC | null } => {
if (isValidElement(Fallback)) {
return { element: Fallback };
}

if ((typeof Fallback === 'function' || typeof Fallback === 'object') && Fallback !== null) {
return { Component: Fallback as FC };
}

return { Component: null };
};

/**
* Import dynamic route
*/
const importRoute = (route: IDynamicRoute, isOnlyClient = false): (() => Promise<IAsyncRoute>) => {
const importRoute = (
route: IDynamicRoute,
onlyClient?: TOnlyClientProp,
): (() => Promise<IAsyncRoute>) => {
return async (): Promise<IAsyncRoute> => {
if (isOnlyClient && IS_SERVER) {
return { element: null };
const Fallback = getFallback(onlyClient);

if (onlyClient && IS_SERVER) {
return Fallback;
}

const resolved = await route();

// fallback to react router export style
if ('Component' in resolved) {
const Component = isOnlyClient
? renderClient(resolved.Component as FCRoute | FCCRoute<any>)
const Component = onlyClient
? renderClient(resolved.Component as FCRoute | FCCRoute<any>, Fallback)
: resolved.Component;

return { ...resolved, Component } as IAsyncRoute;
Expand All @@ -48,8 +73,8 @@ const importRoute = (route: IDynamicRoute, isOnlyClient = false): (() => Promise
result.Component = withSuspense(Component, Component.Suspense);
}

if (isOnlyClient) {
result.Component = renderClient(result.Component as FCRoute | FCCRoute<any>);
if (onlyClient) {
result.Component = renderClient(result.Component as FCRoute | FCCRoute<any>, Fallback);
}

return result;
Expand Down
5 changes: 4 additions & 1 deletion src/interfaces/route-object.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { RouteObject } from 'react-router';
import type { IDynamicRoute } from '@helpers/import-route';

export type TOnlyClientProp = RouteObject['Component'] | RouteObject['element'];

export type TRouteObjectNR = Omit<RouteObject, 'lazy' | 'children'> & {
lazy?: IDynamicRoute | RouteObject['lazy'];
isOnlyClient?: boolean; // render route only on client side
// render route only on client side
onlyClient?: TOnlyClientProp;
children?: TRouteObject[];
};

Expand Down
23 changes: 11 additions & 12 deletions src/services/parse-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
identifier,
isBooleanLiteral,
stringLiteral,
booleanLiteral,
isFunction,
objectProperty,
isArrayExpression,
isJSXElement,
Expand Down Expand Up @@ -411,13 +411,8 @@ class ParseRoutes {
// async routes
if (property.key.name === 'lazy' && property.value.type === 'ArrowFunctionExpression') {
const importCall = property.value.body as CallExpression;
const isOnlyClientPropIndex = nodePath.node.properties.findIndex(
(p) =>
isObjectProperty(p) &&
isIdentifier(p.key) &&
isBooleanLiteral(p.value) &&
p.key.name === 'isOnlyClient' &&
p.value.value,
const onlyClientProp = nodePath.node.properties.find(
(p) => isObjectProperty(p) && isIdentifier(p.key) && p.key.name === 'onlyClient',
);

/**
Expand All @@ -431,13 +426,17 @@ class ParseRoutes {
name: 'n',
},
arguments:
isOnlyClientPropIndex !== -1
? [property.value, booleanLiteral(true)]
isObjectProperty(onlyClientProp) &&
(isBooleanLiteral(onlyClientProp.value) ||
isJSXElement(onlyClientProp.value) ||
isFunction(onlyClientProp.value) ||
isIdentifier(onlyClientProp.value))
? [property.value, onlyClientProp.value]
: [property.value],
};

if (isOnlyClientPropIndex !== -1) {
nodePath.node.properties.splice(isOnlyClientPropIndex, 1);
if (onlyClientProp) {
nodePath.node.properties = nodePath.node.properties.filter((p) => p !== onlyClientProp);
}

addImportRouteWrapper();
Expand Down

0 comments on commit 1133e36

Please sign in to comment.