From c1eafd92c6ba1376fb8ac71d0b6fdc4d286fa9f0 Mon Sep 17 00:00:00 2001 From: MatthewPattell Date: Thu, 9 Jan 2025 16:42:15 +0100 Subject: [PATCH] feat: support client only routes --- README.md | 3 -- __mocks__/route-file.ts | 4 ++- .../{import-route.ts => import-route.tsx} | 31 +++++++++++++++++-- src/components/render-client.tsx | 31 +++++++++++++++++++ src/components/with-suspense.tsx | 2 +- src/constants/common.ts | 5 ++- src/helpers/import-route.ts | 18 +++++++++-- src/interfaces/route-object.ts | 1 + src/services/parse-routes.ts | 19 +++++++++++- 9 files changed, 101 insertions(+), 13 deletions(-) rename __tests__/helpers/{import-route.ts => import-route.tsx} (69%) create mode 100644 src/components/render-client.tsx diff --git a/README.md b/README.md index f37d36b..de35e4d 100644 --- a/README.md +++ b/README.md @@ -326,9 +326,6 @@ const routes: RouteObject[] = [ element: , // 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'); - } } ]; ``` diff --git a/__mocks__/route-file.ts b/__mocks__/route-file.ts index db64b04..963f119 100644 --- a/__mocks__/route-file.ts +++ b/__mocks__/route-file.ts @@ -32,6 +32,7 @@ const routes: TRouteObject[] = [ { path: RouteManager.path('nestedSuspense'), lazy: () => import('@pages/nested-suspense'), + isOnlyClient: true, }, { path: RouteManager.path('redirect'), @@ -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'), diff --git a/__tests__/helpers/import-route.ts b/__tests__/helpers/import-route.tsx similarity index 69% rename from __tests__/helpers/import-route.ts rename to __tests__/helpers/import-route.tsx index 7e9c3a2..2bc0b80 100644 --- a/__tests__/helpers/import-route.ts +++ b/__tests__/helpers/import-route.tsx @@ -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 = {}, 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())(); @@ -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(); + + expect(container.textContent).to.equal('Test'); + }); }); diff --git a/src/components/render-client.tsx b/src/components/render-client.tsx new file mode 100644 index 0000000..78cfa3d --- /dev/null +++ b/src/components/render-client.tsx @@ -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 = >( + Component: FCAny | null | undefined, +): FC => { + const Element: FC = (props) => { + const [shouldRender, setShouldRender] = useState(false); + + useEffect(() => { + setShouldRender(true); + }, []); + + if (shouldRender && Component) { + return ; + } + + return null; + }; + + hoistNonReactStatics(Element, Component); + + return Element; +}; + +export default renderClient; diff --git a/src/components/with-suspense.tsx b/src/components/with-suspense.tsx index e33ffb0..2216c96 100644 --- a/src/components/with-suspense.tsx +++ b/src/components/with-suspense.tsx @@ -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 = >( Component: FCAny, diff --git a/src/constants/common.ts b/src/constants/common.ts index 325d377..9158462 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -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 }; diff --git a/src/helpers/import-route.ts b/src/helpers/import-route.ts index ad5835f..351eeb6 100644 --- a/src/helpers/import-route.ts +++ b/src/helpers/import-route.ts @@ -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'; @@ -15,13 +17,21 @@ export type IAsyncRoute = { pathId?: string } & ( /** * Import dynamic route */ -const importRoute = (route: IDynamicRoute): (() => Promise) => { +const importRoute = (route: IDynamicRoute, isOnlyClient = false): (() => Promise) => { return async (): Promise => { + 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) + : resolved.Component; + + return { ...resolved, Component } as IAsyncRoute; } const Component = resolved.default; @@ -38,6 +48,10 @@ const importRoute = (route: IDynamicRoute): (() => Promise) => { result.Component = withSuspense(Component, Component.Suspense); } + if (isOnlyClient) { + result.Component = renderClient(result.Component as FCRoute | FCCRoute); + } + return result; }; }; diff --git a/src/interfaces/route-object.ts b/src/interfaces/route-object.ts index 14048cb..25b2503 100644 --- a/src/interfaces/route-object.ts +++ b/src/interfaces/route-object.ts @@ -3,6 +3,7 @@ import type { IDynamicRoute } from '@helpers/import-route'; export type TRouteObjectNR = Omit & { lazy?: IDynamicRoute | RouteObject['lazy']; + isOnlyClient?: boolean; // render route only on client side children?: TRouteObject[]; }; diff --git a/src/services/parse-routes.ts b/src/services/parse-routes.ts index a162a4c..3b26432 100644 --- a/src/services/parse-routes.ts +++ b/src/services/parse-routes.ts @@ -17,7 +17,9 @@ import { isObjectProperty, isIdentifier, identifier, + isBooleanLiteral, stringLiteral, + booleanLiteral, objectProperty, isArrayExpression, isJSXElement, @@ -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: @@ -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') {