diff --git a/__mocks__/route-file.ts b/__mocks__/route-file.ts index 963f119..cc104cd 100644 --- a/__mocks__/route-file.ts +++ b/__mocks__/route-file.ts @@ -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: , }, { path: RouteManager.path('redirect'), @@ -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'), + ), pathId: "@pages/redirect" }, { path: RouteManager.path('redirect'), diff --git a/__tests__/helpers/import-route.tsx b/__tests__/helpers/import-route.tsx index 2bc0b80..decdd96 100644 --- a/__tests__/helpers/import-route.tsx +++ b/__tests__/helpers/import-route.tsx @@ -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 () => { diff --git a/src/components/render-client.tsx b/src/components/render-client.tsx index 78cfa3d..afb152a 100644 --- a/src/components/render-client.tsx +++ b/src/components/render-client.tsx @@ -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'; @@ -8,6 +8,7 @@ import type { FCAny } from '@interfaces/fc'; */ const renderClient = >( Component: FCAny | null | undefined, + Fallback?: { element: ReactNode } | { Component: FC | null }, ): FC => { const Element: FC = (props) => { const [shouldRender, setShouldRender] = useState(false); @@ -20,6 +21,14 @@ const renderClient = >( return ; } + if (Fallback && 'element' in Fallback) { + return Fallback.element; + } + + if (Fallback && 'Component' in Fallback && Fallback.Component) { + return ; + } + return null; }; diff --git a/src/helpers/import-route.ts b/src/helpers/import-route.ts index 351eeb6..2e83a6b 100644 --- a/src/helpers/import-route.ts +++ b/src/helpers/import-route.ts @@ -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 }>; @@ -14,21 +17,43 @@ export type IAsyncRoute = { pathId?: string } & ( | Omit ); +/** + * 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) => { +const importRoute = ( + route: IDynamicRoute, + onlyClient?: TOnlyClientProp, +): (() => Promise) => { return async (): Promise => { - 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) + const Component = onlyClient + ? renderClient(resolved.Component as FCRoute | FCCRoute, Fallback) : resolved.Component; return { ...resolved, Component } as IAsyncRoute; @@ -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); + if (onlyClient) { + result.Component = renderClient(result.Component as FCRoute | FCCRoute, Fallback); } return result; diff --git a/src/interfaces/route-object.ts b/src/interfaces/route-object.ts index 25b2503..7c99039 100644 --- a/src/interfaces/route-object.ts +++ b/src/interfaces/route-object.ts @@ -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 & { lazy?: IDynamicRoute | RouteObject['lazy']; - isOnlyClient?: boolean; // render route only on client side + // render route only on client side + onlyClient?: TOnlyClientProp; children?: TRouteObject[]; }; diff --git a/src/services/parse-routes.ts b/src/services/parse-routes.ts index 3b26432..b244048 100644 --- a/src/services/parse-routes.ts +++ b/src/services/parse-routes.ts @@ -19,7 +19,7 @@ import { identifier, isBooleanLiteral, stringLiteral, - booleanLiteral, + isFunction, objectProperty, isArrayExpression, isJSXElement, @@ -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', ); /** @@ -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();