Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: React Router V7 not working with lazy route elements #322

Merged
merged 1 commit into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
4 changes: 4 additions & 0 deletions src/react.d.ts
AndreaCuneo marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// reason: https://stackoverflow.com/a/71017028
declare namespace React {
function lazy<T extends ComponentType<unknown>>(factory: () => Promise<{ default: T }>): T;
}
56 changes: 22 additions & 34 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 Expand Up @@ -213,16 +214,3 @@ export const mainSections: MainSectionType[] = [
},
];

export function getEntryPointPath(sections: MainSectionType[]): string {
for (const section of sections) {
for (const subsection of section.subsections ?? []) {
if (subsection.path === "/") {
return "/";
}
if (subsection.isEntryPoint) {
return (section.path ?? "" + subsection.path) || "/";
}
}
}
return "/";
}
Loading