From 6c5734aa0ccf921cd71e4928f6084040dacff62d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 6 Jul 2026 15:06:47 +0200 Subject: [PATCH] Consolidate module federation on a single router with single-owner banner rendering --- .claude/rules/frontend/build-system.md | 6 +- .claude/rules/frontend/frontend.md | 6 +- application/.oxlintrc.json | 30 ++++++ .../account/BackOffice/routes/__root.tsx | 11 +-- .../-components/AlertsBadgesPreview.tsx | 13 +-- .../shared/components/BackOfficeBanners.tsx | 26 +---- .../WebApp/federated-modules/AccountApp.tsx | 94 ------------------- .../federated-modules/banners/Banners.tsx | 16 +--- .../WebApp/federated-modules/routes.tsx | 52 ++++++++++ .../federated-modules/sideMenu/MobileMenu.tsx | 23 ++--- .../federated-modules/userMenu/UserMenu.tsx | 16 ++-- application/account/WebApp/routes/__root.tsx | 23 +---- application/account/WebApp/rsbuild.config.ts | 2 +- .../shared/components/AccountSideMenu.tsx | 4 +- .../WebApp/shared/hooks/useMainNavigation.ts | 27 +++--- .../shared/lib/router/registerRouterTypes.ts | 15 +++ .../WebApp/shared/lib/router/router.tsx | 19 ---- application/main/WebApp/routes/$.tsx | 47 ---------- application/main/WebApp/routes/__root.tsx | 17 ++-- .../WebApp/shared/components/MainSideMenu.tsx | 8 +- .../main/WebApp/shared/lib/router/router.tsx | 13 ++- .../e2e/federated-navigation-flows.spec.ts | 4 +- .../module-federation-types/account.d.ts | 6 +- .../build/plugin/ModuleFederationPlugin.ts | 23 ++++- .../auth/AuthenticationProvider.tsx | 2 - .../router/createBlockableMemoryHistory.ts | 57 ----------- .../{BannerPortal.tsx => BannerContainer.tsx} | 18 ++-- .../shared-webapp/ui/components/Sidebar.tsx | 2 +- .../ui/hooks/federatedNavigationGuard.ts | 25 ----- .../ui/hooks/useUnsavedChangesGuard.ts | 6 -- 30 files changed, 213 insertions(+), 398 deletions(-) delete mode 100644 application/account/WebApp/federated-modules/AccountApp.tsx create mode 100644 application/account/WebApp/federated-modules/routes.tsx create mode 100644 application/account/WebApp/shared/lib/router/registerRouterTypes.ts delete mode 100644 application/account/WebApp/shared/lib/router/router.tsx delete mode 100644 application/main/WebApp/routes/$.tsx delete mode 100644 application/shared-webapp/infrastructure/router/createBlockableMemoryHistory.ts rename application/shared-webapp/ui/components/{BannerPortal.tsx => BannerContainer.tsx} (64%) delete mode 100644 application/shared-webapp/ui/hooks/federatedNavigationGuard.ts diff --git a/.claude/rules/frontend/build-system.md b/.claude/rules/frontend/build-system.md index ed0b1238f4..256b5d42fd 100644 --- a/.claude/rules/frontend/build-system.md +++ b/.claude/rules/frontend/build-system.md @@ -38,9 +38,13 @@ federation remotes. Understand these constraints before changing any `rslib.conf hand-rolled copy step). Node-side build code must be ESM-safe (`import.meta.dirname`, not `__dirname`; read JSON with `fs`, not `require()`). -4. **Keep `react`, `react-dom`, `@lingui/core`, `@lingui/react` as module-federation `shared` singletons** +4. **Keep `react`, `react-dom`, `@lingui/core`, `@lingui/react`, `@tanstack/react-router`, and + `@tanstack/react-query` as module-federation `shared` singletons** (`application/shared-webapp/build/plugin/ModuleFederationPlugin.ts`). The translation system depends on a single shared Lingui `i18n` across remotes — see [translations](/.claude/rules/frontend/translations.md). + Router instances and route objects cross the federation boundary (account contributes its route subtree + to Main's single router), and federated components resolve their QueryClient from the host's provider, + so those modules must also bind to one instance. 5. **TypeScript 7 is intentionally deferred — stay on the 6.x line.** TS 7's native compiler removed the classic programmatic API (its `package.json` exposes no main export, only `./unstable/*`), so diff --git a/.claude/rules/frontend/frontend.md b/.claude/rules/frontend/frontend.md index e4700ef7b7..7f64c03935 100644 --- a/.claude/rules/frontend/frontend.md +++ b/.claude/rules/frontend/frontend.md @@ -26,14 +26,18 @@ Use browser MCP tools to test at `https://app.dev.localhost:`. Look 2. **Module Federation for Micro-Frontends**: - Each self-contained system has its own WebApp - Common UI exposed via federation in `federated-modules/` + - **Federated leaf exposes stay presentation-shaped**: they may navigate through the shared router hooks, but they never own routes, history, or shared DOM regions. The only structural expose is `./routes` (the route subtree contribution) - Shared components in `application/shared-webapp/` - Don't import directly between self-contained systems 3. **Navigation**: + - **One router per page**: The Main host runs the only TanStack Router. Federated systems contribute route subtrees to it (account exposes `./routes`; Main grafts it in `shared/lib/router/router.tsx`). Never create a second router in a federated module - lint blocks `createRouter` outside `shared/lib/router/router.tsx` + - **The router owns browser history**: never call `window.history.pushState`/`replaceState` - lint blocks both. Hand-rolled history sync is a shadow routing pattern that desynchronizes the router from the address bar - **Within a self-contained system**: Use `useNavigate()` hook or `` component from TanStack Router - - **Account to Main**: Account runs as a federated module inside Main with a memory router. Use the `useMainNavigation()` hook (backed by `MainNavigationContext`) to navigate back to Main, not `window.location.href` + - **Account to Main**: Use the `useMainNavigation()` hook. Main paths such as `/dashboard` are ordinary navigations on the shared router; they are just not part of account's typed route tree - **To Back-Office**: Back-Office is a separate SPA, so use `window.location.href` for full-page navigation - Only use `window.location.href` when navigating to a different SPA or for full-page reloads (e.g., logout) + - **Route protection**: use `beforeLoad` only with the `routeGuards` helpers (`requireAuthentication`, `requirePermission`, `requireSubscriptionEnabled`) or tiny context flags like `disableAuthSync`. Prefer declarative guards that render `` (e.g. `OnboardingGuard`) for user-state redirects. Never load data in `beforeLoad` or route loaders - server state lives in TanStack Query only 4. **API Integration**: - API client auto-generated from OpenAPI spec diff --git a/application/.oxlintrc.json b/application/.oxlintrc.json index 69e71d6463..a1fd993b73 100644 --- a/application/.oxlintrc.json +++ b/application/.oxlintrc.json @@ -86,6 +86,20 @@ "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", + // The router owns browser history. Direct history mutation is a shadow routing pattern that + // desynchronizes the router from the address bar. + "no-restricted-properties": [ + "error", + { + "property": "pushState", + "message": "Never manipulate window.history directly; the single router owns history. Navigate with useNavigate() or ." + }, + { + "property": "replaceState", + "message": "Never manipulate window.history directly; the single router owns history. Navigate with useNavigate() or ." + } + ], + // Restrict specific imports with custom messages "no-restricted-imports": [ "error", @@ -95,6 +109,16 @@ { "name": "recharts", "message": "Import chart components from @repo/ui/components/Chart instead. The shared wrappers default accessibilityLayer={true} on chart types so Tab routes to data points and the focus ring matches our theme — importing recharts directly bypasses this and Chrome will paint its native blue focus ring on the SVG." + }, + { + "name": "@tanstack/react-router", + "importNames": ["createRouter"], + "message": "Only shared/lib/router/router.tsx may create a router. Exactly one router owns history and rendering per page; federated modules contribute route subtrees to the host router and must never mount a router of their own." + }, + { + "name": "react-dom", + "importNames": ["createPortal"], + "message": "Do not portal into shared DOM nodes. Render into a container owned by a single component (e.g. BannerContainer children) so every DOM region has exactly one writer." } ] } @@ -173,6 +197,12 @@ "rules": { "no-restricted-imports": "off" } + }, + { + "files": ["**/main/WebApp/shared/lib/router/router.tsx", "**/BackOffice/shared/lib/router/router.tsx"], + "rules": { + "no-restricted-imports": "off" + } } ] } diff --git a/application/account/BackOffice/routes/__root.tsx b/application/account/BackOffice/routes/__root.tsx index 6b215c1c00..955cbf7169 100644 --- a/application/account/BackOffice/routes/__root.tsx +++ b/application/account/BackOffice/routes/__root.tsx @@ -3,10 +3,10 @@ import { AuthenticationProvider } from "@repo/infrastructure/auth/Authentication import { themeColor } from "@repo/infrastructure/branding"; import { useErrorTrigger } from "@repo/infrastructure/development/useErrorTrigger"; import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; -import { BannerPortal } from "@repo/ui/components/BannerPortal"; +import { BannerContainer } from "@repo/ui/components/BannerContainer"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; import { QueryClientProvider } from "@tanstack/react-query"; -import { createRootRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; import { BackOfficeBanners } from "@/shared/components/BackOfficeBanners"; import { ErrorPage } from "@/shared/components/errorPages/ErrorPage"; @@ -20,17 +20,16 @@ export const Route = createRootRoute({ }); function Root() { - const navigate = useNavigate(); useInitializeLocale(); useErrorTrigger(); return ( - navigate(options)}> - + + - + diff --git a/application/account/BackOffice/routes/components/-components/AlertsBadgesPreview.tsx b/application/account/BackOffice/routes/components/-components/AlertsBadgesPreview.tsx index 9ec5dac060..2a17880283 100644 --- a/application/account/BackOffice/routes/components/-components/AlertsBadgesPreview.tsx +++ b/application/account/BackOffice/routes/components/-components/AlertsBadgesPreview.tsx @@ -6,7 +6,6 @@ import { Button } from "@repo/ui/components/Button"; import { Separator } from "@repo/ui/components/Separator"; import { AlertCircleIcon, FlagIcon, InfoIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useState } from "react"; -import { createPortal } from "react-dom"; import { ItemPreview } from "./ItemPreview"; import { ProgressPreview } from "./ProgressPreview"; @@ -29,14 +28,13 @@ const bannerContent: Record string; icon: React. } }; +// Renders inline in the preview. The live banner area is owned exclusively by BannerContainer, +// so preview content must never render into it. function SampleBanner({ variant, onClose }: Readonly<{ variant: BannerVariant; onClose: () => void }>) { - const [target] = useState(() => document.getElementById("banner-root")); - if (!target) return null; - const content = bannerContent[variant]; - return createPortal( -
+ return ( +
{content.icon} {content.message()} {variant === "cta" && ( @@ -49,8 +47,7 @@ function SampleBanner({ variant, onClose }: Readonly<{ variant: BannerVariant; o )} -
, - target +
); } diff --git a/application/account/BackOffice/shared/components/BackOfficeBanners.tsx b/application/account/BackOffice/shared/components/BackOfficeBanners.tsx index 0269e91caf..91573f5bdb 100644 --- a/application/account/BackOffice/shared/components/BackOfficeBanners.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeBanners.tsx @@ -1,33 +1,15 @@ -import { useEffect, useState } from "react"; -import { createPortal } from "react-dom"; - import { BillingDriftBanner } from "./BillingDriftBanner"; import { MrrMismatchBanner } from "./MrrMismatchBanner"; import { UnsyncedAccountsBanner } from "./UnsyncedAccountsBanner"; -/** - * Portals all back-office banners into the fixed-top BannerPortal target so they render above the - * sidebar and content rather than being clipped by the layout. The user-facing Banners federated - * module relies on a lazy boundary to defer mount until BannerPortal's DOM is committed; we render - * synchronously, so the target lookup runs in useEffect to avoid the first-render race. - */ +// Rendered as a child of BannerContainer, which owns the fixed banner area. +// Banners are ordinary React children of that container so the container has exactly one writer. export function BackOfficeBanners() { - const [target, setTarget] = useState(null); - - useEffect(() => { - setTarget(document.getElementById("banner-root")); - }, []); - - if (!target) { - return null; - } - - return createPortal( + return ( <> - , - target + ); } diff --git a/application/account/WebApp/federated-modules/AccountApp.tsx b/application/account/WebApp/federated-modules/AccountApp.tsx deleted file mode 100644 index 7ecd2c755d..0000000000 --- a/application/account/WebApp/federated-modules/AccountApp.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import "@repo/ui/tailwind.css"; -import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider"; -import { createBlockableMemoryHistory } from "@repo/infrastructure/router/createBlockableMemoryHistory"; -import { shouldBlockNavigation } from "@repo/ui/hooks/federatedNavigationGuard"; -import { createRouter, type NavigateOptions, RouterProvider } from "@tanstack/react-router"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; - -import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; - -import { UnsavedChangesDialog } from "../shared/components/UnsavedChangesDialog"; -import { MainNavigationContext } from "../shared/hooks/useMainNavigation"; -import { routeTree } from "../shared/lib/router/routeTree.generated"; - -export interface AccountAppProps { - initialPath: string; - onNavigateToMain: (path: string) => void; -} - -function AccountApp({ initialPath, onNavigateToMain }: Readonly) { - // Store initialPath in a ref to prevent router recreation when main's router re-renders - // due to URL changes from window.history.pushState() calls within this component - const initialPathRef = useRef(initialPath); - - const router = useMemo(() => { - const memoryHistory = createBlockableMemoryHistory({ initialEntries: [initialPathRef.current] }); - return createRouter({ - routeTree, - history: memoryHistory, - defaultPreload: "intent" - }); - }, []); - - useEffect(() => { - return router.subscribe("onResolved", ({ toLocation }) => { - const newPath = toLocation.pathname + (toLocation.searchStr || ""); - if (window.location.pathname + window.location.search !== newPath) { - window.history.pushState({}, "", newPath); - } - }); - }, [router]); - - useEffect(() => { - const handlePopState = () => { - const currentPath = window.location.pathname + window.location.search; - router.navigate({ to: currentPath }); - }; - window.addEventListener("popstate", handlePopState); - return () => window.removeEventListener("popstate", handlePopState); - }, [router]); - - const [pendingNavigation, setPendingNavigation] = useState<{ proceed: () => void } | null>(null); - - const guardedNavigateToMain = useCallback( - (path: string) => { - if (shouldBlockNavigation()) { - setPendingNavigation({ proceed: () => onNavigateToMain(path) }); - return; - } - onNavigateToMain(path); - }, - [onNavigateToMain] - ); - - const handleNavigate = (options: NavigateOptions) => { - const mainRoutes = ["/", "/dashboard"]; - const targetPath = options.to?.toString() ?? ""; - if (mainRoutes.some((route) => targetPath === route || targetPath.startsWith("/dashboard"))) { - guardedNavigateToMain(targetPath); - } else { - router.navigate(options); - } - }; - - return ( -
- - - - - - { - pendingNavigation?.proceed(); - setPendingNavigation(null); - }} - onCancel={() => setPendingNavigation(null)} - parentTrackingTitle="Navigation" - /> -
- ); -} - -export default withAccountTranslations(AccountApp); diff --git a/application/account/WebApp/federated-modules/banners/Banners.tsx b/application/account/WebApp/federated-modules/banners/Banners.tsx index 1baaa06471..4da63e0053 100644 --- a/application/account/WebApp/federated-modules/banners/Banners.tsx +++ b/application/account/WebApp/federated-modules/banners/Banners.tsx @@ -1,6 +1,3 @@ -import { useState } from "react"; -import { createPortal } from "react-dom"; - import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; import ExpiringCardBanner from "./ExpiringCardBanner"; @@ -8,20 +5,15 @@ import InvitationBanner from "./InvitationBanner"; import PaymentFailedBanner from "./PaymentFailedBanner"; import "@repo/ui/tailwind.css"; +// Rendered as a child of the host's BannerContainer, which owns the fixed banner area. +// Banners are ordinary React children of that container so the container has exactly one writer. function Banners() { - const [target] = useState(() => document.getElementById("banner-root")); - - if (!target) { - return null; - } - - return createPortal( + return ( <> - , - target + ); } diff --git a/application/account/WebApp/federated-modules/routes.tsx b/application/account/WebApp/federated-modules/routes.tsx new file mode 100644 index 0000000000..f75dd41180 --- /dev/null +++ b/application/account/WebApp/federated-modules/routes.tsx @@ -0,0 +1,52 @@ +import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider"; +import { type AnyRoute, createRoute, Outlet } from "@tanstack/react-router"; + +import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; + +import { routeTree } from "../shared/lib/router/routeTree.generated"; +import ErrorPage from "./errorPages/ErrorPage"; +import NotFoundPage from "./errorPages/NotFoundPage"; +import "@repo/ui/tailwind.css"; + +// The #account element scopes account CSS. The AuthenticationProvider must come from account's +// own bundle because @repo packages are compiled per system and context objects are not shared +// across the federation boundary. +function AccountLayout() { + return ( +
+ + + +
+ ); +} + +// The public update() type omits getParentRoute, but re-parenting through update() is exactly +// how the route generator itself assembles the tree. +type ReparentableRoute = AnyRoute & { + update: (options: { getParentRoute: () => AnyRoute }) => unknown; +}; + +/** + * Account's contribution to the host's single router: a pathless layout route carrying the + * #account style scope, the account translation catalog gate, and account's error pages, with + * every generated account route re-parented beneath it. + * + * The generated tree's own root exists only as a code generation anchor and never renders. + */ +export default function createAccountRouteTree(getParentRoute: () => AnyRoute): AnyRoute { + const accountLayoutRoute = createRoute({ + id: "account-layout", + getParentRoute, + component: withAccountTranslations(AccountLayout), + errorComponent: ErrorPage, + notFoundComponent: NotFoundPage + }); + + const accountRoutes = (routeTree.children ?? []) as ReparentableRoute[]; + for (const route of accountRoutes) { + route.update({ getParentRoute: () => accountLayoutRoute }); + } + + return accountLayoutRoute.addChildren(accountRoutes); +} diff --git a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx index 0309fa1455..e6a0271ac5 100644 --- a/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -18,22 +18,20 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Too import { LayoutDashboardIcon, MessageCircleQuestion } from "lucide-react"; import { useContext, useEffect, useState } from "react"; +import { useMainNavigation } from "@/shared/hooks/useMainNavigation"; import { withAccountTranslations } from "@/shared/translations/withAccountTranslations"; import { fetchTenants, type TenantInfo } from "../common/tenantUtils"; import { MobileMenuContent } from "./MobileMenuContent"; import { MobileMenuDialogs } from "./MobileMenuDialogs"; -export interface MobileMenuProps { - onNavigate?: (path: string) => void; -} - // Rich mobile navigation surface rendered inside the Sidebar's mobile Sheet. Shows tenant info, // user actions, tenant switcher, navigation links, and a support button. Federated so both the // Main and Account apps can reuse it inside their mobile sidebars. -function MobileMenu({ onNavigate }: Readonly) { +function MobileMenu() { const userInfo = useUserInfo(); const overlayCtx = useContext(overlayContext); + const { navigateToMain } = useMainNavigation(); const [tenants, setTenants] = useState([]); useTrackOpen("Mobile menu", "menu"); @@ -94,20 +92,13 @@ function MobileMenu({ onNavigate }: Readonly) { - {/* `onNavigate` is provided by the Account app (federated) and the Main app's Dashboard - SCS-local navigator, avoiding full page reloads when both apps are loaded together. - When absent, fall back to a hard navigation — correct for /dashboard since it may live - in a different SCS than the current Account sidebar. Uses a plain