diff --git a/client/app/bundles/course/container/CourseContainer.tsx b/client/app/bundles/course/container/CourseContainer.tsx
index 9ad3a4d7fed..03c31b05368 100644
--- a/client/app/bundles/course/container/CourseContainer.tsx
+++ b/client/app/bundles/course/container/CourseContainer.tsx
@@ -7,9 +7,12 @@ import { CourseLayoutData } from 'types/course/courses';
import CikgoSidebarItems from 'course/stories/components/CikgoSidebarItems';
import PopupNotifier from 'course/user-notification/PopupNotifier';
import Footer from 'lib/components/core/layouts/Footer';
-import { DataHandle, useDynamicNest } from 'lib/hooks/router/dynamicNest';
-import { DEFAULT_WINDOW_TITLE } from 'lib/hooks/router/dynamicNest/constants';
-import { getLastCrumbTitle } from 'lib/hooks/router/dynamicNest/crumbs';
+import {
+ DataHandle,
+ DEFAULT_WINDOW_TITLE,
+ getLastCrumbTitle,
+ useDynamicNest,
+} from 'lib/hooks/router/dynamicNest';
import useTranslation, { translatable } from 'lib/hooks/useTranslation';
import Breadcrumbs from './Breadcrumbs';
diff --git a/client/app/lib/containers/CourselessContainer.tsx b/client/app/lib/containers/CourselessContainer.tsx
index 8e6bd9d7df7..5ea94ca6b55 100644
--- a/client/app/lib/containers/CourselessContainer.tsx
+++ b/client/app/lib/containers/CourselessContainer.tsx
@@ -2,9 +2,11 @@ import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import Footer from 'lib/components/core/layouts/Footer';
-import { useDynamicNest } from 'lib/hooks/router/dynamicNest';
-import { DEFAULT_WINDOW_TITLE } from 'lib/hooks/router/dynamicNest/constants';
-import { getLastCrumbTitle } from 'lib/hooks/router/dynamicNest/crumbs';
+import {
+ DEFAULT_WINDOW_TITLE,
+ getLastCrumbTitle,
+ useDynamicNest,
+} from 'lib/hooks/router/dynamicNest';
import useTranslation, { translatable } from 'lib/hooks/useTranslation';
import BrandingHead from '../components/navigation/BrandingHead';
diff --git a/client/app/lib/hooks/router/dynamicNest.ts b/client/app/lib/hooks/router/dynamicNest.ts
new file mode 100644
index 00000000000..8960b14a100
--- /dev/null
+++ b/client/app/lib/hooks/router/dynamicNest.ts
@@ -0,0 +1,39 @@
+import {
+ type CrumbContent as RDBCrumbContent,
+ type CrumbData as RDBCrumbData,
+ type CrumbPath as RDBCrumbPath,
+ type DataHandle as RDBDataHandle,
+ forEachFlatCrumb,
+ getLastCrumbTitle,
+ type Match as RDBMatch,
+ useDynamicBreadcrumbs,
+} from 'react-dynamic-breadcrumbs';
+import { Location, useLocation, useMatches } from 'react-router-dom';
+
+import type { Descriptor } from '../useTranslation';
+
+type CrumbTitle = string | Descriptor | null | undefined;
+
+export type CrumbData = RDBCrumbData
;
+export type CrumbContent = RDBCrumbContent;
+export type CrumbPath = RDBCrumbPath;
+export type DataHandle = RDBDataHandle<
+ Location,
+ Omit[number], 'handle'>,
+ CrumbTitle
+>;
+
+// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+export const useDynamicNest = () => {
+ const matches = useMatches() as RDBMatch[];
+ const location = useLocation();
+
+ return useDynamicBreadcrumbs({
+ matches,
+ context: location,
+ });
+};
+
+export const DEFAULT_WINDOW_TITLE = 'Coursemology';
+
+export { forEachFlatCrumb, getLastCrumbTitle };
diff --git a/client/app/lib/hooks/router/dynamicNest/builder.ts b/client/app/lib/hooks/router/dynamicNest/builder.ts
deleted file mode 100644
index 0ed5465acf0..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/builder.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Location } from 'react-router-dom';
-
-import { buildCrumbData, CrumbData, CrumbState } from './crumbs';
-import {
- getHandleData,
- getHandleRequestData,
- getShouldRevalidateCrumb,
-} from './handles';
-import { isPromise, Match, Promisable } from './utils';
-
-interface CrumbsDataBuilderResult {
- data: Promisable;
- hasPending: boolean;
- invalidCrumbs: Set;
-}
-
-export const buildCrumbsData = (
- matches: Match[],
- location: Location,
- state: CrumbState,
-): CrumbsDataBuilderResult => {
- const invalidCrumbs = new Set(Object.keys(state));
- const oldCrumbsCount = invalidCrumbs.size;
-
- let hasPromise = false;
- let newCrumbsCount = 0;
-
- const delta = matches.reduce[]>((crumbs, match) => {
- const handleData = getHandleData(match, location);
- if (!handleData) return crumbs;
-
- newCrumbsCount += 1;
-
- const crumbExists = invalidCrumbs.has(match.pathname);
-
- if (getShouldRevalidateCrumb(handleData) || crumbExists)
- invalidCrumbs.delete(match.pathname);
-
- if (getShouldRevalidateCrumb(handleData) || !crumbExists) {
- const content = getHandleRequestData(handleData);
-
- if (isPromise(content)) {
- hasPromise = true;
- crumbs.push(content.then((data) => buildCrumbData(match, data)));
- } else {
- crumbs.push(buildCrumbData(match, content));
- }
- }
-
- return crumbs;
- }, []);
-
- const validCrumbsCount = oldCrumbsCount - invalidCrumbs.size;
-
- return {
- data: hasPromise ? Promise.all(delta) : (delta as CrumbData[]),
- hasPending: newCrumbsCount > validCrumbsCount,
- invalidCrumbs,
- };
-};
diff --git a/client/app/lib/hooks/router/dynamicNest/constants.ts b/client/app/lib/hooks/router/dynamicNest/constants.ts
deleted file mode 100644
index 8de2c9806d0..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/constants.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const DEFAULT_WINDOW_TITLE = 'Coursemology';
diff --git a/client/app/lib/hooks/router/dynamicNest/crumbs.ts b/client/app/lib/hooks/router/dynamicNest/crumbs.ts
deleted file mode 100644
index 3a7a705d231..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/crumbs.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { produce } from 'immer';
-
-import { Descriptor } from 'lib/hooks/useTranslation';
-
-import { isRecord, Match } from './utils';
-
-export type CrumbTitle = string | Descriptor | null | undefined;
-
-export interface CrumbContent {
- url?: string;
- title?: CrumbTitle;
-}
-
-export interface CrumbData {
- id: string;
- pathname: string;
- activePath?: string | null;
- content?: CrumbContent | CrumbContent[];
-}
-
-export interface CrumbPath {
- pathname?: string;
- activePath?: string | null;
- content?: CrumbContent | CrumbContent[];
-}
-
-export type CrumbState = Record;
-
-export const getLastCrumbTitle = (crumbs: CrumbData[]): CrumbTitle | null => {
- const content = crumbs[crumbs.length - 1]?.content;
- if (!content) return null;
-
- const actualContent = Array.isArray(content)
- ? content[content.length - 1]
- : content;
- if (!actualContent) return null;
-
- return actualContent.title;
-};
-
-export const isCrumbPath = (value: unknown): value is CrumbPath =>
- isRecord(value) && 'content' in value;
-
-export const buildCrumbData = (
- match: Match,
- data: CrumbTitle | CrumbPath,
-): CrumbData => ({
- id: match.id,
- pathname: match.pathname,
- ...(isCrumbPath(data)
- ? data
- : {
- content: {
- title: data,
- url: match.pathname,
- },
- }),
-});
-
-export const combineCrumbs = (
- delta: CrumbData[],
-): ((state: CrumbState) => CrumbState) =>
- produce((draft) => {
- delta.forEach((crumb) => {
- draft[crumb.pathname] = crumb;
- });
- });
diff --git a/client/app/lib/hooks/router/dynamicNest/handles.ts b/client/app/lib/hooks/router/dynamicNest/handles.ts
deleted file mode 100644
index 9b258f5d2eb..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/handles.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Location } from 'react-router-dom';
-
-import { CrumbPath, CrumbTitle } from './crumbs';
-import { isRecord, Match, Promisable } from './utils';
-
-interface HandleRequest {
- shouldRevalidate?: boolean;
- getData: () => Promisable;
-}
-
-export type HandleData = CrumbTitle | HandleRequest | null;
-
-export type DataHandle = (match: Match, location: Location) => HandleData;
-
-export type Handle = CrumbTitle | DataHandle;
-
-export const isHandleRequest = (value: unknown): value is HandleRequest =>
- isRecord(value) && 'getData' in value && typeof value.getData === 'function';
-
-export const getHandleData = (match: Match, location: Location): HandleData => {
- const handle = match.handle as Handle | undefined;
- if (!handle) return null;
-
- return typeof handle === 'function' ? handle(match, location) : handle;
-};
-
-export const getShouldRevalidateCrumb = (data: HandleData): boolean =>
- isHandleRequest(data) && (data.shouldRevalidate ?? false);
-
-export const getHandleRequestData = (
- data: HandleData,
-): Promisable =>
- isHandleRequest(data) ? data.getData() : data;
diff --git a/client/app/lib/hooks/router/dynamicNest/index.ts b/client/app/lib/hooks/router/dynamicNest/index.ts
deleted file mode 100644
index 1cfad6a63e7..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export type { CrumbContent, CrumbData, CrumbPath, CrumbTitle } from './crumbs';
-export type { DataHandle } from './handles';
-export { default as useDynamicNest } from './useDynamicNest';
diff --git a/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts b/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts
deleted file mode 100644
index fee4afba24b..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useEffect, useMemo, useState } from 'react';
-import { useLocation, useMatches } from 'react-router-dom';
-import { produce } from 'immer';
-
-import { buildCrumbsData } from './builder';
-import { combineCrumbs, CrumbData, CrumbState } from './crumbs';
-import { isPromise } from './utils';
-
-interface UseDynamicNestHook {
- crumbs: CrumbData[];
- loading: boolean;
- activePath?: string;
-}
-
-const useDynamicNest = (): UseDynamicNestHook => {
- const matches = useMatches();
- const location = useLocation();
-
- const [state, setState] = useState({});
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- let ignore = false;
-
- const result = buildCrumbsData(matches, location, state);
-
- if (isPromise(result.data)) {
- setLoading(result.hasPending);
-
- result.data
- .then((crumbs) => !ignore && setState(combineCrumbs(crumbs)))
- .finally(() => setLoading(false));
- } else {
- setState(combineCrumbs(result.data));
- }
-
- setState(
- produce((draft) => {
- result.invalidCrumbs.forEach((pathname) => delete draft[pathname]);
- }),
- );
-
- return () => {
- ignore = true;
- };
- }, [matches]);
-
- const crumbs = useMemo(
- () => Object.values(state).sort((a, b) => a.id.localeCompare(b.id)),
- [state],
- );
-
- const activePath = useMemo(() => {
- for (let index = crumbs.length - 1; index >= 0; index--) {
- const crumb = crumbs[index];
-
- // Allow the distinction between `undefined` and `null`. If `activePath`
- // is `null`, it means that explicitly there shouldn't be any active paths
- // for the current nest.
- if (crumb.activePath !== undefined) return crumb.activePath ?? '';
- }
-
- return undefined;
- }, [crumbs]);
-
- return { crumbs, loading, activePath };
-};
-
-export default useDynamicNest;
diff --git a/client/app/lib/hooks/router/dynamicNest/utils.ts b/client/app/lib/hooks/router/dynamicNest/utils.ts
deleted file mode 100644
index 2eddfe7e95b..00000000000
--- a/client/app/lib/hooks/router/dynamicNest/utils.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { useMatches } from 'react-router-dom';
-
-export type Promisable = T | Promise;
-
-export type Match = ReturnType[number];
-
-export const isRecord = (
- value: unknown,
-): value is Record => typeof value === 'object' && value !== null;
-
-export const isPromise = (value: unknown): value is Promise =>
- isRecord(value) && 'then' in value && typeof value.then === 'function';
diff --git a/client/package.json b/client/package.json
index 24d7242181e..da4c7900aa6 100644
--- a/client/package.json
+++ b/client/package.json
@@ -80,6 +80,7 @@
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-dropzone": "^14.2.3",
+ "react-dynamic-breadcrumbs": "^1.0.1",
"react-google-recaptcha": "^3.1.0",
"react-hook-form": "^7.51.1",
"react-hot-keys": "^2.7.3",
diff --git a/client/yarn.lock b/client/yarn.lock
index 1bdae2a126b..1da56906603 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -6267,6 +6267,11 @@ immer@^10.0.4:
resolved "https://registry.yarnpkg.com/immer/-/immer-10.0.4.tgz#09af41477236b99449f9d705369a4daaf780362b"
integrity sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==
+immer@^10.1.1:
+ version "10.1.1"
+ resolved "https://registry.yarnpkg.com/immer/-/immer-10.1.1.tgz#206f344ea372d8ea176891545ee53ccc062db7bc"
+ integrity sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==
+
immer@^9.0.21:
version "9.0.21"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"
@@ -9296,6 +9301,13 @@ react-dropzone@^14.2.3:
file-selector "^0.6.0"
prop-types "^15.8.1"
+react-dynamic-breadcrumbs@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/react-dynamic-breadcrumbs/-/react-dynamic-breadcrumbs-1.0.1.tgz#6dd4b7f494a55baab4d51d2ff51aab733c5d5305"
+ integrity sha512-WQY6rcjtMHQUxBL+XKSVq4ZohDA0BcOJgahStjBeW7RZS1StAg3Q5Xz+zKo5lUQ+46KJWTcmaaMtFW0UnjICNg==
+ dependencies:
+ immer "^10.1.1"
+
react-fast-compare@^3.0.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb"