Skip to content

Commit

Permalink
fix: React Router V7 not working with lazy route elements
Browse files Browse the repository at this point in the history
  • Loading branch information
AndreaCuneo committed Jan 10, 2025
1 parent ea12cc8 commit 365e25e
Show file tree
Hide file tree
Showing 7 changed files with 103 additions and 72 deletions.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@
"prestart:app": "wait-on --log --timeout 30000 http-get://localhost:4000",
"start:app": "vite",
"build": "tsc && vite build",
"preview": "concurrently \"npm run localSettings\" \"npm run vite-preview\"",
"vite-preview": "vite preview",
"prepreview": "cross-env npm run build",
"preview": "concurrently --kill-others --success first \"npm:preview:*\"",
"preview:connectionStrings": "cross-env PORT=4000 node --watch -- public/connectionStrings.cjs",
"prepreview:app": "wait-on --log --timeout 30000 http-get://localhost:4000",
"preview:app": "cross-env PORT=4321 vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "vitest",
"test:watch": "vitest --watch",
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/sideBar/menuItem/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ export type MainSectionType = {
path?: string;
authenticatedOnly?: boolean;
component?: ReactNode;
lazy?: () => Promise<{ default: React.ComponentType<unknown> }>;
permissions?: string[];
};

export type SubsectionMenuItemType = {
isInMenu: boolean;
icon?: IconType;
externalUrl?: string;
isEntryPoint?: boolean;
} & MainSectionType;
24 changes: 11 additions & 13 deletions src/features/formWizard/formWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import * as z from "zod";

import { CheckboxControl, InputControl } from "../../components/formControls";
import { Wizard, WizardPage } from "../../components/wizard/wizard";
import { delay } from "../../lib/helper";

const sleep = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms));


