Skip to content

Commit

Permalink
feat: support client only routes
Browse files Browse the repository at this point in the history
  • Loading branch information
MatthewPattell committed Jan 9, 2025
1 parent d78a886 commit c1eafd9
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 13 deletions.
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,6 @@ const routes: RouteObject[] = [
element: <AppLayout />, // support
lazy: () => import('./pages/home'), // support
lazy: () => import(importPath), // not support, but you can move logic in separate file and import it with supported case
lazy: () => { // not support
return import('./pages/home');
}
}
];
```
Expand Down
4 changes: 3 additions & 1 deletion __mocks__/route-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const routes: TRouteObject[] = [
{
path: RouteManager.path('nestedSuspense'),
lazy: () => import('@pages/nested-suspense'),
isOnlyClient: true,
},
{
path: RouteManager.path('redirect'),
Expand Down Expand Up @@ -92,7 +93,8 @@ const routes: TRouteObject[] = [
},
{
path: RouteManager.path('nestedSuspense'),
lazy: n(() => import('@pages/nested-suspense')), pathId: "@pages/nested-suspense"
lazy: n(() => import('@pages/nested-suspense'), true), pathId: "@pages/nested-suspense"
},
{
path: RouteManager.path('redirect'),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { render } from '@testing-library/react';
import { expect } from 'chai';
import { describe, it } from 'vitest';
import React from 'react';
import sinon from 'sinon';
import { afterEach, describe, it } from 'vitest';
import * as COMMON_CONSTANTS from '@constants/common';
import type { IDynamicRoute } from '@helpers/import-route';
import importRoute from '@helpers/import-route';
import type { FCRoute } from '@interfaces/fc-route';
import { keys } from '@interfaces/fc-route';

describe('importRoute', () => {
const Component = (() => null) as unknown as FCRoute;
const sandbox = sinon.createSandbox();

const Component = (() => 'Test') as unknown as FCRoute;
const getDynamicRoute = (props: Record<string, any> = {}, isDefaultExport = false) =>
(() =>
Promise.resolve(
isDefaultExport ? { default: { Component, ...props } } : { Component, ...props },
)) as unknown as IDynamicRoute;

afterEach(() => {
sandbox.restore();
});

it('should import dynamic route and return an IAsyncRoute object with Component', async () => {
const result = await importRoute(getDynamicRoute())();

Expand Down Expand Up @@ -61,4 +71,21 @@ describe('importRoute', () => {
expect(result).to.have.property(key).and.to.equal(value);
});
});

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

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

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

it('should handle dynamic route wrap Component with renderClient', async () => {
const result = await importRoute(getDynamicRoute(), true)();
const ClientComponent = result.Component!;

const { container } = render(<ClientComponent />);

expect(container.textContent).to.equal('Test');
});
});
31 changes: 31 additions & 0 deletions src/components/render-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import hoistNonReactStatics from 'hoist-non-react-statics';
import type { FC } from 'react';
import React, { useEffect, useState } from 'react';
import type { FCAny } from '@interfaces/fc';

/**
* HOC: Render component only on client side
*/
const renderClient = <T extends Record<string, any>>(
Component: FCAny<T> | null | undefined,
): FC<T> => {
const Element: FC<T> = (props) => {
const [shouldRender, setShouldRender] = useState(false);

useEffect(() => {
setShouldRender(true);
}, []);

if (shouldRender && Component) {
return <Component {...props} />;
}

return null;
};

hoistNonReactStatics(Element, Component);

return Element;
};

export default renderClient;
2 changes: 1 addition & 1 deletion src/components/with-suspense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React from 'react';
import type { FCAny, FCC } from '@interfaces/fc';

/**
* Wrap component in suspense
* HOC: Wrap component in suspense
*/
const withSuspense = <T extends Record<string, any>>(
Component: FCAny<T>,
Expand Down
5 changes: 2 additions & 3 deletions src/constants/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// @ts-ignore
const IS_SSR_MODE = (typeof __IS_SSR__ === 'undefined' ? true : __IS_SSR__) as boolean; // build in SSR mode?

// const IS_SERVER = typeof window === 'undefined';
const IS_SERVER = typeof window === 'undefined';

// eslint-disable-next-line import/prefer-default-export
export { IS_SSR_MODE };
export { IS_SSR_MODE, IS_SERVER };
18 changes: 16 additions & 2 deletions src/helpers/import-route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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';

Expand All @@ -15,13 +17,21 @@ export type IAsyncRoute = { pathId?: string } & (
/**
* Import dynamic route
*/
const importRoute = (route: IDynamicRoute): (() => Promise<IAsyncRoute>) => {
const importRoute = (route: IDynamicRoute, isOnlyClient = false): (() => Promise<IAsyncRoute>) => {
return async (): Promise<IAsyncRoute> => {
if (isOnlyClient && IS_SERVER) {
return { element: null };
}

const resolved = await route();

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

return { ...resolved, Component } as IAsyncRoute;
}

const Component = resolved.default;
Expand All @@ -38,6 +48,10 @@ const importRoute = (route: IDynamicRoute): (() => Promise<IAsyncRoute>) => {
result.Component = withSuspense(Component, Component.Suspense);
}

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

return result;
};
};
Expand Down
1 change: 1 addition & 0 deletions src/interfaces/route-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { IDynamicRoute } from '@helpers/import-route';

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

Expand Down
19 changes: 18 additions & 1 deletion src/services/parse-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import {
isObjectProperty,
isIdentifier,
identifier,
isBooleanLiteral,
stringLiteral,
booleanLiteral,
objectProperty,
isArrayExpression,
isJSXElement,
Expand Down Expand Up @@ -409,6 +411,14 @@ 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,
);

/**
* Wrap lazy import with:
Expand All @@ -420,9 +430,16 @@ class ParseRoutes {
type: 'Identifier',
name: 'n',
},
arguments: [property.value],
arguments:
isOnlyClientPropIndex !== -1
? [property.value, booleanLiteral(true)]
: [property.value],
};

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

addImportRouteWrapper();

if (importCall.type === 'CallExpression' && importCall.callee.type === 'Import') {
Expand Down

0 comments on commit c1eafd9

Please sign in to comment.