const _wizardSchema = z.object({
firstName: z.string().min(6),
lastname: z.string().min(6),
Expand All @@ -31,39 +29,39 @@ export default function WizardFormView() {
const { t } = useTranslation();

const onSubmit = async (values: Schema) => {
await sleep(300);
await delay(300);
window.alert(JSON.stringify(values, null, 2));
};

return (
<Box>
<Heading>{t("wizard_form_title")}</Heading>
<Box marginTop={"20px"}>
<Wizard<Schema>
<Wizard<Schema>
onSubmit={onSubmit}
formProps={{ mode: 'onChange', resolver: zodResolver(_wizardSchema) }}
>
<WizardPage>
<Stack spacing={4}>
<InputControl name="firstName" label={t('firstname')} placeholder={t("wizard_first_name_placeholder")} />
<InputControl name="lastName" label={t('lastname')} placeholder={t("wizard_last_name_placeholder")} />
<InputControl name="email" label={t('email')} placeholder={t("wizard_email_placeholder")} />
<InputControl name="password" label={t('password')} placeholder={t("wizard_password_placeholder")} inputProps={{ type: 'password' }} />
<InputControl name="firstName" label={t('firstname')} placeholder={t("wizard_first_name_placeholder")} />
<InputControl name="lastName" label={t('lastname')} placeholder={t("wizard_last_name_placeholder")} />
<InputControl name="email" label={t('email')} placeholder={t("wizard_email_placeholder")} />
<InputControl name="password" label={t('password')} placeholder={t("wizard_password_placeholder")} inputProps={{ type: 'password' }} />
</Stack>
</WizardPage>

<WizardPage>
<Stack spacing={4}>
<InputControl name="phone" label={t('Phone')} placeholder={t("wizard_phone_placeholder")} />
<InputControl name="billingAddress" label={t('billing_address')} placeholder={t("wizard_billing_address_placeholder")} />
<InputControl name="phone" label={t('Phone')} placeholder={t("wizard_phone_placeholder")} />
<InputControl name="billingAddress" label={t('billing_address')} placeholder={t("wizard_billing_address_placeholder")} />
<InputControl name="shippingAddress" label={t('shipping_address')} placeholder={t("wizard_shipping_address_placeholder")} />
</Stack>
</WizardPage>

<WizardPage>
<Stack spacing={4}>
<CheckboxControl name="newsletter" label={t("wizard_newsletter_label")} />
<CheckboxControl name="specialOffers" label={t("wizard_special_offers_label")} />
<CheckboxControl name="newsletter" label={t("wizard_newsletter_label")} />
<CheckboxControl name="specialOffers" label={t("wizard_special_offers_label")} />
<CheckboxControl name="smsNotifications" label={t("wizard_sms_notifications_label")} />
</Stack>
</WizardPage>
Expand Down
5 changes: 2 additions & 3 deletions src/lib/authentication/components/protectedRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React from "react";
import type { PropsWithChildren } from "react";

import { useAppSelector } from "../../../app/hooks";
import { userSelector } from "../authenticationSlice";
import Unauthorized from "../unauthorized";

type ProtectedRouteProps = {
permissions?: string[];
children: React.ReactNode;
};

const ProtectedRoute = ({ permissions, children }: ProtectedRouteProps) => {
const ProtectedRoute = ({ permissions, children }: PropsWithChildren<ProtectedRouteProps>) => {
const user = useAppSelector(userSelector);
const userPermissions = user?.permissions ?? ([] as string[]);
const hasAllPermissions = permissions ? permissions.every(permission => userPermissions.includes(permission)) : true;
Expand Down
91 changes: 59 additions & 32 deletions src/lib/router.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import type { ReactNode } from "react";
import { Else, If, Then } from "react-if";
import { Outlet, Route, createBrowserRouter, createRoutesFromChildren } from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { Outlet, createBrowserRouter } from "react-router-dom";

import Layout from "../components/layout/layout";
import type { MainSectionType, SubsectionMenuItemType } from "../components/layout/sideBar/menuItem/types";
import LazyLoad from "../components/lazyLoad";
import PageNotFound from "../components/pageNotFound";
import SEO from "../components/seo";
import { mainSections } from "../siteMap/mainSections";
Expand All @@ -13,9 +16,16 @@ import ProtectedRoute from "./authentication/components/protectedRoute";
import Unauthorized from "./authentication/unauthorized";
import { ErrorFallback } from "./errorFallback";

const wrapComponent = (x: MainSectionType) => {
const wrapLazy = (x: MainSectionType) => {
const checkPermissions = x.permissions && x.permissions.length > 0;

let element: ReactNode = <Outlet />;
if (x.component) element = x.component;
const lazy = x.lazy;
// key={x.label} is needed to force a rerender when the route changes due to https://github.com/remix-run/react-router/issues/12474
// assumption: x.label is unique across all routes
if (lazy) element = <LazyLoad loader={lazy} key={x.label} />;

return (
<>
<SEO title={x.label} />
Expand All @@ -25,14 +35,22 @@ const wrapComponent = (x: MainSectionType) => {
<Then>
{() => (
<AuthenticatedOnly>
<ProtectedRoute permissions={x.permissions}>{x.component ?? <Outlet />}</ProtectedRoute>
<ProtectedRoute permissions={x.permissions}>
{element}
</ProtectedRoute>
</AuthenticatedOnly>
)}
</Then>
<Else>{() => <AuthenticatedOnly>{x.component ?? <Outlet />}</AuthenticatedOnly>}</Else>
<Else>
{() =>
<AuthenticatedOnly>
{element}
</AuthenticatedOnly>
}
</Else>
</If>
</Then>
<Else>{() => <>{x.component ?? <Outlet />}</>}</Else>
<Else>{element}</Else>
</If>
</>
);
Expand All @@ -41,48 +59,57 @@ const wrapComponent = (x: MainSectionType) => {
const renderSections = (s?: SubsectionMenuItemType[]) => {
return s
?.filter(x => x.path !== undefined)
.map((x, i) =>
.map((x): RouteObject =>
x.path === "" && !x.subsections ? ( // index route, shall not have children
<Route key={i} index element={wrapComponent(x)} />
{ index: true, element: wrapLazy(x) }
) : (
<Route key={i} path={x.path} element={wrapComponent(x)}>
{renderSections(x.subsections)}
</Route>
{ path: x.path, element: wrapLazy(x), children: renderSections(x.subsections) }
),
);
};

const routes = mainSections
.filter(x => x.path !== undefined)
.map((x, i) =>
.map((x): RouteObject =>
x.path === "" && !x.subsections ? ( // index route, shall not have children
<Route key={i} index element={wrapComponent(x)} />
{ index: true, element: wrapLazy(x) }
) : (
<Route key={i} path={x.path} element={wrapComponent(x)}>
{renderSections(x.subsections)}
</Route>
{ path: x.path, element: wrapLazy(x), children: renderSections(x.subsections) }
),
);

export const router = createBrowserRouter(
createRoutesFromChildren(
<Route path="/" element={<Layout />}>
<Route errorElement={<ErrorFallback />}>
<Route path="auth-callback" element={<AuthenticationCallback />} />
<Route path="Unauthorized" element={<Unauthorized />} />
{routes}
<Route path="/null" element={<></>} />
<Route path="*" element={<PageNotFound />} />
</Route>
</Route>,
),
export const router = createBrowserRouter([
{
path: "/",
Component: Layout,
children: [
{
errorElement: <ErrorFallback />,
children: [
{
path: "auth-callback",
Component: AuthenticationCallback
},
{
path: "Unauthorized",
Component: Unauthorized
},
...routes,
{
path: "null",
element: null
},
{
path: "*",
Component: PageNotFound
}
]
}
]
}
],
{
future: {
v7_relativeSplatPath: true,
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
v7_skipActionErrorRevalidation: true
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/react.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
declare namespace React {
function lazy<T extends ComponentType<unknown>>(factory: () => Promise<{ default: T }>): T;
}
43 changes: 22 additions & 21 deletions src/siteMap/mainSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { Navigate } from "react-router-dom";

import { Bomb } from "../components/Bomb";
import type { MainSectionType } from "../components/layout/sideBar/menuItem/types";
import LazyLoad from "../components/lazyLoad";
import DetailsPage from "../features/detailsPage/detailsPage";
import DetailsPageExampleMainView from "../features/detailsPage/detailsPageExampleMainView";
import NoEntryPoint from "../features/staticPageExample/staticPage";
Expand All @@ -31,44 +30,39 @@ export const mainSections: MainSectionType[] = [
path: "",
label: "index",
isInMenu: false,
component: <Navigate to="jsonplaceholder" />,
isEntryPoint: true,
component: <Navigate to="jsonplaceholder" />
},
{
path: "jsonplaceholder",
label: "Posts",
icon: FaCloudUploadAlt,
isInMenu: true,
component: <LazyLoad loader={async () => import("../features/fetchApiExample/JsonPlaceHolder")} />,
isEntryPoint: true,
lazy: async () => import("../features/fetchApiExample/JsonPlaceHolder"),
},
{
path: "playground",
label: "PlayGround",
icon: FaPlay,
isInMenu: true,
component: (
<LazyLoad loader={async () => import("../features/notificationPlayground/notificationPlaygroundView")} />
),
lazy: async () => import("../features/notificationPlayground/notificationPlaygroundView")

},
{
path: "globalLoadingBar",
label: "GlobalLoadingBar",
icon: FaPlay,
isInMenu: true,
component: (
<LazyLoad loader={async () => import("../features/globalLoadingBar/globalLoadingPage")} />
),
lazy: async () => import("../features/globalLoadingBar/globalLoadingPage")

},
{
path: "permissionsPlayground",
label: "Permissions",
authenticatedOnly: true,
icon: CiLock,
isInMenu: true,
component: (
<LazyLoad loader={async () => import("../features/permissionsPlayground/permissionsPlaygroundView")} />
),
lazy: async () => import("../features/permissionsPlayground/permissionsPlaygroundView")

},
{
path: "protectedRoute",
Expand All @@ -77,44 +71,50 @@ export const mainSections: MainSectionType[] = [
permissions: ["grant:admin"],
icon: CiLock,
isInMenu: false,
component: <LazyLoad loader={async () => import("../features/permissionsPlayground/protectedRouteView")} />,
lazy: async () => import("../features/permissionsPlayground/protectedRouteView")
,
},

{
path: "configTable",
label: "Config Table",
icon: FaTable,
isInMenu: true,
component: <LazyLoad loader={async () => import("../features/configTable/configTableExample")} />,
lazy: async () => import("../features/configTable/configTableExample")
,
},
{
path: "moviesTable",
label: "Movie Paginated Table",
icon: RiMovie2Line,
isInMenu: true,
component: <LazyLoad loader={async () => import("../features/paginatedTable/moviePage")} />,
lazy: async () => import("../features/paginatedTable/moviePage")
,
},
{
path: "videoGamesTable",
label: "VideoGames Table",
icon: FaGamepad,
isInMenu: true,
component: <LazyLoad loader={async () => import("../features/formExample/videoGamesPage")} />,
lazy: async () => import("../features/formExample/videoGamesPage")
,
},

{
path: "wizardForm",
label: "Wizard Form",
icon: FaTable,
isInMenu: true,
component: <LazyLoad loader={async () => import("../features/formWizard/formWizard")} />,
lazy: async () => import("../features/formWizard/formWizard")
,
},
{
path: "translation",
label: "Translation Sample",
icon: FaFlag,
isInMenu: true,
component: <LazyLoad loader={async () => import("../features/localization/localizationPage")} />,
lazy: async () => import("../features/localization/localizationPage")
,
},

{
Expand All @@ -123,7 +123,8 @@ export const mainSections: MainSectionType[] = [
icon: FaLock,
isInMenu: true,
authenticatedOnly: true,
component: <LazyLoad loader={async () => import("../features/authentication/authInfoPage")} />,
lazy: async () => import("../features/authentication/authInfoPage")
,
},
{
path: "bomb",
Expand Down

0 comments on commit 365e25e

Please sign in to comment.