From a22891fa57f36d2ea343766673c5b73b0059a7f3 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 20 Oct 2023 13:43:16 -0400 Subject: [PATCH 01/22] Trying something --- .../local-module/src/CharactersTab.tsx | 2 + .../endpoints/local-module/src/PublicPage.tsx | 13 + .../local-module/src/SubscriptionPage.tsx | 2 + .../endpoints/local-module/src/register.tsx | 8 + samples/endpoints/shell/src/AppRouter.tsx | 272 ++++++++++++++---- 5 files changed, 248 insertions(+), 49 deletions(-) create mode 100644 samples/endpoints/local-module/src/PublicPage.tsx diff --git a/samples/endpoints/local-module/src/CharactersTab.tsx b/samples/endpoints/local-module/src/CharactersTab.tsx index de9682f89..d82219078 100644 --- a/samples/endpoints/local-module/src/CharactersTab.tsx +++ b/samples/endpoints/local-module/src/CharactersTab.tsx @@ -36,3 +36,5 @@ export function CharactersTab() { ); } + +export const Component = CharactersTab; diff --git a/samples/endpoints/local-module/src/PublicPage.tsx b/samples/endpoints/local-module/src/PublicPage.tsx new file mode 100644 index 000000000..766d3191c --- /dev/null +++ b/samples/endpoints/local-module/src/PublicPage.tsx @@ -0,0 +1,13 @@ +import { Link } from "react-router-dom"; + +export function PublicPage() { + return ( + <> +

Public page

+

This page is served by @endpoints/local-module

+ Go to the protected home page + + ); +} + +export const Component = PublicPage; diff --git a/samples/endpoints/local-module/src/SubscriptionPage.tsx b/samples/endpoints/local-module/src/SubscriptionPage.tsx index 324689ad3..a7c4a6ee9 100644 --- a/samples/endpoints/local-module/src/SubscriptionPage.tsx +++ b/samples/endpoints/local-module/src/SubscriptionPage.tsx @@ -20,3 +20,5 @@ export function SubscriptionPage() { ); } + +export const Component = SubscriptionPage; diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx index 8cecc8d9f..a5e813d1a 100644 --- a/samples/endpoints/local-module/src/register.tsx +++ b/samples/endpoints/local-module/src/register.tsx @@ -27,6 +27,14 @@ function Providers({ children }: { children: ReactNode }) { } function registerRoutes(runtime: Runtime) { + runtime.registerRoute({ + $visibility: "public", + path: "/public", + lazy: () => import("./PublicPage.tsx") + }, { + hoist: true + }); + runtime.registerRoute({ path: "/subscription", lazy: async () => { diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 46bd54d2a..ac5a2155c 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -1,18 +1,93 @@ import { SubscriptionContext, TelemetryServiceContext, useTelemetryService, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; import { useIsMswStarted } from "@squide/msw"; -import { useIsRouteMatchProtected, useLogger, useRoutes } from "@squide/react-router"; +import { useIsAuthenticated, useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; import { useAreModulesReady } from "@squide/webpack-module-federation"; import axios from "axios"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; -export function RootRoute() { +type SetSession = (session: Session) => void; +type SetSubscription = (subscription: Subscription) => void; + +async function fetchProtectedData( + setSession: SetSession, + setSubscription: SetSubscription, + logger: Logger +) { + logger.debug(`[shell] Fetching session data as "${window.location}" is a protected route.`); + + const sessionPromise = axios.get("/api/session") + .then(({ data }) => { + const session: Session = { + user: { + id: data.userId, + name: data.username + } + }; + + logger.debug("[shell] %cSession is ready%c:", "color: white; background-color: green;", "", session); + + setSession(data); + }); + + const subscriptionPromise = axios.get("/api/subscription") + .then(({ data }) => { + const subscription: Subscription = { + company: data.company, + contact: data.contact, + status: data.status + }; + + logger.debug("[shell] %cSubscription is ready%c:", "color: white; background-color: green;", "", subscription); + + setSubscription(data); + }); + + return Promise.all([sessionPromise, subscriptionPromise]) + .catch((error: unknown) => { + if (axios.isAxiosError(error) && error.response?.status === 401) { + // The authentication boundary will redirect to the login page. + return; + } + + throw error; + }); +} + +interface RootRouteProps { + setSession: SetSession; + setSubscription: SetSubscription; +} + +export function RootRoute({ setSession, setSubscription }: RootRouteProps) { + const logger = useLogger(); + const location = useLocation(); const telemetryService = useTelemetryService(); + const isAuthenticated = useIsAuthenticated(); + const isActiveRouteProtected = useIsRouteMatchProtected(location); + + const [isReady, setIsReady] = useState(!isActiveRouteProtected || isAuthenticated); + useEffect(() => { telemetryService?.track(`Navigated to the "${location.pathname}" page.`); - }, [location, telemetryService]); + + // If the user is already authenticated and come back later with a direct hit to a public page, + // without this code, the user would be asked to login again because the AppRouter code + // is not re-rendered when the location change. + if (isActiveRouteProtected && !isAuthenticated) { + setIsReady(false); + + fetchProtectedData(setSession, setSubscription, logger).finally(() => { + setIsReady(true); + }); + } + }, [location, telemetryService, logger, isAuthenticated, isActiveRouteProtected, setSession, setSubscription]); + + if (!isReady) { + return
Loading...
; + } return ( @@ -42,11 +117,16 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR const isMswStarted = useIsMswStarted(waitForMsw); // Ideally "useLocation" would be used so the component re-renderer everytime the location change but it doesn't - // seem feasible (at least not easily) as public and private routes go through this component. + // seem feasible (at least not easily) as public and private routes go through this component and we expect to show the same + // loading through the whole bootstrapping process. // Anyhow, since all the Workleap apps will authenticate through a third party authentication provider, it // doesn't seems like a big deal as the application will be reloaded anyway after the user logged in on the third party. const isActiveRouteProtected = useIsRouteMatchProtected(window.location); + const setSession = useCallback((session: Session) => { + sessionManager.setSession(session); + }, [sessionManager]); + useEffect(() => { if (areModulesReady && !isMswStarted) { logger.debug("[shell] Modules are ready, waiting for MSW to start."); @@ -58,63 +138,25 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR if (areModulesReady && isMswStarted) { if (isActiveRouteProtected) { - logger.debug(`[shell] Fetching session data as "${window.location}" is a protected route.`); - - const sessionPromise = axios.get("/api/session") - .then(({ data }) => { - const session: Session = { - user: { - id: data.userId, - name: data.username - } - }; - - logger.debug("[shell] %cSession is ready%c:", "color: white; background-color: green;", "", session); - - sessionManager.setSession(session); - }); - - const subscriptionPromise = axios.get("/api/subscription") - .then(({ data }) => { - const _subscription: Subscription = { - company: data.company, - contact: data.contact, - status: data.status - }; - - logger.debug("[shell] %cSubscription is ready%c:", "color: white; background-color: green;", "", _subscription); - - setSubscription(_subscription); - }); - - Promise.all([sessionPromise, subscriptionPromise]) - .catch((error: unknown) => { - if (axios.isAxiosError(error) && error.response?.status === 401) { - // The authentication boundary will redirect to the login page. - return; - } - - throw error; - }) - .finally(() => { - setIsReady(true); - }); + fetchProtectedData(setSession, setSubscription, logger).finally(() => { + setIsReady(true); + }); } else { logger.debug(`[shell] Passing through as "${window.location}" is a public route.`); setIsReady(true); } } - }, [areModulesReady, isMswStarted, isActiveRouteProtected, logger, sessionManager]); + }, [logger, areModulesReady, isMswStarted, isActiveRouteProtected, setSession]); const router = useMemo(() => { return createBrowserRouter([ { - element: , + element: , children: routes } ]); - }, [routes]); + }, [routes, setSession, setSubscription]); if (!isReady) { return
Loading...
; @@ -128,3 +170,135 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR ); } + + +// import { SubscriptionContext, TelemetryServiceContext, useTelemetryService, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; +// import { useIsMswStarted } from "@squide/msw"; +// import { useIsRouteMatchProtected, useLogger, useRoutes } from "@squide/react-router"; +// import { useAreModulesReady } from "@squide/webpack-module-federation"; +// import axios from "axios"; +// import { useEffect, useMemo, useState } from "react"; +// import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; + +// export function RootRoute() { +// const location = useLocation(); +// const telemetryService = useTelemetryService(); + +// useEffect(() => { +// telemetryService?.track(`Navigated to the "${location.pathname}" page.`); +// }, [location, telemetryService]); + +// return ( +// +// ); +// } + +// export interface AppRouterProps { +// waitForMsw: boolean; +// sessionManager: SessionManager; +// telemetryService: TelemetryService; +// } + +// export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) { +// const [isReady, setIsReady] = useState(false); + +// // Could be done with a ref (https://react.dev/reference/react/useRef) to save a re-render but for this sample +// // it seemed unnecessary. If your application loads a lot of data at bootstrapping, it should be considered. +// const [subscription, setSubscription] = useState(); + +// const logger = useLogger(); +// const routes = useRoutes(); + +// // Re-render the app once all the remotes are registered, otherwise the remotes routes won't be added to the router. +// const areModulesReady = useAreModulesReady(); + +// // Re-render the app once MSW is started, otherwise, the API calls for module routes will return a 404 status. +// const isMswStarted = useIsMswStarted(waitForMsw); + +// // Ideally "useLocation" would be used so the component re-renderer everytime the location change but it doesn't +// // seem feasible (at least not easily) as public and private routes go through this component. +// // Anyhow, since all the Workleap apps will authenticate through a third party authentication provider, it +// // doesn't seems like a big deal as the application will be reloaded anyway after the user logged in on the third party. +// const isActiveRouteProtected = useIsRouteMatchProtected(window.location); + +// useEffect(() => { +// if (areModulesReady && !isMswStarted) { +// logger.debug("[shell] Modules are ready, waiting for MSW to start."); +// } + +// if (!areModulesReady && isMswStarted) { +// logger.debug("[shell] MSW is started, waiting for the modules to be ready."); +// } + +// if (areModulesReady && isMswStarted) { +// if (isActiveRouteProtected) { +// logger.debug(`[shell] Fetching session data as "${window.location}" is a protected route.`); + +// const sessionPromise = axios.get("/api/session") +// .then(({ data }) => { +// const session: Session = { +// user: { +// id: data.userId, +// name: data.username +// } +// }; + +// logger.debug("[shell] %cSession is ready%c:", "color: white; background-color: green;", "", session); + +// sessionManager.setSession(session); +// }); + +// const subscriptionPromise = axios.get("/api/subscription") +// .then(({ data }) => { +// const _subscription: Subscription = { +// company: data.company, +// contact: data.contact, +// status: data.status +// }; + +// logger.debug("[shell] %cSubscription is ready%c:", "color: white; background-color: green;", "", _subscription); + +// setSubscription(_subscription); +// }); + +// Promise.all([sessionPromise, subscriptionPromise]) +// .catch((error: unknown) => { +// if (axios.isAxiosError(error) && error.response?.status === 401) { +// // The authentication boundary will redirect to the login page. +// return; +// } + +// throw error; +// }) +// .finally(() => { +// setIsReady(true); +// }); +// } else { +// logger.debug(`[shell] Passing through as "${window.location}" is a public route.`); + +// setIsReady(true); +// } +// } +// }, [areModulesReady, isMswStarted, isActiveRouteProtected, logger, sessionManager]); + +// const router = useMemo(() => { +// return createBrowserRouter([ +// { +// element: , +// children: routes +// } +// ]); +// }, [routes]); + +// if (!isReady) { +// return
Loading...
; +// } + +// return ( +// +// +// +// +// +// ); +// } From 2b33c473ccac0a844777649d71ba633bedbf5541 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 20 Oct 2023 13:45:46 -0400 Subject: [PATCH 02/22] Improved the comment --- samples/endpoints/shell/src/AppRouter.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index ac5a2155c..2ebb690d5 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -74,8 +74,8 @@ export function RootRoute({ setSession, setSubscription }: RootRouteProps) { telemetryService?.track(`Navigated to the "${location.pathname}" page.`); // If the user is already authenticated and come back later with a direct hit to a public page, - // without this code, the user would be asked to login again because the AppRouter code - // is not re-rendered when the location change. + // without this code, once the user attempt to navigate to a protected page, the user will be asked + // to login again because the AppRouter code is not re-rendered when the location change. if (isActiveRouteProtected && !isAuthenticated) { setIsReady(false); From a0d9725082d29beb9ba60f77a8b248bb86de13ed Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 20 Oct 2023 13:47:29 -0400 Subject: [PATCH 03/22] Event better comment --- samples/endpoints/shell/src/AppRouter.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 2ebb690d5..7def9b0ef 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -76,6 +76,12 @@ export function RootRoute({ setSession, setSubscription }: RootRouteProps) { // If the user is already authenticated and come back later with a direct hit to a public page, // without this code, once the user attempt to navigate to a protected page, the user will be asked // to login again because the AppRouter code is not re-rendered when the location change. + // To try this out: + // - Authenticate to the app with temp/temp + // - Navigate to the /public page and force a full refresh + // - Click on "Go to the protected home page" link + // - If this code work, you should be redirected directly to the home page without having to login + // - If this code fail, you will be redirected to the login page if (isActiveRouteProtected && !isAuthenticated) { setIsReady(false); From f0a46f000163d7d4f2b7544a0be0dc99632f79d0 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 20 Oct 2023 13:50:31 -0400 Subject: [PATCH 04/22] Moved logging outside of the fetch function --- samples/endpoints/shell/src/AppRouter.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 7def9b0ef..6370d68d0 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -14,8 +14,6 @@ async function fetchProtectedData( setSubscription: SetSubscription, logger: Logger ) { - logger.debug(`[shell] Fetching session data as "${window.location}" is a protected route.`); - const sessionPromise = axios.get("/api/session") .then(({ data }) => { const session: Session = { @@ -85,6 +83,8 @@ export function RootRoute({ setSession, setSubscription }: RootRouteProps) { if (isActiveRouteProtected && !isAuthenticated) { setIsReady(false); + logger.debug(`[shell] Fetching protected data as "${location}" is a protected route.`); + fetchProtectedData(setSession, setSubscription, logger).finally(() => { setIsReady(true); }); @@ -144,6 +144,8 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR if (areModulesReady && isMswStarted) { if (isActiveRouteProtected) { + logger.debug(`[shell] Fetching protected data as "${window.location}" is a protected route.`); + fetchProtectedData(setSession, setSubscription, logger).finally(() => { setIsReady(true); }); From ba4fa3acd9248196c84dc140c71fe05fa2812cde Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 20 Oct 2023 21:03:38 -0400 Subject: [PATCH 05/22] Rework the AppRouter component --- .../endpoints/local-module/src/register.tsx | 5 + samples/endpoints/shell/src/AppRouter.tsx | 317 ++++++------------ samples/endpoints/shell/src/LoginPage.tsx | 10 +- 3 files changed, 106 insertions(+), 226 deletions(-) diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx index a5e813d1a..4198bda0e 100644 --- a/samples/endpoints/local-module/src/register.tsx +++ b/samples/endpoints/local-module/src/register.tsx @@ -75,6 +75,11 @@ function registerRoutes(runtime: Runtime) { to: "/subscription" }); + runtime.registerNavigationItem({ + $label: "Public page", + to: "/public" + }); + runtime.registerNavigationItem({ $label: "Tabs", $priority: 100, diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 6370d68d0..30d9c4eb1 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -3,15 +3,47 @@ import { useIsMswStarted } from "@squide/msw"; import { useIsAuthenticated, useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; import { useAreModulesReady } from "@squide/webpack-module-federation"; import axios from "axios"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; -type SetSession = (session: Session) => void; -type SetSubscription = (subscription: Subscription) => void; +/* +AppRouter + - loader + - onFetchInitialData -> (doit passer un "signal") + - onFetchSession + - onFetchProtectedData -> Si fournie, est inclus dans le isReady - (doit passer un "signal") + - waitForMsw + - rootRoute - Si fournis est-ce le parent de la root route du AppRouter? + - routerProviderOptions +*/ + +/* + +import { AppRouter as SquideAppRouter } from "@squide/shell"; + +export function AppRouter() { + const [subscription, setSubscription] = useState(); + + onFetchProtectedData() { + .... + + + } + + return ( + + + + + ) +} + +*/ async function fetchProtectedData( - setSession: SetSession, - setSubscription: SetSubscription, + setSession: (session: Session) => void, + setSubscription: (subscription: Subscription) => void, logger: Logger ) { const sessionPromise = axios.get("/api/session") @@ -25,7 +57,7 @@ async function fetchProtectedData( logger.debug("[shell] %cSession is ready%c:", "color: white; background-color: green;", "", session); - setSession(data); + setSession(session); }); const subscriptionPromise = axios.get("/api/subscription") @@ -38,7 +70,7 @@ async function fetchProtectedData( logger.debug("[shell] %cSubscription is ready%c:", "color: white; background-color: green;", "", subscription); - setSubscription(data); + setSubscription(subscription); }); return Promise.all([sessionPromise, subscriptionPromise]) @@ -53,85 +85,29 @@ async function fetchProtectedData( } interface RootRouteProps { - setSession: SetSession; - setSubscription: SetSubscription; -} - -export function RootRoute({ setSession, setSubscription }: RootRouteProps) { - const logger = useLogger(); - - const location = useLocation(); - const telemetryService = useTelemetryService(); - - const isAuthenticated = useIsAuthenticated(); - const isActiveRouteProtected = useIsRouteMatchProtected(location); - - const [isReady, setIsReady] = useState(!isActiveRouteProtected || isAuthenticated); - - useEffect(() => { - telemetryService?.track(`Navigated to the "${location.pathname}" page.`); - - // If the user is already authenticated and come back later with a direct hit to a public page, - // without this code, once the user attempt to navigate to a protected page, the user will be asked - // to login again because the AppRouter code is not re-rendered when the location change. - // To try this out: - // - Authenticate to the app with temp/temp - // - Navigate to the /public page and force a full refresh - // - Click on "Go to the protected home page" link - // - If this code work, you should be redirected directly to the home page without having to login - // - If this code fail, you will be redirected to the login page - if (isActiveRouteProtected && !isAuthenticated) { - setIsReady(false); - - logger.debug(`[shell] Fetching protected data as "${location}" is a protected route.`); - - fetchProtectedData(setSession, setSubscription, logger).finally(() => { - setIsReady(true); - }); - } - }, [location, telemetryService, logger, isAuthenticated, isActiveRouteProtected, setSession, setSubscription]); - - if (!isReady) { - return
Loading...
; - } - - return ( - - ); -} - -export interface AppRouterProps { waitForMsw: boolean; sessionManager: SessionManager; - telemetryService: TelemetryService; + areModulesReady: boolean; } -export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) { - const [isReady, setIsReady] = useState(false); +// Most of the bootstrapping logic has been moved to this component because AppRouter +// cannot leverage "useLocation" since it's depend on "RouterProvider". +export function RootRoute({ waitForMsw, sessionManager, areModulesReady }: RootRouteProps) { + const [isProtectedDataLoaded, setIsProtectedDataLoaded] = useState(false); // Could be done with a ref (https://react.dev/reference/react/useRef) to save a re-render but for this sample // it seemed unnecessary. If your application loads a lot of data at bootstrapping, it should be considered. const [subscription, setSubscription] = useState(); const logger = useLogger(); - const routes = useRoutes(); - - // Re-render the app once all the remotes are registered, otherwise the remotes routes won't be added to the router. - const areModulesReady = useAreModulesReady(); + const location = useLocation(); + const telemetryService = useTelemetryService(); // Re-render the app once MSW is started, otherwise, the API calls for module routes will return a 404 status. const isMswStarted = useIsMswStarted(waitForMsw); - // Ideally "useLocation" would be used so the component re-renderer everytime the location change but it doesn't - // seem feasible (at least not easily) as public and private routes go through this component and we expect to show the same - // loading through the whole bootstrapping process. - // Anyhow, since all the Workleap apps will authenticate through a third party authentication provider, it - // doesn't seems like a big deal as the application will be reloaded anyway after the user logged in on the third party. - const isActiveRouteProtected = useIsRouteMatchProtected(window.location); - - const setSession = useCallback((session: Session) => { - sessionManager.setSession(session); - }, [sessionManager]); + const isActiveRouteProtected = useIsRouteMatchProtected(location); + const isAuthenticated = useIsAuthenticated(); useEffect(() => { if (areModulesReady && !isMswStarted) { @@ -144,169 +120,68 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR if (areModulesReady && isMswStarted) { if (isActiveRouteProtected) { - logger.debug(`[shell] Fetching protected data as "${window.location}" is a protected route.`); + if (!isAuthenticated) { + logger.debug(`[shell] Fetching protected data as "${location.pathname}" is a protected route.`); - fetchProtectedData(setSession, setSubscription, logger).finally(() => { - setIsReady(true); - }); - } else { - logger.debug(`[shell] Passing through as "${window.location}" is a public route.`); + const setSession = (session: Session) => { + sessionManager.setSession(session); + }; - setIsReady(true); + fetchProtectedData(setSession, setSubscription, logger).finally(() => { + setIsProtectedDataLoaded(true); + }); + } + } else { + logger.debug(`[shell] Passing through as "${location.pathname}" is a public route.`); } } - }, [logger, areModulesReady, isMswStarted, isActiveRouteProtected, setSession]); + }, [logger, location, sessionManager, areModulesReady, isMswStarted, isActiveRouteProtected, isAuthenticated]); + + useEffect(() => { + telemetryService?.track(`Navigated to the "${location.pathname}" page.`); + }, [location, telemetryService]); + + if (!areModulesReady || !isMswStarted || (isActiveRouteProtected && !isProtectedDataLoaded)) { + return
Loading...
; + } + + return ( + + + + ); +} + +export interface AppRouterProps { + waitForMsw: boolean; + sessionManager: SessionManager; + telemetryService: TelemetryService; +} + +export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) { + // Re-render the app once all the remotes are registered, otherwise the remotes routes won't be added to the router. + const areModulesReady = useAreModulesReady(); + + const routes = useRoutes(); const router = useMemo(() => { return createBrowserRouter([ { - element: , + element: ( + + ), children: routes } ]); - }, [routes, setSession, setSubscription]); - - if (!isReady) { - return
Loading...
; - } + }, [areModulesReady, routes, waitForMsw, sessionManager]); return ( - - - + ); } - - -// import { SubscriptionContext, TelemetryServiceContext, useTelemetryService, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; -// import { useIsMswStarted } from "@squide/msw"; -// import { useIsRouteMatchProtected, useLogger, useRoutes } from "@squide/react-router"; -// import { useAreModulesReady } from "@squide/webpack-module-federation"; -// import axios from "axios"; -// import { useEffect, useMemo, useState } from "react"; -// import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; - -// export function RootRoute() { -// const location = useLocation(); -// const telemetryService = useTelemetryService(); - -// useEffect(() => { -// telemetryService?.track(`Navigated to the "${location.pathname}" page.`); -// }, [location, telemetryService]); - -// return ( -// -// ); -// } - -// export interface AppRouterProps { -// waitForMsw: boolean; -// sessionManager: SessionManager; -// telemetryService: TelemetryService; -// } - -// export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) { -// const [isReady, setIsReady] = useState(false); - -// // Could be done with a ref (https://react.dev/reference/react/useRef) to save a re-render but for this sample -// // it seemed unnecessary. If your application loads a lot of data at bootstrapping, it should be considered. -// const [subscription, setSubscription] = useState(); - -// const logger = useLogger(); -// const routes = useRoutes(); - -// // Re-render the app once all the remotes are registered, otherwise the remotes routes won't be added to the router. -// const areModulesReady = useAreModulesReady(); - -// // Re-render the app once MSW is started, otherwise, the API calls for module routes will return a 404 status. -// const isMswStarted = useIsMswStarted(waitForMsw); - -// // Ideally "useLocation" would be used so the component re-renderer everytime the location change but it doesn't -// // seem feasible (at least not easily) as public and private routes go through this component. -// // Anyhow, since all the Workleap apps will authenticate through a third party authentication provider, it -// // doesn't seems like a big deal as the application will be reloaded anyway after the user logged in on the third party. -// const isActiveRouteProtected = useIsRouteMatchProtected(window.location); - -// useEffect(() => { -// if (areModulesReady && !isMswStarted) { -// logger.debug("[shell] Modules are ready, waiting for MSW to start."); -// } - -// if (!areModulesReady && isMswStarted) { -// logger.debug("[shell] MSW is started, waiting for the modules to be ready."); -// } - -// if (areModulesReady && isMswStarted) { -// if (isActiveRouteProtected) { -// logger.debug(`[shell] Fetching session data as "${window.location}" is a protected route.`); - -// const sessionPromise = axios.get("/api/session") -// .then(({ data }) => { -// const session: Session = { -// user: { -// id: data.userId, -// name: data.username -// } -// }; - -// logger.debug("[shell] %cSession is ready%c:", "color: white; background-color: green;", "", session); - -// sessionManager.setSession(session); -// }); - -// const subscriptionPromise = axios.get("/api/subscription") -// .then(({ data }) => { -// const _subscription: Subscription = { -// company: data.company, -// contact: data.contact, -// status: data.status -// }; - -// logger.debug("[shell] %cSubscription is ready%c:", "color: white; background-color: green;", "", _subscription); - -// setSubscription(_subscription); -// }); - -// Promise.all([sessionPromise, subscriptionPromise]) -// .catch((error: unknown) => { -// if (axios.isAxiosError(error) && error.response?.status === 401) { -// // The authentication boundary will redirect to the login page. -// return; -// } - -// throw error; -// }) -// .finally(() => { -// setIsReady(true); -// }); -// } else { -// logger.debug(`[shell] Passing through as "${window.location}" is a public route.`); - -// setIsReady(true); -// } -// } -// }, [areModulesReady, isMswStarted, isActiveRouteProtected, logger, sessionManager]); - -// const router = useMemo(() => { -// return createBrowserRouter([ -// { -// element: , -// children: routes -// } -// ]); -// }, [routes]); - -// if (!isReady) { -// return
Loading...
; -// } - -// return ( -// -// -// -// -// -// ); -// } diff --git a/samples/endpoints/shell/src/LoginPage.tsx b/samples/endpoints/shell/src/LoginPage.tsx index 30626ba72..b815b41ca 100644 --- a/samples/endpoints/shell/src/LoginPage.tsx +++ b/samples/endpoints/shell/src/LoginPage.tsx @@ -23,12 +23,12 @@ export function LoginPage({ host }: LoginPageProps) { .then(() => { setIsBusy(false); - // Reloading the whole application so the "App.tsx" component is re-rendered. Ideally, "useNavigate" would be - // used so "App.tsx" component would re-renderer everytime the location change but it doesn't - // seem feasible (at least not easily) as public and private routes go through the "App.tsx" component. + // Reloading the whole application so the "RootRoute" component states are reinitialize. + // If we use navigate("/") instead, since "isProtectedDataLoaded" might already be true in the case + // of Logout -> Login, the rendering will bypass the loading of the protected data (including the session) + // which will result in an incoherent state. // Anyhow, since all the Workleap apps will authenticate through a third party authentication provider, it - // doesn't seems like a big deal as the application will be reloaded anyway after the user logged in on the third party. - // application will be reloaded anyway after the login on the third party. + // doesn't seems like a big deal as the application will be reloaded anyway after the user logged in with the third party. window.location.href = "/"; }) .catch((error: unknown) => { From dadaadb87bf67f8d5fb0eaa7d1c29de187f96824 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Sun, 22 Oct 2023 23:15:07 -0400 Subject: [PATCH 06/22] Added early support for deferred registration --- README.md | 1 + docs/getting-started/create-host.md | 6 +- docs/getting-started/create-local-module.md | 2 +- docs/getting-started/learn-the-api.md | 26 +- docs/guides/develop-a-module-in-isolation.md | 6 +- .../registration/registerLocalModules.md | 10 +- .../registration/registerRemoteModules.md | 10 +- .../registration/useAreModulesReady.md | 2 +- .../federation/moduleRegistrationStatus.ts | 2 +- .../src/federation/registerLocalModules.ts | 82 ++++++- .../core/src/federation/registerModule.ts | 6 +- packages/core/src/shared/assertions.ts | 5 + .../webpack-module-federation/src/index.ts | 1 + .../src/registerRemoteModules.ts | 81 ++++++- .../src/useAreModulesReady.ts | 5 +- .../src/useAreModulesRegistered.ts | 38 +++ pnpm-lock.yaml | 223 ++++++++++++++---- .../another-remote-module/src/dev/index.tsx | 2 +- .../another-remote-module/src/register.tsx | 2 +- samples/basic/host/src/bootstrap.tsx | 4 +- samples/basic/host/src/register.tsx | 6 +- samples/basic/local-module/src/dev/index.tsx | 2 +- samples/basic/local-module/src/register.tsx | 2 +- .../basic/remote-module/src/ColoredPage.tsx | 4 +- samples/basic/remote-module/src/dev/index.tsx | 2 +- samples/basic/remote-module/src/register.tsx | 2 +- samples/basic/shell/src/register.tsx | 2 +- samples/endpoints/host/src/bootstrap.tsx | 30 +-- samples/endpoints/host/src/register.tsx | 6 +- .../local-module/src/FeatureAPage.tsx | 11 + .../endpoints/local-module/src/dev/index.tsx | 3 +- .../endpoints/local-module/src/register.tsx | 21 +- .../remote-module/src/FeatureBPage.tsx | 11 + .../remote-module/src/FeatureCPage.tsx | 11 + .../endpoints/remote-module/src/dev/index.tsx | 3 +- .../endpoints/remote-module/src/register.tsx | 31 ++- samples/endpoints/shared/src/featureFlags.ts | 13 + samples/endpoints/shared/src/index.ts | 1 + .../shell/mocks/featureFlagHandlers.ts | 24 ++ samples/endpoints/shell/mocks/handlers.ts | 10 + samples/endpoints/shell/package.json | 7 +- samples/endpoints/shell/src/AppRouter.tsx | 95 ++++++-- samples/endpoints/shell/src/register.tsx | 34 ++- 43 files changed, 669 insertions(+), 176 deletions(-) create mode 100644 packages/webpack-module-federation/src/useAreModulesRegistered.ts create mode 100644 samples/endpoints/local-module/src/FeatureAPage.tsx create mode 100644 samples/endpoints/remote-module/src/FeatureBPage.tsx create mode 100644 samples/endpoints/remote-module/src/FeatureCPage.tsx create mode 100644 samples/endpoints/shared/src/featureFlags.ts create mode 100644 samples/endpoints/shell/mocks/featureFlagHandlers.ts create mode 100644 samples/endpoints/shell/mocks/handlers.ts diff --git a/README.md b/README.md index c481330e8..8cf378ee7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A federated web application shell built on top of [Module Federation](https://we | [@squide/core](packages/core/README.md) | [![npm version](https://img.shields.io/npm/v/@squide/core)](https://www.npmjs.com/package/@squide/core) | | [@squide/react-router](packages/react-router/README.md) | [![npm version](https://img.shields.io/npm/v/@squide/react-router)](https://www.npmjs.com/package/@squide/react-router) | | [@squide/webpack-module-federation](packages/webpack-module-federation/README.md) | [![npm version](https://img.shields.io/npm/v/@squide/webpack-module-federation)](https://www.npmjs.com/package/@squide/webpack-module-federation) | +| [@squide/msw](packages/msw/README.md) | [![npm version](https://img.shields.io/npm/v/@squide/msw)](https://www.npmjs.com/package/@squide/msw) | | [@squide/fakes](packages/fakes/README.md) | [![npm version](https://img.shields.io/npm/v/@squide/fakes)](https://www.npmjs.com/package/@squide/fakes) | ## Have a question or found an issue? diff --git a/docs/getting-started/create-host.md b/docs/getting-started/create-host.md index 376eeb8a1..fe93f4d22 100644 --- a/docs/getting-started/create-host.md +++ b/docs/getting-started/create-host.md @@ -104,7 +104,7 @@ const context: AppContext = { }; // Register the remote module. -registerRemoteModules(Remotes, runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); const root = createRoot(document.getElementById("root")!); @@ -290,10 +290,10 @@ const context: AppContext = { }; // Register the newly created local module. -registerLocalModules([registerHost], runtime, { context }); +await registerLocalModules([registerHost], runtime, { context }); // Register the remote module. -registerRemoteModules(Remotes, runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); const root = createRoot(document.getElementById("root")!); diff --git a/docs/getting-started/create-local-module.md b/docs/getting-started/create-local-module.md index c0d401961..f4bb17fc4 100644 --- a/docs/getting-started/create-local-module.md +++ b/docs/getting-started/create-local-module.md @@ -151,7 +151,7 @@ const context: AppContext = { }; // Register the remote module. -registerRemoteModules(Remotes, runtime, context); +await registerRemoteModules(Remotes, runtime, context); // Register the local module. registerLocalModule([registerLocalModule], runtime, context); diff --git a/docs/getting-started/learn-the-api.md b/docs/getting-started/learn-the-api.md index 88f8226c1..2f57dd212 100644 --- a/docs/getting-started/learn-the-api.md +++ b/docs/getting-started/learn-the-api.md @@ -200,19 +200,19 @@ Then, [retrieve the modules MSW request handlers](../reference/msw/MswPlugin.md# import { registerRemoteModules } from "@squide/webpack-module-federation"; import { setMswAsStarted } from "@squide/msw"; -registerRemoteModules(Remotes, runtime).then(() => { - if (process.env.USE_MSW) { - // Files including an import to the "msw" package are included dynamically to prevent adding - // MSW stuff to the bundled when it's not used. - import("../mocks/browser.ts").then(({ startMsw }) => { - // Will start MSW with the request handlers provided by every module. - startMsw(mswPlugin.requestHandlers); - - // Indicate to resources that are dependent on MSW that the service has been started. - setMswAsStarted(); - }); - } -}); +await registerRemoteModules(Remotes, runtime); + +if (process.env.USE_MSW) { + // Files including an import to the "msw" package are included dynamically to prevent adding + // MSW stuff to the bundled when it's not used. + import("../mocks/browser.ts").then(({ startMsw }) => { + // Will start MSW with the request handlers provided by every module. + startMsw(mswPlugin.requestHandlers); + + // Indicate to resources that are dependent on MSW that the service has been started. + setMswAsStarted(); + }); +} ``` Finally, make sure that the [application rendering is delayed](../reference/msw/useIsMswReady.md) until MSW is started: diff --git a/docs/guides/develop-a-module-in-isolation.md b/docs/guides/develop-a-module-in-isolation.md index fcd7e4b1f..4c2c9f99f 100644 --- a/docs/guides/develop-a-module-in-isolation.md +++ b/docs/guides/develop-a-module-in-isolation.md @@ -149,9 +149,9 @@ const context: AppContext = { }; // Register the shell module. -registerLocalModules([registerShell, registerHost], runtime, { context }); +await registerLocalModules([registerShell, registerHost], runtime, { context }); -registerRemoteModules(Remotes, runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); const root = createRoot(document.getElementById("root")!); @@ -214,7 +214,7 @@ const runtime = new Runtime({ // Registering the remote module as a static module because the "register" function // is local when developing in isolation. -registerLocalModules([registerModule, registerDev, registerShell], runtime); +await registerLocalModules([registerModule, registerDev, registerShell], runtime); const root = createRoot(document.getElementById("root")!); diff --git a/docs/reference/registration/registerLocalModules.md b/docs/reference/registration/registerLocalModules.md index 079131342..804202175 100644 --- a/docs/reference/registration/registerLocalModules.md +++ b/docs/reference/registration/registerLocalModules.md @@ -44,7 +44,7 @@ const context: AppContext = { name: "Test app" }; -registerLocalModules([register], runtime, { context }); +await registerLocalModules([register], runtime, { context }); ``` ```tsx !#5-15 local-module/src/register.tsx @@ -67,7 +67,7 @@ export function register: ModuleRegisterFunction(runtime, c ### Handle the registration errors -```tsx !#11-15 host/src/bootstrap.tsx +```tsx !#11-13 host/src/bootstrap.tsx import { registerLocalModules, Runtime } from "@squide/react-router"; import { register } from "@sample/local-module"; import type { AppContext } from "@sample/shared"; @@ -78,9 +78,7 @@ const context: AppContext = { name: "Test app" }; -registerLocalModules([register], runtime, { context }).then(errors => { - errors.forEach(x => { - console.log(x); - }); +(await registerLocalModules([register], runtime, { context })).forEach(x => { + console.log(x); }); ``` diff --git a/docs/reference/registration/registerRemoteModules.md b/docs/reference/registration/registerRemoteModules.md index 35b3848a8..d8e02f8e4 100644 --- a/docs/reference/registration/registerRemoteModules.md +++ b/docs/reference/registration/registerRemoteModules.md @@ -51,7 +51,7 @@ const Remotes: RemoteDefinition = [ { name: "remote1", url: "http://localhost:8081" } ]; -registerRemoteModules(Remotes, runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); ``` ```tsx !#5-15 remote-module/src/register.tsx @@ -74,7 +74,7 @@ export function register: ModuleRegisterFunction(runtime, c ### Handle the registration errors -```tsx !#15-19 host/src/bootstrap.tsx +```tsx !#15-17 host/src/bootstrap.tsx import { Runtime } from "@squide/react-router"; import { registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; import type { AppContext } from "@sample/shared"; @@ -89,10 +89,8 @@ const Remotes: RemoteDefinition = [ { name: "remote1", url: "http://localhost:8081" } ]; -registerRemoteModules(Remotes, runtime, { context }).then(errors => { - errors.forEach(x => { - console.log(x); - }); +(await registerRemoteModules(Remotes, runtime, { context })).forEach(x => { + console.log(x); }); ``` diff --git a/docs/reference/registration/useAreModulesReady.md b/docs/reference/registration/useAreModulesReady.md index 5f2efba4f..090b4f9c6 100644 --- a/docs/reference/registration/useAreModulesReady.md +++ b/docs/reference/registration/useAreModulesReady.md @@ -36,7 +36,7 @@ const Remotes: RemoteDefinition = [ { name: "remote1", url: "http://localhost:8081" } ]; -registerRemoteModules(Remotes, runtime); +await registerRemoteModules(Remotes, runtime); const root = createRoot(document.getElementById("root")!); diff --git a/packages/core/src/federation/moduleRegistrationStatus.ts b/packages/core/src/federation/moduleRegistrationStatus.ts index bfc912d51..bcef8c7b3 100644 --- a/packages/core/src/federation/moduleRegistrationStatus.ts +++ b/packages/core/src/federation/moduleRegistrationStatus.ts @@ -1 +1 @@ -export type ModuleRegistrationStatus = "none" | "in-progress" | "ready"; +export type ModuleRegistrationStatus = "none" | "in-progress" | "registered" | "in-completion" | "ready"; diff --git a/packages/core/src/federation/registerLocalModules.ts b/packages/core/src/federation/registerLocalModules.ts index 5f2879b8b..d25a8f8c6 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -1,6 +1,7 @@ +import { isFunction, type Logger } from "../index.ts"; import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; import type { ModuleRegistrationStatus } from "./moduleRegistrationStatus.ts"; -import { registerModule, type ModuleRegisterFunction } from "./registerModule.ts"; +import { registerModule, type DeferedRegisterationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; let registrationStatus: ModuleRegistrationStatus = "none"; @@ -12,15 +13,22 @@ export function resetLocalModulesRegistrationStatus() { registrationStatus = "none"; } -export interface LocalModuleRegistrationError { - // The registration error. - error: unknown; +interface DeferedRegisteration { + index: string; + fct: DeferedRegisterationFunction; } +const deferedRegistrations: DeferedRegisteration[] = []; + export interface RegisterLocalModulesOptions { context?: TContext; } +export interface LocalModuleRegistrationError { + // The registration error. + error: unknown; +} + export async function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { if (registrationStatus !== "none") { throw new Error("[squide] [local] registerLocalModules() can only be called once."); @@ -33,12 +41,17 @@ export async function registerLocalModules { - let optionalPromise; - runtime.logger.information(`[squide] [local] ${index + 1}/${registerFunctions.length} Registering local module.`); try { - optionalPromise = registerModule(x as ModuleRegisterFunction, runtime, context); + const optionalDeferedRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); + + if (isFunction(optionalDeferedRegistration)) { + deferedRegistrations.push({ + index: `${index + 1}/${registerFunctions.length}`, + fct: optionalDeferedRegistration + }); + } } catch (error: unknown) { runtime.logger.error( `[squide] [local] ${index + 1}/${registerFunctions.length} An error occured while registering a local module.`, @@ -51,6 +64,50 @@ export async function registerLocalModules 0 ? "registered" : "ready"; + + return errors; +} + +export async function completeLocalModuleRegistration(logger: Logger, data?: TData) { + if (registrationStatus === "none" || registrationStatus === "in-progress") { + throw new Error("[squide] [local] completeLocalModuleRegistration() can only be called once registerLocalModules() terminated."); + } + + if (registrationStatus !== "registered" && deferedRegistrations.length > 0) { + throw new Error("[squide] [local] completeLocalModuleRegistration() can only be called once."); + } + + if (registrationStatus === "ready") { + // No defered registrations were returned by the local modules, skip the completion process. + return Promise.resolve(); + } + + registrationStatus = "in-completion"; + + const errors: LocalModuleRegistrationError[] = []; + + await Promise.allSettled(deferedRegistrations.map(({ index, fct }) => { + let optionalPromise; + + logger.information(`[squide] [local] ${index} Completing local module deferred registration.`); + + try { + optionalPromise = fct(data); + } catch (error: unknown) { + logger.error( + `[squide] [local] ${index} An error occured while completing the registration of a local module.`, + error + ); + + errors.push({ + error + }); + } + + logger.information(`[squide] [local] ${index} Completed local module deferred registration.`); return optionalPromise; })); @@ -59,3 +116,14 @@ export async function registerLocalModules What if, je ne veux pas gosser avec les defered registrations et je n'en utilise pas?!?! + -> Je pourrais avoir une option initialement: { supportDeferredRegistration: boolean - false par défaut } + -> Probablement pas besoin, je vais juste regarder en fonction du nombre de deferredRegistrations +*/ diff --git a/packages/core/src/federation/registerModule.ts b/packages/core/src/federation/registerModule.ts index 509513efd..a7a17230e 100644 --- a/packages/core/src/federation/registerModule.ts +++ b/packages/core/src/federation/registerModule.ts @@ -1,6 +1,10 @@ import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; -export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => void; +// TODO: Alex, helppppp! +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type DeferedRegisterationFunction = (data?: any) => Promise | void; + +export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | Promise | void; export async function registerModule(register: ModuleRegisterFunction, runtime: AbstractRuntime, context?: unknown) { return register(runtime, context); diff --git a/packages/core/src/shared/assertions.ts b/packages/core/src/shared/assertions.ts index 04ce1378b..83d14d354 100644 --- a/packages/core/src/shared/assertions.ts +++ b/packages/core/src/shared/assertions.ts @@ -17,3 +17,8 @@ export function isNil(value: unknown): value is null | undefined { export function isNilOrEmpty(value: unknown): value is null | undefined | "" { return isNil(value) || value === ""; } + +export function isFunction(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === "function"; +} + diff --git a/packages/webpack-module-federation/src/index.ts b/packages/webpack-module-federation/src/index.ts index 0d1bd8420..791238cd5 100644 --- a/packages/webpack-module-federation/src/index.ts +++ b/packages/webpack-module-federation/src/index.ts @@ -2,4 +2,5 @@ export * from "./loadRemote.ts"; export * from "./registerRemoteModules.ts"; export * from "./remoteDefinition.ts"; export * from "./useAreModulesReady.ts"; +export * from "./useAreModulesRegistered.ts"; diff --git a/packages/webpack-module-federation/src/registerRemoteModules.ts b/packages/webpack-module-federation/src/registerRemoteModules.ts index a3e9deff1..c62a30442 100644 --- a/packages/webpack-module-federation/src/registerRemoteModules.ts +++ b/packages/webpack-module-federation/src/registerRemoteModules.ts @@ -1,4 +1,4 @@ -import { isNil, registerModule, type AbstractRuntime, type ModuleRegistrationStatus } from "@squide/core"; +import { isFunction, isNil, registerModule, type AbstractRuntime, type DeferedRegisterationFunction, type Logger, type ModuleRegistrationStatus } from "@squide/core"; import { loadRemote } from "./loadRemote.ts"; import { RemoteEntryPoint, RemoteModuleName, type RemoteDefinition } from "./remoteDefinition.ts"; @@ -8,11 +8,24 @@ export function getRemoteModulesRegistrationStatus() { return registrationStatus; } -// Strictly for testing purpose. +// Added to facilitate the unit tests. export function resetRemoteModulesRegistrationStatus() { registrationStatus = "none"; } +interface DeferedRegistration { + url: string; + containerName: string; + index: string; + fct: DeferedRegisterationFunction; +} + +const deferedRegistrations: DeferedRegistration[] = []; + +export interface RegisterRemoteModulesOptions { + context?: unknown; +} + export interface RemoteModuleRegistrationError { // The remote base URL. url: string; @@ -24,10 +37,6 @@ export interface RemoteModuleRegistrationError { error: unknown; } -export interface RegisterRemoteModulesOptions { - context?: unknown; -} - export async function registerRemoteModules(remotes: RemoteDefinition[], runtime: AbstractRuntime, { context }: RegisterRemoteModulesOptions = {}) { if (registrationStatus !== "none") { throw new Error("[squide] [remote] registerRemoteModules() can only be called once."); @@ -58,7 +67,16 @@ export async function registerRemoteModules(remotes: RemoteDefinition[], runtime runtime.logger.information(`[squide] [remote] ${index + 1}/${remotes.length} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - await registerModule(module.register, runtime, context); + const optionalDeferedRegistration = await registerModule(module.register, runtime, context); + + if (isFunction(optionalDeferedRegistration)) { + deferedRegistrations.push({ + url: remoteUrl, + containerName: x.name, + index: `${index + 1}/${remotes.length}`, + fct: optionalDeferedRegistration + }); + } runtime.logger.information(`[squide] [remote] ${index + 1}/${remotes.length} Container "${containerName}" of remote "${remoteUrl}" registration completed.`); } catch (error: unknown) { @@ -76,6 +94,55 @@ export async function registerRemoteModules(remotes: RemoteDefinition[], runtime } })); + registrationStatus = deferedRegistrations.length > 0 ? "registered" : "ready"; + + return errors; +} + +export async function completeRemoteModuleRegistration(logger: Logger, data?: TData) { + if (registrationStatus === "none" || registrationStatus === "in-progress") { + throw new Error("[squide] [remote] completeRemoteModuleRegistration() can only be called once registerRemoteModules() terminated."); + } + + if (registrationStatus !== "registered" && deferedRegistrations.length > 0) { + throw new Error("[squide] [remote] completeRemoteModuleRegistration() can only be called once."); + } + + if (registrationStatus === "ready") { + // No defered registrations were returned by the remote modules, skip the completion process. + return Promise.resolve(); + } + + registrationStatus = "in-completion"; + + const errors: RemoteModuleRegistrationError[] = []; + + await Promise.allSettled(deferedRegistrations.map(({ url, containerName, index, fct }) => { + let optionalPromise; + + logger.information(`[squide] [remote] ${index} Completing registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); + + try { + optionalPromise = fct(data); + } catch (error: unknown) { + logger.error( + `[squide] [remote] ${index} An error occured while completing the registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`, + error + ); + + errors.push({ + url, + containerName, + moduleName: RemoteModuleName, + error + }); + } + + logger.information(`[squide] [remote] ${index} Completed registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); + + return optionalPromise; + })); + registrationStatus = "ready"; return errors; diff --git a/packages/webpack-module-federation/src/useAreModulesReady.ts b/packages/webpack-module-federation/src/useAreModulesReady.ts index 41b73f936..4976ac99e 100644 --- a/packages/webpack-module-federation/src/useAreModulesReady.ts +++ b/packages/webpack-module-federation/src/useAreModulesReady.ts @@ -9,9 +9,8 @@ export interface UseAreModulesReadyOptions { } function areModulesReady() { - // Validating for "in-progress" instead of "ready" for the local module because "registerLocalModules" - // could never be called. - return getLocalModulesRegistrationStatus() !== "in-progress" && getRemoteModulesRegistrationStatus() !== "in-progress"; + return (getLocalModulesRegistrationStatus() === "none" || getLocalModulesRegistrationStatus() === "ready") && + (getRemoteModulesRegistrationStatus() === "none" || getRemoteModulesRegistrationStatus() === "ready"); } export function useAreModulesReady({ interval = 10 }: UseAreModulesReadyOptions = {}) { diff --git a/packages/webpack-module-federation/src/useAreModulesRegistered.ts b/packages/webpack-module-federation/src/useAreModulesRegistered.ts new file mode 100644 index 000000000..d2f7324de --- /dev/null +++ b/packages/webpack-module-federation/src/useAreModulesRegistered.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +import { getLocalModulesRegistrationStatus } from "@squide/core"; +import { getRemoteModulesRegistrationStatus } from "./registerRemoteModules.ts"; + +export interface UseAreModulesRegisteredOptions { + // The interval is in milliseconds. + interval?: number; +} + +function areModulesRegistered() { + return (getLocalModulesRegistrationStatus() === "none" || getLocalModulesRegistrationStatus() === "registered") && + (getRemoteModulesRegistrationStatus() === "none" || getRemoteModulesRegistrationStatus() === "registered"); +} + +export function useAreModulesRegistered({ interval = 10 }: UseAreModulesRegisteredOptions = {}) { + // Using a state hook to force a rerender once registered. + const [value, setAreModulesRegistered] = useState(false); + + // Perform a reload once the modules are registered. + useEffect(() => { + const intervalId = setInterval(() => { + if (areModulesRegistered()) { + clearInterval(intervalId); + + setAreModulesRegistered(true); + } + }, interval); + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, []); + + return value; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 554b7bcdb..625e2ba29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,16 +31,16 @@ importers: version: 8.51.0 jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + version: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) netlify-cli: specifier: 16.8.0 - version: 16.8.0(@types/node@20.8.6) + version: 16.8.0(@types/node@20.8.7) retypeapp: specifier: 3.5.0 version: 3.5.0 ts-node: specifier: 10.9.1 - version: 10.9.1(@types/node@20.8.6)(typescript@5.2.2) + version: 10.9.1(@types/node@20.8.7)(typescript@5.2.2) typescript: specifier: 5.2.2 version: 5.2.2 @@ -99,7 +99,7 @@ importers: version: 3.0.2(typescript@5.2.2) jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + version: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) tsup: specifier: 7.2.0 version: 7.2.0(@swc/core@1.3.93)(postcss@8.4.31)(ts-node@10.9.1)(typescript@5.2.2) @@ -133,7 +133,7 @@ importers: version: 3.0.2(typescript@5.2.2) jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + version: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) msw: specifier: 1.3.2 version: 1.3.2(typescript@5.2.2) @@ -194,7 +194,7 @@ importers: version: 3.0.2(typescript@5.2.2) jest: specifier: 29.7.0 - version: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + version: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) jest-environment-jsdom: specifier: 29.7.0 version: 29.7.0 @@ -1088,6 +1088,9 @@ importers: '@squide/webpack-module-federation': specifier: workspace:* version: link:../../../packages/webpack-module-federation + '@types/node': + specifier: 20.8.7 + version: 20.8.7 '@types/react': specifier: 18.2.28 version: 18.2.28 @@ -1106,6 +1109,9 @@ importers: axios: specifier: 1.5.1 version: 1.5.1(debug@4.3.4) + cross-env: + specifier: 7.0.3 + version: 7.0.3 msw: specifier: 1.3.2 version: 1.3.2(typescript@5.2.2) @@ -3185,7 +3191,7 @@ packages: engines: {node: ^8.13.0 || >=10.10.0} dependencies: '@grpc/proto-loader': 0.7.10 - '@types/node': 20.8.6 + '@types/node': 20.8.7 dev: true /@grpc/proto-loader@0.7.10: @@ -3282,7 +3288,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -3303,14 +3309,14 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + jest-config: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -3345,7 +3351,7 @@ packages: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 jest-mock: 29.7.0 dev: true @@ -3372,7 +3378,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.8.6 + '@types/node': 20.8.7 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -3405,7 +3411,7 @@ packages: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.20 - '@types/node': 20.8.6 + '@types/node': 20.8.7 chalk: 4.1.2 collect-v8-coverage: 1.0.2 exit: 0.1.2 @@ -3492,7 +3498,7 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.5 '@types/istanbul-reports': 3.0.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 '@types/yargs': 16.0.7 chalk: 4.1.2 dev: true @@ -3504,7 +3510,7 @@ packages: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.5 '@types/istanbul-reports': 3.0.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 '@types/yargs': 17.0.29 chalk: 4.1.2 dev: true @@ -3635,7 +3641,7 @@ packages: yargs: 17.7.2 dev: true - /@netlify/build@29.23.1(@types/node@20.8.6)(debug@4.3.4): + /@netlify/build@29.23.1(@types/node@20.8.7)(debug@4.3.4): resolution: {integrity: sha512-Rt5Depj9QwBdvRW+atwWThBsLcWgNIUq6ZA2ypGWTu5+FISXaX8Py5tWsaeIMc7TS5/ZZBcJWYn+gSkgH+FWQg==} engines: {node: ^14.16.0 || >=16.0.0} hasBin: true @@ -3691,7 +3697,7 @@ packages: strip-ansi: 7.1.0 supports-color: 9.4.0 terminal-link: 3.0.0 - ts-node: 10.9.1(@types/node@20.8.6)(typescript@5.2.2) + ts-node: 10.9.1(@types/node@20.8.7)(typescript@5.2.2) typescript: 5.2.2 uuid: 9.0.0 yargs: 17.7.2 @@ -5443,29 +5449,29 @@ packages: resolution: {integrity: sha512-N7UDG0/xiPQa2D/XrVJXjkWbpqHCd2sBaB32ggRF2l83RhPfamgKGF8gwwqyksS95qUS5ZYF9aF+lLPRlwI2UA==} dependencies: '@types/connect': 3.4.37 - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/bonjour@3.5.12: resolution: {integrity: sha512-ky0kWSqXVxSqgqJvPIkgFkcn4C8MnRog308Ou8xBBIVo39OmUFy+jqNe0nPwLCDFxUpmT9EvT91YzOJgkDRcFg==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/concat-stream@2.0.1: resolution: {integrity: sha512-v5HP9ZsRbzFq5XRo2liUZPKzwbGK5SuGVMWZjE6iJOm/JNdESk3/rkfcPe0lcal0C32PTLVlYUYqGpMGNdDsDg==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 dev: true /@types/connect-history-api-fallback@1.5.2: resolution: {integrity: sha512-gX2j9x+NzSh4zOhnRPSdPPmTepS4DfxES0AvIFv3jGv5QyeAJf6u6dY5/BAoAJU9Qq1uTvwOku8SSC2GnCRl6Q==} dependencies: '@types/express-serve-static-core': 4.17.38 - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/connect@3.4.37: resolution: {integrity: sha512-zBUSRqkfZ59OcwXon4HVxhx5oWCJmc0OtBTK05M+p0dYjgN6iTwIL2T/WbsQZrEsdnwaF9cWQ+azOnpPvIqY3Q==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/cookie@0.4.1: resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} @@ -5499,7 +5505,7 @@ packages: /@types/express-serve-static-core@4.17.38: resolution: {integrity: sha512-hXOtc0tuDHZPFwwhuBJXPbjemWtXnJjbvuuyNH2Y5Z6in+iXc63c4eXYDc7GGGqHy+iwYqAJMdaItqdnbcBKmg==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 '@types/qs': 6.9.9 '@types/range-parser': 1.2.6 '@types/send': 0.17.3 @@ -5515,7 +5521,7 @@ packages: /@types/graceful-fs@4.1.8: resolution: {integrity: sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 dev: true /@types/hast@2.3.7: @@ -5537,7 +5543,7 @@ packages: /@types/http-proxy@1.17.13: resolution: {integrity: sha512-GkhdWcMNiR5QSQRYnJ+/oXzu0+7JJEPC8vkWXK351BkhjraZF+1W13CUYARUvX9+NqIU2n6YHA4iwywsc/M6Sw==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/is-ci@3.0.3: resolution: {integrity: sha512-FdHbjLiN2e8fk9QYQyVYZrK8svUDJpxSaSWLUga8EZS1RGAvvrqM9zbVARBtQuYPeLgnJxM2xloOswPwj1o2cQ==} @@ -5578,7 +5584,7 @@ packages: /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 '@types/tough-cookie': 4.0.4 parse5: 7.1.2 dev: true @@ -5621,6 +5627,12 @@ packages: resolution: {integrity: sha512-eWO4K2Ji70QzKUqRy6oyJWUeB7+g2cRagT3T/nxYibYcT4y2BDL8lqolRXjTHmkZCdJfIPaY73KbJAZmcryxTQ==} dependencies: undici-types: 5.25.3 + dev: true + + /@types/node@20.8.7: + resolution: {integrity: sha512-21TKHHh3eUHIi2MloeptJWALuCu5H7HQTdTrWIFReA8ad+aggoX+lRes3ex7/FtpC+sVUpFMQ+QTfYr74mruiQ==} + dependencies: + undici-types: 5.25.3 /@types/normalize-package-data@2.4.3: resolution: {integrity: sha512-ehPtgRgaULsFG8x0NeYJvmyH1hmlfsNLujHe9dQEia/7MAJYdzMSi19JtchUHjmBA6XC/75dK55mzZH+RyieSg==} @@ -5675,7 +5687,7 @@ packages: resolution: {integrity: sha512-/7fKxvKUoETxjFUsuFlPB9YndePpxxRAOfGC/yJdc9kTjTeP5kRCTzfnE8kPUKCeyiyIZu0YQ76s50hCedI1ug==} dependencies: '@types/mime': 1.3.4 - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/serve-index@1.9.3: resolution: {integrity: sha512-4KG+yMEuvDPRrYq5fyVm/I2uqAJSAwZK9VSa+Zf+zUq9/oxSSvy3kkIqyL+jjStv6UCVi8/Aho0NHtB1Fwosrg==} @@ -5687,17 +5699,17 @@ packages: dependencies: '@types/http-errors': 2.0.3 '@types/mime': 3.0.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/set-cookie-parser@2.4.5: resolution: {integrity: sha512-ZPmztaAQ4rbnW/WTUnT1dwSENQo4bjGqxCSeyK+gZxmd+zJl/QAeF6dpEXcS5UEJX22HwiggFSaY8nE1nRmkbg==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/sockjs@0.3.35: resolution: {integrity: sha512-tIF57KB+ZvOBpAQwSaACfEu7htponHXaFzP7RfKYgsOS0NoYnn+9+jzp7bbq4fWerizI3dTB4NfAZoyeQKWJLw==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/stack-utils@2.0.2: resolution: {integrity: sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==} @@ -5748,7 +5760,7 @@ packages: /@types/ws@8.5.8: resolution: {integrity: sha512-flUksGIQCnJd6sZ1l5dqCEG/ksaoAg/eUwiLAGTJQcfgvZJKF++Ta4bJA6A5aPSJmsr+xlseHn4KLgVlNnvPTg==} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 /@types/yargs-parser@21.0.2: resolution: {integrity: sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==} @@ -5770,7 +5782,7 @@ packages: resolution: {integrity: sha512-Km7XAtUIduROw7QPgvcft0lIupeG8a8rdKL8RiSyKvlE7dYY31fEn41HVuQsRFDuROA8tA4K2UVL+WdfFmErBA==} requiresBuild: true dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 dev: true optional: true @@ -8088,6 +8100,25 @@ packages: - ts-node dev: true + /create-jest@29.7.0(@types/node@20.8.7)(ts-node@10.9.1): + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true @@ -9173,7 +9204,7 @@ packages: '@typescript-eslint/eslint-plugin': 6.8.0(@typescript-eslint/parser@6.8.0)(eslint@8.51.0)(typescript@5.2.2) '@typescript-eslint/utils': 5.62.0(eslint@8.51.0)(typescript@5.2.2) eslint: 8.51.0 - jest: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + jest: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) transitivePeerDependencies: - supports-color - typescript @@ -11558,7 +11589,7 @@ packages: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 chalk: 4.1.2 co: 4.6.0 dedent: 1.5.1 @@ -11607,6 +11638,34 @@ packages: - ts-node dev: true + /jest-cli@29.7.0(@types/node@20.8.7)(ts-node@10.9.1): + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.1) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /jest-config@29.7.0(@types/node@20.8.6)(ts-node@10.9.1): resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -11642,7 +11701,48 @@ packages: pretty-format: 29.7.0 slash: 3.0.0 strip-json-comments: 3.1.1 - ts-node: 10.9.1(@types/node@20.8.6)(typescript@5.2.2) + ts-node: 10.9.1(@types/node@20.8.7)(typescript@5.2.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + dev: true + + /jest-config@29.7.0(@types/node@20.8.7)(ts-node@10.9.1): + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + dependencies: + '@babel/core': 7.23.2 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.8.7 + babel-jest: 29.7.0(@babel/core@7.23.2) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.5 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + ts-node: 10.9.1(@types/node@20.8.7)(typescript@5.2.2) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -11706,7 +11806,7 @@ packages: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 jest-mock: 29.7.0 jest-util: 29.7.0 dev: true @@ -11727,7 +11827,7 @@ packages: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.8 - '@types/node': 20.8.6 + '@types/node': 20.8.7 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -11778,7 +11878,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 jest-util: 29.7.0 dev: true @@ -11833,7 +11933,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -11864,7 +11964,7 @@ packages: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 chalk: 4.1.2 cjs-module-lexer: 1.2.3 collect-v8-coverage: 1.0.2 @@ -11916,7 +12016,7 @@ packages: engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -11953,7 +12053,7 @@ packages: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.8.6 + '@types/node': 20.8.7 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -11965,7 +12065,7 @@ packages: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -11973,7 +12073,7 @@ packages: resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} dependencies: - '@types/node': 20.8.6 + '@types/node': 20.8.7 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -12000,6 +12100,27 @@ packages: - ts-node dev: true + /jest@29.7.0(@types/node@20.8.7)(ts-node@10.9.1): + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.1) + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + dev: true + /jiti@1.20.0: resolution: {integrity: sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==} hasBin: true @@ -13446,7 +13567,7 @@ packages: resolution: {integrity: sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==} dev: true - /netlify-cli@16.8.0(@types/node@20.8.6): + /netlify-cli@16.8.0(@types/node@20.8.7): resolution: {integrity: sha512-Eg/jrszkJE1EAcU8yOGpENxcIiYdB25wI5UeCb4EZrI079n9nrgW8ttGPaR1cY8zhWUcExweJ3iv4i1iQfGBQA==} engines: {node: '>=16.16.0'} hasBin: true @@ -13454,7 +13575,7 @@ packages: dependencies: '@bugsnag/js': 7.20.2 '@fastify/static': 6.10.2 - '@netlify/build': 29.23.1(@types/node@20.8.6)(debug@4.3.4) + '@netlify/build': 29.23.1(@types/node@20.8.7)(debug@4.3.4) '@netlify/build-info': 7.10.1 '@netlify/config': 20.9.0 '@netlify/edge-bundler': 9.3.0(supports-color@9.4.0) @@ -14478,7 +14599,7 @@ packages: dependencies: lilconfig: 2.1.0 postcss: 8.4.31 - ts-node: 10.9.1(@types/node@20.8.6)(typescript@5.2.2) + ts-node: 10.9.1(@types/node@20.8.7)(typescript@5.2.2) yaml: 2.3.3 dev: true @@ -14715,7 +14836,7 @@ packages: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.8.6 + '@types/node': 20.8.7 long: 5.2.3 dev: true @@ -16708,7 +16829,7 @@ packages: bs-logger: 0.2.6 esbuild: 0.18.20 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@20.8.6)(ts-node@10.9.1) + jest: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -16718,7 +16839,7 @@ packages: yargs-parser: 21.1.1 dev: true - /ts-node@10.9.1(@types/node@20.8.6)(typescript@5.2.2): + /ts-node@10.9.1(@types/node@20.8.7)(typescript@5.2.2): resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} hasBin: true peerDependencies: @@ -16737,7 +16858,7 @@ packages: '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 20.8.6 + '@types/node': 20.8.7 acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 diff --git a/samples/basic/another-remote-module/src/dev/index.tsx b/samples/basic/another-remote-module/src/dev/index.tsx index 0b01b35ba..a4633987d 100644 --- a/samples/basic/another-remote-module/src/dev/index.tsx +++ b/samples/basic/another-remote-module/src/dev/index.tsx @@ -14,7 +14,7 @@ const runtime = new Runtime({ sessionAccessor }); -registerLocalModules([registerShell(sessionManager), registerDev, registerModule], runtime); +await registerLocalModules([registerShell(sessionManager), registerDev, registerModule], runtime); const root = createRoot(document.getElementById("root")!); diff --git a/samples/basic/another-remote-module/src/register.tsx b/samples/basic/another-remote-module/src/register.tsx index 3cef24975..98edaca05 100644 --- a/samples/basic/another-remote-module/src/register.tsx +++ b/samples/basic/another-remote-module/src/register.tsx @@ -19,5 +19,5 @@ function registerRoutes(runtime: Runtime) { } export const register: ModuleRegisterFunction = runtime => { - registerRoutes(runtime); + return registerRoutes(runtime); }; diff --git a/samples/basic/host/src/bootstrap.tsx b/samples/basic/host/src/bootstrap.tsx index 413ba62d0..5ed04446c 100644 --- a/samples/basic/host/src/bootstrap.tsx +++ b/samples/basic/host/src/bootstrap.tsx @@ -29,9 +29,9 @@ const context: AppContext = { name: "Test app" }; -registerLocalModules([registerShell(sessionManager, { host: "@basic/host" }), registerHost, registerLocalModule], runtime, { context }); +await registerLocalModules([registerShell(sessionManager, { host: "@basic/host" }), registerHost, registerLocalModule], runtime, { context }); -registerRemoteModules(Remotes, runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); const root = createRoot(document.getElementById("root")!); diff --git a/samples/basic/host/src/register.tsx b/samples/basic/host/src/register.tsx index bf2860e0a..91d024207 100644 --- a/samples/basic/host/src/register.tsx +++ b/samples/basic/host/src/register.tsx @@ -1,6 +1,6 @@ import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; -export const registerHost: ModuleRegisterFunction = runtime => { +function registerRoutes(runtime: Runtime) { runtime.registerRoute({ index: true, lazy: () => import("./HomePage.tsx") @@ -21,4 +21,8 @@ export const registerHost: ModuleRegisterFunction = runtime => { }, { menuId: "/federated-tabs" }); +} + +export const registerHost: ModuleRegisterFunction = runtime => { + return registerRoutes(runtime); }; diff --git a/samples/basic/local-module/src/dev/index.tsx b/samples/basic/local-module/src/dev/index.tsx index 42d730435..30a6439f4 100644 --- a/samples/basic/local-module/src/dev/index.tsx +++ b/samples/basic/local-module/src/dev/index.tsx @@ -14,7 +14,7 @@ const runtime = new Runtime({ sessionAccessor }); -registerLocalModules([registerShell(sessionManager), registerDev, registerLocalModule], runtime); +await registerLocalModules([registerShell(sessionManager), registerDev, registerLocalModule], runtime); const root = createRoot(document.getElementById("root")!); diff --git a/samples/basic/local-module/src/register.tsx b/samples/basic/local-module/src/register.tsx index a05f90a75..d831ba4b3 100644 --- a/samples/basic/local-module/src/register.tsx +++ b/samples/basic/local-module/src/register.tsx @@ -38,5 +38,5 @@ function registerRoutes(runtime: Runtime) { export const registerLocalModule: ModuleRegisterFunction = (runtime, context) => { console.log("Local module context: ", context); - registerRoutes(runtime); + return registerRoutes(runtime); }; diff --git a/samples/basic/remote-module/src/ColoredPage.tsx b/samples/basic/remote-module/src/ColoredPage.tsx index 8817514b2..a670b6bc0 100644 --- a/samples/basic/remote-module/src/ColoredPage.tsx +++ b/samples/basic/remote-module/src/ColoredPage.tsx @@ -8,7 +8,7 @@ export function ColoredPage() { <>

Colored page

This page is served by @basic/remote-module

-

+

There are a few distinctive features that are showcased with this page:

  • This page demonstrates that a React context defined in an host application can be overried in a remote module.
  • @@ -16,7 +16,7 @@ export function ColoredPage() {
  • The host application React context define that background color as blue and the nested React context in the remote module override the background color to be red.
  • Toggle between the Context override and No context override pages to view the difference.
-

+

The background color is "{backgroundColor}"

diff --git a/samples/basic/remote-module/src/dev/index.tsx b/samples/basic/remote-module/src/dev/index.tsx index af125f7c2..23edbc8ee 100644 --- a/samples/basic/remote-module/src/dev/index.tsx +++ b/samples/basic/remote-module/src/dev/index.tsx @@ -16,7 +16,7 @@ const runtime = new Runtime({ // Registering the remote module as a static module because the "register" function // is local when developing in isolation. -registerLocalModules([registerShell(sessionManager), registerDev, registerModule], runtime); +await registerLocalModules([registerShell(sessionManager), registerDev, registerModule], runtime); const root = createRoot(document.getElementById("root")!); diff --git a/samples/basic/remote-module/src/register.tsx b/samples/basic/remote-module/src/register.tsx index 921e4064b..6dba356cb 100644 --- a/samples/basic/remote-module/src/register.tsx +++ b/samples/basic/remote-module/src/register.tsx @@ -86,5 +86,5 @@ function registerRoutes(runtime: Runtime) { } export const register: ModuleRegisterFunction = runtime => { - registerRoutes(runtime); + return registerRoutes(runtime); }; diff --git a/samples/basic/shell/src/register.tsx b/samples/basic/shell/src/register.tsx index 9cba8b74e..6670fd4a0 100644 --- a/samples/basic/shell/src/register.tsx +++ b/samples/basic/shell/src/register.tsx @@ -100,7 +100,7 @@ function registerRoutes(runtime: Runtime, sessionManager: SessionManager, host?: export function registerShell(sessionManager: SessionManager, { host }: RegisterShellOptions = {}) { const register: ModuleRegisterFunction = runtime => { - registerRoutes(runtime, sessionManager, host); + return registerRoutes(runtime, sessionManager, host); }; return register; diff --git a/samples/endpoints/host/src/bootstrap.tsx b/samples/endpoints/host/src/bootstrap.tsx index 68fe3798b..ab5bffe04 100644 --- a/samples/endpoints/host/src/bootstrap.tsx +++ b/samples/endpoints/host/src/bootstrap.tsx @@ -27,21 +27,21 @@ const runtime = new Runtime({ sessionAccessor }); -registerLocalModules([registerShell(sessionManager, { host: "@endpoints/host" }), registerHost, registerLocalModule], runtime); - -registerRemoteModules(Remotes, runtime).then(() => { - if (process.env.USE_MSW) { - // Files including an import to the "msw" package are included dynamically to prevent adding - // MSW stuff to the bundled when it's not used. - import("../mocks/browser.ts").then(({ startMsw }) => { - // Will start MSW with the request handlers provided by every module. - startMsw(mswPlugin.requestHandlers); - - // Indicate to resources that are dependent on MSW that the service has been started. - setMswAsStarted(); - }); - } -}); +await registerLocalModules([registerShell(sessionManager, { host: "@endpoints/host" }), registerHost, registerLocalModule], runtime); + +await registerRemoteModules(Remotes, runtime); + +if (process.env.USE_MSW) { + // Files including an import to the "msw" package are included dynamically to prevent adding + // MSW stuff to the bundled when it's not used. + import("../mocks/browser.ts").then(({ startMsw }) => { + // Will start MSW with the request handlers provided by every module. + startMsw(mswPlugin.requestHandlers); + + // Indicate to resources that are dependent on MSW that the service has been started. + setMswAsStarted(); + }); +} const root = createRoot(document.getElementById("root")!); diff --git a/samples/endpoints/host/src/register.tsx b/samples/endpoints/host/src/register.tsx index b45901d30..994d2fb8f 100644 --- a/samples/endpoints/host/src/register.tsx +++ b/samples/endpoints/host/src/register.tsx @@ -53,8 +53,8 @@ async function registerMsw(runtime: Runtime) { } } -export const registerHost: ModuleRegisterFunction = runtime => { - registerRoutes(runtime); +export const registerHost: ModuleRegisterFunction = async runtime => { + await registerMsw(runtime); - return registerMsw(runtime); + return registerRoutes(runtime); }; diff --git a/samples/endpoints/local-module/src/FeatureAPage.tsx b/samples/endpoints/local-module/src/FeatureAPage.tsx new file mode 100644 index 000000000..e19cd31ae --- /dev/null +++ b/samples/endpoints/local-module/src/FeatureAPage.tsx @@ -0,0 +1,11 @@ +export function FeatureAPage() { + return ( + <> +

FeatureA page

+

This page is served by @endpoints/local-module

+

This page is only available if the featureA flag is active.

+ + ); +} + +export const Component = FeatureAPage; diff --git a/samples/endpoints/local-module/src/dev/index.tsx b/samples/endpoints/local-module/src/dev/index.tsx index dfd929f43..30c06f1cc 100644 --- a/samples/endpoints/local-module/src/dev/index.tsx +++ b/samples/endpoints/local-module/src/dev/index.tsx @@ -18,7 +18,7 @@ const runtime = new Runtime({ sessionAccessor }); -registerLocalModules([registerShell(sessionManager), registerDev, registerLocalModule], runtime); +await registerLocalModules([registerShell(sessionManager), registerDev, registerLocalModule], runtime); // Register MSW after the local modules has been registered since the request handlers // will be registered by the modules. @@ -27,7 +27,6 @@ if (process.env.USE_MSW) { // MSW stuff to the bundled when it's not used. import("../../mocks/browser.ts").then(({ startMsw }) => { startMsw(mswPlugin.requestHandlers); - setMswAsStarted(); }); } diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx index 4198bda0e..3285d852f 100644 --- a/samples/endpoints/local-module/src/register.tsx +++ b/samples/endpoints/local-module/src/register.tsx @@ -1,3 +1,4 @@ +import type { FeatureFlags } from "@endpoints/shared"; import { getMswPlugin } from "@squide/msw"; import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -92,6 +93,20 @@ function registerRoutes(runtime: Runtime) { }, { menuId: "/federated-tabs" }); + + return ({ featureFlags }: { featureFlags?: FeatureFlags } = {}) => { + if (featureFlags?.featureA) { + runtime.registerRoute({ + path: "/feature-a", + lazy: () => import("./FeatureAPage.tsx") + }); + + runtime.registerNavigationItem({ + $label: "Feature A", + to: "/feature-a" + }); + } + }; } async function registerMsw(runtime: Runtime) { @@ -106,8 +121,8 @@ async function registerMsw(runtime: Runtime) { } } -export const registerLocalModule: ModuleRegisterFunction = runtime => { - registerRoutes(runtime); +export const registerLocalModule: ModuleRegisterFunction = async runtime => { + await registerMsw(runtime); - return registerMsw(runtime); + return registerRoutes(runtime); }; diff --git a/samples/endpoints/remote-module/src/FeatureBPage.tsx b/samples/endpoints/remote-module/src/FeatureBPage.tsx new file mode 100644 index 000000000..51be18c0f --- /dev/null +++ b/samples/endpoints/remote-module/src/FeatureBPage.tsx @@ -0,0 +1,11 @@ +export function FeatureBPage() { + return ( + <> +

FeatureB page

+

This page is served by @endpoints/remote-module

+

This page is only available if the featureB flag is active.

+ + ); +} + +export const Component = FeatureBPage; diff --git a/samples/endpoints/remote-module/src/FeatureCPage.tsx b/samples/endpoints/remote-module/src/FeatureCPage.tsx new file mode 100644 index 000000000..82e0c4b87 --- /dev/null +++ b/samples/endpoints/remote-module/src/FeatureCPage.tsx @@ -0,0 +1,11 @@ +export function FeatureCPage() { + return ( + <> +

FeatureC page

+

This page is served by @endpoints/remote-module

+

This page is only available if the featureC flag is active.

+ + ); +} + +export const Component = FeatureCPage; diff --git a/samples/endpoints/remote-module/src/dev/index.tsx b/samples/endpoints/remote-module/src/dev/index.tsx index 36cfde3f9..97002f461 100644 --- a/samples/endpoints/remote-module/src/dev/index.tsx +++ b/samples/endpoints/remote-module/src/dev/index.tsx @@ -20,7 +20,7 @@ const runtime = new Runtime({ // Registering the remote module as a static module because the "register" function // is local when developing in isolation. -registerLocalModules([registerShell(sessionManager), registerDev, registerModule], runtime); +await registerLocalModules([registerShell(sessionManager), registerDev, registerModule], runtime); // Register MSW after the local modules has been registered since the request handlers // will be registered by the modules. @@ -29,7 +29,6 @@ if (process.env.USE_MSW) { // MSW stuff to the bundled when it's not used. import("../../mocks/browser.ts").then(({ startMsw }) => { startMsw(mswPlugin.requestHandlers); - setMswAsStarted(); }); } diff --git a/samples/endpoints/remote-module/src/register.tsx b/samples/endpoints/remote-module/src/register.tsx index 36467f5ea..341b03b63 100644 --- a/samples/endpoints/remote-module/src/register.tsx +++ b/samples/endpoints/remote-module/src/register.tsx @@ -1,3 +1,4 @@ +import type { FeatureFlags } from "@endpoints/shared"; import { getMswPlugin } from "@squide/msw"; import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import { Providers } from "./Providers.tsx"; @@ -62,6 +63,32 @@ function registerRoutes(runtime: Runtime) { }, { menuId: "/federated-tabs" }); + + return ({ featureFlags }: { featureFlags?: FeatureFlags } = {}) => { + if (featureFlags?.featureB) { + runtime.registerRoute({ + path: "/feature-b", + lazy: () => import("./FeatureBPage.tsx") + }); + + runtime.registerNavigationItem({ + $label: "Feature B", + to: "/feature-b" + }); + } + + if (featureFlags?.featureC) { + runtime.registerRoute({ + path: "/feature-c", + lazy: () => import("./FeatureCPage.tsx") + }); + + runtime.registerNavigationItem({ + $label: "Feature C", + to: "/feature-c" + }); + } + }; } async function registerMsw(runtime: Runtime) { @@ -77,7 +104,7 @@ async function registerMsw(runtime: Runtime) { } export const register: ModuleRegisterFunction = async runtime => { - registerRoutes(runtime); + await registerMsw(runtime); - return registerMsw(runtime); + return registerRoutes(runtime); }; diff --git a/samples/endpoints/shared/src/featureFlags.ts b/samples/endpoints/shared/src/featureFlags.ts new file mode 100644 index 000000000..853c4118e --- /dev/null +++ b/samples/endpoints/shared/src/featureFlags.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; + +export interface FeatureFlags { + featureA: boolean; + featureB: boolean; + featureC: boolean; +} + +export const FeatureFlagsContext = createContext(undefined); + +export function useFeatureFlags() { + return useContext(FeatureFlagsContext); +} diff --git a/samples/endpoints/shared/src/index.ts b/samples/endpoints/shared/src/index.ts index 0490fb04b..dc40fc2b0 100644 --- a/samples/endpoints/shared/src/index.ts +++ b/samples/endpoints/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from "./featureFlags.ts"; export * from "./isNetlify.ts"; export * from "./session.ts"; export * from "./subscription.ts"; diff --git a/samples/endpoints/shell/mocks/featureFlagHandlers.ts b/samples/endpoints/shell/mocks/featureFlagHandlers.ts new file mode 100644 index 000000000..4ce9c6f96 --- /dev/null +++ b/samples/endpoints/shell/mocks/featureFlagHandlers.ts @@ -0,0 +1,24 @@ +import { rest, type RestHandler } from "msw"; + +function simulateDelay(delay: number) { + return new Promise(resolve => { + setTimeout(() => { + resolve(undefined); + }, delay); + }); +} + +export const featureFlagHandlers: RestHandler[] = [ + rest.get("/api/feature-flags", async (req, res, ctx) => { + await simulateDelay(500); + + return res( + ctx.status(200), + ctx.json({ + featureA: true, + featureB: true, + featureC: false + }) + ); + }) +]; diff --git a/samples/endpoints/shell/mocks/handlers.ts b/samples/endpoints/shell/mocks/handlers.ts new file mode 100644 index 000000000..beecbb489 --- /dev/null +++ b/samples/endpoints/shell/mocks/handlers.ts @@ -0,0 +1,10 @@ +import type { RestHandler } from "msw"; +import { authenticationHandlers } from "./authenticationHandlers.ts"; +import { featureFlagHandlers } from "./featureFlagHandlers.ts"; +import { subscriptionHandlers } from "./subscriptionHandlers.ts"; + +export const requestHandlers: RestHandler[] = [ + ...authenticationHandlers, + ...featureFlagHandlers, + ...subscriptionHandlers +]; diff --git a/samples/endpoints/shell/package.json b/samples/endpoints/shell/package.json index 3edb35872..e598d7b91 100644 --- a/samples/endpoints/shell/package.json +++ b/samples/endpoints/shell/package.json @@ -14,9 +14,8 @@ } }, "scripts": { - "dev": "nodemon", - "dev-msw": "pnpm nodemon", - "build": "tsup --config ./tsup.build.ts", + "dev": "cross-env USE_MSW=true nodemon", + "build": "cross-env USE_MSW=true tsup --config ./tsup.build.ts", "serve-build": "pnpm build" }, "peerDependencies": { @@ -38,12 +37,14 @@ "@squide/msw": "workspace:*", "@squide/react-router": "workspace:*", "@squide/webpack-module-federation": "workspace:*", + "@types/node": "20.8.7", "@types/react": "18.2.28", "@types/react-dom": "18.2.13", "@workleap/eslint-plugin": "3.0.0", "@workleap/tsup-configs": "3.0.1", "@workleap/typescript-configs": "3.0.2", "axios": "1.5.1", + "cross-env": "7.0.3", "msw": "1.3.2", "nodemon": "3.0.1", "react": "18.2.0", diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 30d9c4eb1..895892961 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -1,7 +1,7 @@ -import { SubscriptionContext, TelemetryServiceContext, useTelemetryService, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; +import { FeatureFlagsContext, SubscriptionContext, TelemetryServiceContext, useTelemetryService, type FeatureFlags, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; import { useIsMswStarted } from "@squide/msw"; -import { useIsAuthenticated, useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; -import { useAreModulesReady } from "@squide/webpack-module-federation"; +import { completeLocalModuleRegistration, useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; +import { completeRemoteModuleRegistration, useAreModulesReady, useAreModulesRegistered } from "@squide/webpack-module-federation"; import axios from "axios"; import { useEffect, useMemo, useState } from "react"; import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; @@ -41,6 +41,26 @@ export function AppRouter() { */ +async function fetchPublicData( + setFeatureFlags: (featureFlags: FeatureFlags) => void, + logger: Logger +) { + const featureFlagsPromise = axios.get("/api/feature-flags") + .then(({ data }) => { + const featureFlags: FeatureFlags = { + featureA: data.featureA, + featureB: data.featureB, + featureC: data.featureC + }; + + logger.debug("[shell] %cFeature flags are ready%c:", "color: white; background-color: green;", "", featureFlags); + + setFeatureFlags(featureFlags); + }); + + return featureFlagsPromise; +} + async function fetchProtectedData( setSession: (session: Session) => void, setSubscription: (subscription: Subscription) => void, @@ -87,16 +107,19 @@ async function fetchProtectedData( interface RootRouteProps { waitForMsw: boolean; sessionManager: SessionManager; + areModulesRegistered: boolean; areModulesReady: boolean; } // Most of the bootstrapping logic has been moved to this component because AppRouter // cannot leverage "useLocation" since it's depend on "RouterProvider". -export function RootRoute({ waitForMsw, sessionManager, areModulesReady }: RootRouteProps) { +export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, areModulesReady }: RootRouteProps) { + const [isPublicDataLoaded, setIsPublicDataLoaded] = useState(false); const [isProtectedDataLoaded, setIsProtectedDataLoaded] = useState(false); // Could be done with a ref (https://react.dev/reference/react/useRef) to save a re-render but for this sample // it seemed unnecessary. If your application loads a lot of data at bootstrapping, it should be considered. + const [featureFlags, setFeatureFlags] = useState(); const [subscription, setSubscription] = useState(); const logger = useLogger(); @@ -107,21 +130,34 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesReady }: RootR const isMswStarted = useIsMswStarted(waitForMsw); const isActiveRouteProtected = useIsRouteMatchProtected(location); - const isAuthenticated = useIsAuthenticated(); useEffect(() => { - if (areModulesReady && !isMswStarted) { - logger.debug("[shell] Modules are ready, waiting for MSW to start."); + if ((areModulesRegistered || areModulesReady) && !isMswStarted) { + logger.debug(`[shell] Modules are ${areModulesReady ? "ready" : "registered"}, waiting for MSW to start...`); } - if (!areModulesReady && isMswStarted) { - logger.debug("[shell] MSW is started, waiting for the modules to be ready."); + if (!areModulesRegistered && !areModulesReady && isMswStarted) { + logger.debug("[shell] MSW is started, waiting for the modules..."); } + }, [logger, areModulesRegistered, areModulesReady, isMswStarted]); - if (areModulesReady && isMswStarted) { + useEffect(() => { + if ((areModulesRegistered || areModulesReady) && isMswStarted) { + if (!isPublicDataLoaded) { + logger.debug("[shell] Fetching public initial data."); + + fetchPublicData(setFeatureFlags, logger).finally(() => { + setIsPublicDataLoaded(true); + }); + } + } + }, [logger, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded]); + + useEffect(() => { + if ((areModulesRegistered || areModulesReady) && isMswStarted) { if (isActiveRouteProtected) { - if (!isAuthenticated) { - logger.debug(`[shell] Fetching protected data as "${location.pathname}" is a protected route.`); + if (!isProtectedDataLoaded) { + logger.debug(`[shell] Fetching protected initial data as "${location.pathname}" is a protected route.`); const setSession = (session: Session) => { sessionManager.setSession(session); @@ -132,23 +168,38 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesReady }: RootR }); } } else { - logger.debug(`[shell] Passing through as "${location.pathname}" is a public route.`); + logger.debug(`[shell] Not fetching protected initial data as "${location.pathname}" is a public route.`); } } - }, [logger, location, sessionManager, areModulesReady, isMswStarted, isActiveRouteProtected, isAuthenticated]); + }, [logger, location, sessionManager, areModulesRegistered, areModulesReady, isMswStarted, isActiveRouteProtected, isProtectedDataLoaded]); + + useEffect(() => { + if (areModulesRegistered && isMswStarted && isPublicDataLoaded) { + if (!areModulesReady) { + const data = { + featureFlags + }; + + completeLocalModuleRegistration(logger, data); + completeRemoteModuleRegistration(logger, data); + } + } + }, [logger, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); useEffect(() => { telemetryService?.track(`Navigated to the "${location.pathname}" page.`); }, [location, telemetryService]); - if (!areModulesReady || !isMswStarted || (isActiveRouteProtected && !isProtectedDataLoaded)) { + if (!areModulesReady || !isMswStarted || !isPublicDataLoaded || (isActiveRouteProtected && !isProtectedDataLoaded)) { return
Loading...
; } return ( - - - + + + + + ); } @@ -159,7 +210,10 @@ export interface AppRouterProps { } export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) { - // Re-render the app once all the remotes are registered, otherwise the remotes routes won't be added to the router. + // Re-render the app once all the remote modules are registered, otherwise the remote modules routes won't be added to the router. + const areModulesRegistered = useAreModulesRegistered(); + + // Re-render the app once all the remote modules are ready, otherwise the deferred remote modules routes won't be added to the router. const areModulesReady = useAreModulesReady(); const routes = useRoutes(); @@ -171,13 +225,14 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR ), children: routes } ]); - }, [areModulesReady, routes, waitForMsw, sessionManager]); + }, [areModulesRegistered, areModulesReady, routes, waitForMsw, sessionManager]); return ( diff --git a/samples/endpoints/shell/src/register.tsx b/samples/endpoints/shell/src/register.tsx index f0dcc5522..edee54a27 100644 --- a/samples/endpoints/shell/src/register.tsx +++ b/samples/endpoints/shell/src/register.tsx @@ -2,8 +2,6 @@ import type { SessionManager } from "@endpoints/shared"; import { getMswPlugin } from "@squide/msw"; import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import { ManagedRoutes } from "@squide/react-router"; -import { authenticationHandlers } from "../mocks/authenticationHandlers.ts"; -import { subscriptionHandlers } from "../mocks/subscriptionHandlers.ts"; import { RootErrorBoundary } from "./RootErrorBoundary.tsx"; import { RootLayout } from "./RootLayout.tsx"; @@ -100,20 +98,34 @@ function registerRoutes(runtime: Runtime, sessionManager: SessionManager, host?: }); } -function registerMsw(runtime: Runtime) { - const mswPlugin = getMswPlugin(runtime); +async function registerMsw(runtime: Runtime) { + if (process.env.USE_MSW) { + const mswPlugin = getMswPlugin(runtime); - mswPlugin.registerRequestHandlers([ - ...authenticationHandlers, - ...subscriptionHandlers - ]); + // Files including an import to the "msw" package are included dynamically to prevent adding + // MSW stuff to the bundled when it's not used. + const requestHandlers = (await import("../mocks/handlers.ts")).requestHandlers; + + mswPlugin.registerRequestHandlers(requestHandlers); + } } export function registerShell(sessionManager: SessionManager, { host }: RegisterShellOptions = {}) { - const register: ModuleRegisterFunction = runtime => { - registerRoutes(runtime, sessionManager, host); - registerMsw(runtime); + const register: ModuleRegisterFunction = async runtime => { + await registerMsw(runtime); + + return registerRoutes(runtime, sessionManager, host); }; return register; } + +// export function registerShell(sessionManager: SessionManager, { host }: RegisterShellOptions = {}) { +// const register: ModuleRegisterFunction = runtime => { +// registerRoutes(runtime, sessionManager, host); + +// return registerMsw(runtime); +// }; + +// return register; +// } From 8adb6250921fd5a57070564c9bde35ed7d3e0d9b Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Mon, 23 Oct 2023 11:57:19 -0400 Subject: [PATCH 07/22] Not using the promise anymore to start MSW --- docs/getting-started/learn-the-api.md | 16 ++++++++-------- docs/reference/msw/MswPlugin.md | 2 +- packages/msw/CHANGELOG.md | 8 ++++---- .../src/completeModuleRegistration.ts | 16 ++++++++++++++++ packages/webpack-module-federation/src/index.ts | 1 + samples/endpoints/host/src/bootstrap.tsx | 12 ++++++------ samples/endpoints/local-module/src/dev/index.tsx | 8 ++++---- .../endpoints/remote-module/src/dev/index.tsx | 8 ++++---- samples/endpoints/shell/src/AppRouter.tsx | 11 ++++------- 9 files changed, 48 insertions(+), 34 deletions(-) create mode 100644 packages/webpack-module-federation/src/completeModuleRegistration.ts diff --git a/docs/getting-started/learn-the-api.md b/docs/getting-started/learn-the-api.md index 2f57dd212..6f32b84b7 100644 --- a/docs/getting-started/learn-the-api.md +++ b/docs/getting-started/learn-the-api.md @@ -173,7 +173,7 @@ const runtime = new Runtime({ Then, [register the modules MSW request handlers](../reference/msw/MswPlugin.md#register-request-handlers) at registration: -```ts !#12 remote-module/src/register.tsx +```ts !#10,12 remote-module/src/register.tsx import { getMswPlugin } from "@squide/msw"; import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; @@ -196,7 +196,7 @@ Don't forget to mark the registration function as `async` since there's a dynami Then, [retrieve the modules MSW request handlers](../reference/msw/MswPlugin.md#retrieve-the-request-handlers) in the host application and start MSW: -```ts !#10,13 +```ts !#9,12 import { registerRemoteModules } from "@squide/webpack-module-federation"; import { setMswAsStarted } from "@squide/msw"; @@ -205,13 +205,13 @@ await registerRemoteModules(Remotes, runtime); if (process.env.USE_MSW) { // Files including an import to the "msw" package are included dynamically to prevent adding // MSW stuff to the bundled when it's not used. - import("../mocks/browser.ts").then(({ startMsw }) => { - // Will start MSW with the request handlers provided by every module. - startMsw(mswPlugin.requestHandlers); + const startMsw = (await import("../mocks/browser.ts")).startMsw; - // Indicate to resources that are dependent on MSW that the service has been started. - setMswAsStarted(); - }); + // Will start MSW with the request handlers provided by every module. + startMsw(mswPlugin.requestHandlers); + + // Indicate to resources that are dependent on MSW that the service has been started. + setMswAsStarted(); } ``` diff --git a/docs/reference/msw/MswPlugin.md b/docs/reference/msw/MswPlugin.md index 646dbdc26..13b2d59a4 100644 --- a/docs/reference/msw/MswPlugin.md +++ b/docs/reference/msw/MswPlugin.md @@ -93,7 +93,7 @@ const runtime = new Runtime({ ### Register request handlers -```ts !#3,10 +```ts !#3,8,10 import { getMswPlugin } from "@squide/msw"; if (process.env.USE_MSW) { diff --git a/packages/msw/CHANGELOG.md b/packages/msw/CHANGELOG.md index b326edecf..333e771b9 100644 --- a/packages/msw/CHANGELOG.md +++ b/packages/msw/CHANGELOG.md @@ -12,17 +12,17 @@ ```ts const mswPlugin = getMswPlugin(runtime); + mswPlugin.registerRequestHandlers(requestHandlers); ``` **In the host app:** ```ts - import("../mocks/browser.ts").then(({ startMsw }) => { - startMsw(mswPlugin.requestHandlers); + const startMsw = (await import("../mocks/browser.ts")).startMsw; - setMswAsStarted(); - }); + startMsw(mswPlugin.requestHandlers); + setMswAsStarted(); ``` And offer an utility to wait for MSW to be started before rendering the app: diff --git a/packages/webpack-module-federation/src/completeModuleRegistration.ts b/packages/webpack-module-federation/src/completeModuleRegistration.ts new file mode 100644 index 000000000..616281553 --- /dev/null +++ b/packages/webpack-module-federation/src/completeModuleRegistration.ts @@ -0,0 +1,16 @@ +import { completeLocalModuleRegistration, type LocalModuleRegistrationError, type Logger } from "@squide/core"; +import { completeRemoteModuleRegistration, type RemoteModuleRegistrationError } from "./registerRemoteModules.ts"; + +export function completeModuleRegistration(logger: Logger, data?: TData) { + const promise: Promise[] = [ + completeLocalModuleRegistration(logger, data), + completeRemoteModuleRegistration(logger, data) + ]; + + return Promise.allSettled(promise).then(([locaModulesErrors, remoteModulesErrors]) => { + return { + locaModulesErrors: locaModulesErrors as unknown as LocalModuleRegistrationError, + remoteModulesErrors: remoteModulesErrors as unknown as RemoteModuleRegistrationError + }; + }); +} diff --git a/packages/webpack-module-federation/src/index.ts b/packages/webpack-module-federation/src/index.ts index 791238cd5..e7f08068f 100644 --- a/packages/webpack-module-federation/src/index.ts +++ b/packages/webpack-module-federation/src/index.ts @@ -1,3 +1,4 @@ +export * from "./completeModuleRegistration.ts"; export * from "./loadRemote.ts"; export * from "./registerRemoteModules.ts"; export * from "./remoteDefinition.ts"; diff --git a/samples/endpoints/host/src/bootstrap.tsx b/samples/endpoints/host/src/bootstrap.tsx index ab5bffe04..c3c82a3e4 100644 --- a/samples/endpoints/host/src/bootstrap.tsx +++ b/samples/endpoints/host/src/bootstrap.tsx @@ -34,13 +34,13 @@ await registerRemoteModules(Remotes, runtime); if (process.env.USE_MSW) { // Files including an import to the "msw" package are included dynamically to prevent adding // MSW stuff to the bundled when it's not used. - import("../mocks/browser.ts").then(({ startMsw }) => { - // Will start MSW with the request handlers provided by every module. - startMsw(mswPlugin.requestHandlers); + const startMsw = (await import("../mocks/browser.ts")).startMsw; - // Indicate to resources that are dependent on MSW that the service has been started. - setMswAsStarted(); - }); + // Will start MSW with the request handlers provided by every module. + startMsw(mswPlugin.requestHandlers); + + // Indicate to resources that are dependent on MSW that the service has been started. + setMswAsStarted(); } const root = createRoot(document.getElementById("root")!); diff --git a/samples/endpoints/local-module/src/dev/index.tsx b/samples/endpoints/local-module/src/dev/index.tsx index 30c06f1cc..3a1495044 100644 --- a/samples/endpoints/local-module/src/dev/index.tsx +++ b/samples/endpoints/local-module/src/dev/index.tsx @@ -25,10 +25,10 @@ await registerLocalModules([registerShell(sessionManager), registerDev, register if (process.env.USE_MSW) { // Files including an import to the "msw" package are included dynamically to prevent adding // MSW stuff to the bundled when it's not used. - import("../../mocks/browser.ts").then(({ startMsw }) => { - startMsw(mswPlugin.requestHandlers); - setMswAsStarted(); - }); + const startMsw = (await import("../../mocks/browser.ts")).startMsw; + + startMsw(mswPlugin.requestHandlers); + setMswAsStarted(); } const root = createRoot(document.getElementById("root")!); diff --git a/samples/endpoints/remote-module/src/dev/index.tsx b/samples/endpoints/remote-module/src/dev/index.tsx index 97002f461..3d997fbdf 100644 --- a/samples/endpoints/remote-module/src/dev/index.tsx +++ b/samples/endpoints/remote-module/src/dev/index.tsx @@ -27,10 +27,10 @@ await registerLocalModules([registerShell(sessionManager), registerDev, register if (process.env.USE_MSW) { // Files including an import to the "msw" package are included dynamically to prevent adding // MSW stuff to the bundled when it's not used. - import("../../mocks/browser.ts").then(({ startMsw }) => { - startMsw(mswPlugin.requestHandlers); - setMswAsStarted(); - }); + const startMsw = (await import("../../mocks/browser.ts")).startMsw; + + startMsw(mswPlugin.requestHandlers); + setMswAsStarted(); } const root = createRoot(document.getElementById("root")!); diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 895892961..cca0fbbca 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -1,7 +1,7 @@ import { FeatureFlagsContext, SubscriptionContext, TelemetryServiceContext, useTelemetryService, type FeatureFlags, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; import { useIsMswStarted } from "@squide/msw"; -import { completeLocalModuleRegistration, useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; -import { completeRemoteModuleRegistration, useAreModulesReady, useAreModulesRegistered } from "@squide/webpack-module-federation"; +import { useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; +import { completeModuleRegistration, useAreModulesReady, useAreModulesRegistered } from "@squide/webpack-module-federation"; import axios from "axios"; import { useEffect, useMemo, useState } from "react"; import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; @@ -176,12 +176,9 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, ar useEffect(() => { if (areModulesRegistered && isMswStarted && isPublicDataLoaded) { if (!areModulesReady) { - const data = { + completeModuleRegistration(logger, { featureFlags - }; - - completeLocalModuleRegistration(logger, data); - completeRemoteModuleRegistration(logger, data); + }); } } }, [logger, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); From 4f0094ffa880355de9a6241b7247fce6ffe784ad Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Mon, 23 Oct 2023 23:28:05 -0400 Subject: [PATCH 08/22] Added tests for the modules registration functions --- packages/core/jest.config.ts | 10 + packages/core/package.json | 8 + .../src/federation/registerLocalModules.ts | 169 ++++---- .../core/src/federation/registerModule.ts | 4 +- packages/core/src/runtime/RuntimeContext.ts | 2 +- packages/core/src/runtime/abstractRuntime.ts | 2 +- packages/core/swc.jest.ts | 6 + .../completeLocalModuleRegistrations.test.ts | 179 +++++++++ .../core/tests/registerLocalModules.test.ts | 127 ++++++ packages/msw/src/index.ts | 1 + packages/msw/src/requestHandlerRegistry.ts | 5 + packages/msw/src/setMswAsStarted.ts | 9 + packages/msw/src/useIsMswReady.ts | 9 +- packages/react-router/src/runtime.ts | 7 +- .../webpack-module-federation/jest.config.ts | 5 + .../src/completeModuleRegistration.ts | 16 - .../src/completeModuleRegistrations.ts | 16 + .../webpack-module-federation/src/index.ts | 2 +- .../src/loadRemote.ts | 8 +- .../src/registerRemoteModules.ts | 221 ++++++----- .../completeRemoteModuleRegistrations.test.ts | 257 +++++++++++++ .../tests/registerRemoteModules.test.ts | 150 ++++++++ .../tests/useAreModulesReady.test.tsx | 362 +++++++++--------- pnpm-lock.yaml | 27 +- samples/endpoints/shell/src/AppRouter.tsx | 9 +- samples/endpoints/shell/src/register.tsx | 10 - 26 files changed, 1211 insertions(+), 410 deletions(-) create mode 100644 packages/core/jest.config.ts create mode 100644 packages/core/swc.jest.ts create mode 100644 packages/core/tests/completeLocalModuleRegistrations.test.ts create mode 100644 packages/core/tests/registerLocalModules.test.ts create mode 100644 packages/msw/src/setMswAsStarted.ts delete mode 100644 packages/webpack-module-federation/src/completeModuleRegistration.ts create mode 100644 packages/webpack-module-federation/src/completeModuleRegistrations.ts create mode 100644 packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts create mode 100644 packages/webpack-module-federation/tests/registerRemoteModules.test.ts diff --git a/packages/core/jest.config.ts b/packages/core/jest.config.ts new file mode 100644 index 000000000..a02a94f09 --- /dev/null +++ b/packages/core/jest.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "jest"; +import { swcConfig } from "./swc.jest.ts"; + +const config: Config = { + transform: { + "^.+\\.(js|ts)$": ["@swc/jest", swcConfig as Record] + } +}; + +export default config; diff --git a/packages/core/package.json b/packages/core/package.json index cfd08cee0..52ea70284 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,13 +35,21 @@ "react-dom": "*" }, "devDependencies": { + "@swc/core": "1.3.93", + "@swc/helpers": "0.5.3", + "@swc/jest": "0.2.29", + "@testing-library/react": "14.0.0", + "@types/jest": "29.5.5", "@types/react": "18.2.28", "@types/react-dom": "18.2.13", "@workleap/eslint-plugin": "3.0.0", + "@workleap/swc-configs": "2.1.2", "@workleap/tsup-configs": "3.0.1", "@workleap/typescript-configs": "3.0.2", + "jest": "29.7.0", "react": "18.2.0", "react-dom": "18.2.0", + "ts-jest": "29.1.1", "tsup": "7.2.0", "typescript": "5.2.2" }, diff --git a/packages/core/src/federation/registerLocalModules.ts b/packages/core/src/federation/registerLocalModules.ts index d25a8f8c6..7f00c431c 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -1,25 +1,13 @@ -import { isFunction, type Logger } from "../index.ts"; +import { isFunction } from "../index.ts"; import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; import type { ModuleRegistrationStatus } from "./moduleRegistrationStatus.ts"; -import { registerModule, type DeferedRegisterationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; +import { registerModule, type DeferredRegisterationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; -let registrationStatus: ModuleRegistrationStatus = "none"; - -export function getLocalModulesRegistrationStatus() { - return registrationStatus; -} - -export function resetLocalModulesRegistrationStatus() { - registrationStatus = "none"; -} - -interface DeferedRegisteration { +interface DeferredRegisteration { index: string; - fct: DeferedRegisterationFunction; + fct: DeferredRegisterationFunction; } -const deferedRegistrations: DeferedRegisteration[] = []; - export interface RegisterLocalModulesOptions { context?: TContext; } @@ -29,101 +17,110 @@ export interface LocalModuleRegistrationError { error: unknown; } -export async function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { - if (registrationStatus !== "none") { - throw new Error("[squide] [local] registerLocalModules() can only be called once."); - } +export class LocalModuleRegistry { + #registrationStatus: ModuleRegistrationStatus = "none"; - const errors: LocalModuleRegistrationError[] = []; + readonly #deferredRegistrations: DeferredRegisteration[] = []; - runtime.logger.information(`[squide] [local] Found ${registerFunctions.length} local module${registerFunctions.length !== 1 ? "s" : ""} to register.`); + async registerModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { + const errors: LocalModuleRegistrationError[] = []; - registrationStatus = "in-progress"; + if (this.#registrationStatus !== "none") { + throw new Error("[squide] [local] The registerLocalModules function can only be called once."); + } + + runtime.logger.debug(`[squide] [local] Found ${registerFunctions.length} local module${registerFunctions.length !== 1 ? "s" : ""} to register.`); + + this.#registrationStatus = "in-progress"; + + await Promise.allSettled(registerFunctions.map(async (x, index) => { + runtime.logger.debug(`[squide] [local] ${index + 1}/${registerFunctions.length} Registering local module.`); - await Promise.allSettled(registerFunctions.map(async (x, index) => { - runtime.logger.information(`[squide] [local] ${index + 1}/${registerFunctions.length} Registering local module.`); + try { + const optionalDeferedRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); - try { - const optionalDeferedRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); + if (isFunction(optionalDeferedRegistration)) { + this.#deferredRegistrations.push({ + index: `${index + 1}/${registerFunctions.length}`, + fct: optionalDeferedRegistration + }); + } + } catch (error: unknown) { + runtime.logger.error( + `[squide] [local] ${index + 1}/${registerFunctions.length} An error occured while registering a local module.`, + error + ); - if (isFunction(optionalDeferedRegistration)) { - deferedRegistrations.push({ - index: `${index + 1}/${registerFunctions.length}`, - fct: optionalDeferedRegistration + errors.push({ + error }); } - } catch (error: unknown) { - runtime.logger.error( - `[squide] [local] ${index + 1}/${registerFunctions.length} An error occured while registering a local module.`, - error - ); - - errors.push({ - error - }); - } - - runtime.logger.information(`[squide] [local] ${index + 1}/${registerFunctions.length} Local module registration completed.`); - })); - registrationStatus = deferedRegistrations.length > 0 ? "registered" : "ready"; + runtime.logger.debug(`[squide] [local] ${index + 1}/${registerFunctions.length} Local module registration completed.`); + })); - return errors; -} + this.#registrationStatus = this.#deferredRegistrations.length > 0 ? "registered" : "ready"; -export async function completeLocalModuleRegistration(logger: Logger, data?: TData) { - if (registrationStatus === "none" || registrationStatus === "in-progress") { - throw new Error("[squide] [local] completeLocalModuleRegistration() can only be called once registerLocalModules() terminated."); + return errors; } - if (registrationStatus !== "registered" && deferedRegistrations.length > 0) { - throw new Error("[squide] [local] completeLocalModuleRegistration() can only be called once."); - } + async completeRegistrations(runtime: TRuntime, data?: TData) { + const errors: LocalModuleRegistrationError[] = []; - if (registrationStatus === "ready") { - // No defered registrations were returned by the local modules, skip the completion process. - return Promise.resolve(); - } + if (this.#registrationStatus === "none" || this.#registrationStatus === "in-progress") { + throw new Error("[squide] [local] The completeLocalModuleRegistration function can only be called once the registerLocalModules function terminated."); + } - registrationStatus = "in-completion"; + if (this.#registrationStatus !== "registered" && this.#deferredRegistrations.length > 0) { + throw new Error("[squide] [local] The completeLocalModuleRegistration function can only be called once."); + } - const errors: LocalModuleRegistrationError[] = []; + if (this.#registrationStatus === "ready") { + // No defered registrations were returned by the local modules, skip the completion process. + return Promise.resolve(errors); + } - await Promise.allSettled(deferedRegistrations.map(({ index, fct }) => { - let optionalPromise; + this.#registrationStatus = "in-completion"; - logger.information(`[squide] [local] ${index} Completing local module deferred registration.`); + await Promise.allSettled(this.#deferredRegistrations.map(async ({ index, fct: deferredRegister }) => { + runtime.logger.debug(`[squide] [local] ${index} Completing local module deferred registration.`); - try { - optionalPromise = fct(data); - } catch (error: unknown) { - logger.error( - `[squide] [local] ${index} An error occured while completing the registration of a local module.`, - error - ); + try { + await deferredRegister(data); + } catch (error: unknown) { + runtime.logger.error( + `[squide] [local] ${index} An error occured while completing the registration of a local module.`, + error + ); - errors.push({ - error - }); - } + errors.push({ + error + }); + } - logger.information(`[squide] [local] ${index} Completed local module deferred registration.`); + runtime.logger.debug(`[squide] [local] ${index} Completed local module deferred registration.`); + })); - return optionalPromise; - })); + this.#registrationStatus = "ready"; - registrationStatus = "ready"; + return errors; + } - return errors; + get registrationStatus() { + return this.#registrationStatus; + } } +const localModuleRegistry = new LocalModuleRegistry(); -/* -Les states doivent être différent - - Ça prends un state pour dire qu'ils sont tous loader mais pas 100% completed - - Un autre state pour dire qu'ils le sont incluant les deferred +export function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, options?: RegisterLocalModulesOptions) { + return localModuleRegistry.registerModules(registerFunctions, runtime, options); +} - -> What if, je ne veux pas gosser avec les defered registrations et je n'en utilise pas?!?! - -> Je pourrais avoir une option initialement: { supportDeferredRegistration: boolean - false par défaut } - -> Probablement pas besoin, je vais juste regarder en fonction du nombre de deferredRegistrations -*/ +export function completeLocalModuleRegistrations(runtime: TRuntime, data?: TData) { + return localModuleRegistry.completeRegistrations(runtime, data); +} + +export function getLocalModulesRegistrationStatus() { + return localModuleRegistry.registrationStatus; +} diff --git a/packages/core/src/federation/registerModule.ts b/packages/core/src/federation/registerModule.ts index a7a17230e..29fdd830e 100644 --- a/packages/core/src/federation/registerModule.ts +++ b/packages/core/src/federation/registerModule.ts @@ -2,9 +2,9 @@ import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; // TODO: Alex, helppppp! // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DeferedRegisterationFunction = (data?: any) => Promise | void; +export type DeferredRegisterationFunction = (data?: any) => Promise | void; -export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | Promise | void; +export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | DeferredRegisterationFunction | Promise | void; export async function registerModule(register: ModuleRegisterFunction, runtime: AbstractRuntime, context?: unknown) { return register(runtime, context); diff --git a/packages/core/src/runtime/RuntimeContext.ts b/packages/core/src/runtime/RuntimeContext.ts index c2af1810e..2f4f12ddc 100644 --- a/packages/core/src/runtime/RuntimeContext.ts +++ b/packages/core/src/runtime/RuntimeContext.ts @@ -8,7 +8,7 @@ export function useRuntime() { const runtime = useContext(RuntimeContext); if (isNil(runtime)) { - throw new Error("[squide] useRuntime() is called before a Runtime instance has been provided."); + throw new Error("[squide] The useRuntime function is called before a Runtime instance has been provided."); } return runtime; diff --git a/packages/core/src/runtime/abstractRuntime.ts b/packages/core/src/runtime/abstractRuntime.ts index de597d306..d04dd1bed 100644 --- a/packages/core/src/runtime/abstractRuntime.ts +++ b/packages/core/src/runtime/abstractRuntime.ts @@ -95,7 +95,7 @@ export abstract class AbstractRuntime { + setTimeout(() => { + resolve(undefined); + }, delay); + }); +} + +class DummyRuntime extends AbstractRuntime { + registerRoute() { + throw new Error("Method not implemented."); + } + + get routes() { + return []; + } + + registerNavigationItem() { + throw new Error("Method not implemented."); + } + + getNavigationItems() { + return []; + } +} + +const runtime = new DummyRuntime(); + +test("when called before registerLocalModules, throw an error", async () => { + const registry = new LocalModuleRegistry(); + + await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeLocalModuleRegistration function can only be called once the registerLocalModules function terminated/); +}); + +test("when called twice, throw an error", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => () => {}, + () => () => {} + ], runtime); + + await registry.completeRegistrations(runtime); + + await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeLocalModuleRegistration function can only be called once/); +}); + +test("when called for the first time but the registration status is already \"ready\", return a resolving promise", async () => { + const registry = new LocalModuleRegistry(); + + // When there's no deferred modules, the status should be "ready". + await registry.registerModules([ + () => {}, + () => {} + ], runtime); + + expect(registry.registrationStatus).toBe("ready"); + + await registry.completeRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("can complete all the deferred modules registration", async () => { + const registry = new LocalModuleRegistry(); + + const register1 = jest.fn(); + const register2 = jest.fn(); + const register3 = jest.fn(); + + await registry.registerModules([ + () => register1, + () => register2, + () => register3 + ], runtime); + + await registry.completeRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register2).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when all the deferred modules are registered, set the status to \"ready\"", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => () => {}, + () => () => {} + ], runtime); + + expect(registry.registrationStatus).toBe("registered"); + + await registry.completeRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("when a deferred module is asynchronous, the function can be awaited", async () => { + const registry = new LocalModuleRegistry(); + + let hasBeenCompleted = false; + + await registry.registerModules([ + () => () => {}, + () => async () => { + await simulateDelay(10); + + hasBeenCompleted = true; + }, + () => () => {} + ], runtime); + + await registry.completeRegistrations(runtime); + + expect(hasBeenCompleted).toBeTruthy(); +}); + +test("when a deferred module registration fail, register the remaining deferred modules", async () => { + const registry = new LocalModuleRegistry(); + + const register1 = jest.fn(); + const register3 = jest.fn(); + + await registry.registerModules([ + () => register1, + () => () => { throw new Error("Module 2 registration failed"); }, + () => register3 + ], runtime); + + await registry.completeRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a deferred module registration fail, return the error", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => () => {}, + () => () => { throw new Error("Module 2 registration failed"); }, + () => () => {} + ], runtime); + + const errors = await registry.completeRegistrations(runtime); + + expect(errors.length).toBe(1); + expect(errors[0]!.error!.toString()).toContain("Module 2 registration failed"); +}); + +test("when data is provided, all the deferred module registrations receive the data object", async () => { + const registry = new LocalModuleRegistry(); + + const register1 = jest.fn(); + const register2 = jest.fn(); + const register3 = jest.fn(); + + await registry.registerModules([ + () => register1, + () => register2, + () => register3 + ], runtime); + + const data = { + foo: "bar" + }; + + await registry.completeRegistrations(runtime, data); + + expect(register1).toHaveBeenCalledWith(data); + expect(register2).toHaveBeenCalledWith(data); + expect(register3).toHaveBeenCalledWith(data); +}); + + diff --git a/packages/core/tests/registerLocalModules.test.ts b/packages/core/tests/registerLocalModules.test.ts new file mode 100644 index 000000000..c4ad53e90 --- /dev/null +++ b/packages/core/tests/registerLocalModules.test.ts @@ -0,0 +1,127 @@ +import { LocalModuleRegistry } from "../src/federation/registerLocalModules.ts"; +import { AbstractRuntime } from "../src/runtime/abstractRuntime.ts"; + +function simulateDelay(delay: number) { + return new Promise(resolve => { + setTimeout(() => { + resolve(undefined); + }, delay); + }); +} + +class DummyRuntime extends AbstractRuntime { + registerRoute() { + throw new Error("Method not implemented."); + } + + get routes() { + return []; + } + + registerNavigationItem() { + throw new Error("Method not implemented."); + } + + getNavigationItems() { + return []; + } +} + +const runtime = new DummyRuntime(); + +test("can register all the modules", async () => { + const registry = new LocalModuleRegistry(); + + const register1 = jest.fn(); + const register2 = jest.fn(); + const register3 = jest.fn(); + + await registry.registerModules([ + register1, + register2, + register3 + ], runtime); + + expect(register1).toHaveBeenCalled(); + expect(register2).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a module is asynchronous, the function can be awaited", async () => { + const registry = new LocalModuleRegistry(); + + let hasBeenCompleted = false; + + await registry.registerModules([ + () => {}, + async () => { + await simulateDelay(10); + + hasBeenCompleted = true; + }, + () => {} + ], runtime); + + expect(hasBeenCompleted).toBeTruthy(); +}); + +test("when called twice, throw an error", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([() => {}], runtime); + + await expect(async () => registry.registerModules([() => {}], runtime)).rejects.toThrow(/The registerLocalModules function can only be called once/); +}); + +test("when there are no deferred modules, once all the modules are registered, set the status to \"ready\"", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => {}, + () => {} + ], runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("when there are deferred modules, once all the modules are registered, set the status to \"registered\"", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => {}, + () => () => {} + ], runtime); + + expect(registry.registrationStatus).toBe("registered"); +}); + +test("when a module registration fail, register the remaining modules", async () => { + const registry = new LocalModuleRegistry(); + + const register1 = jest.fn(); + const register3 = jest.fn(); + + await registry.registerModules([ + register1, + () => { throw new Error("Module 2 registration failed"); }, + register3 + ], runtime); + + expect(register1).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a module registration fail, return the error", async () => { + const registry = new LocalModuleRegistry(); + + const errors = await registry.registerModules([ + () => {}, + () => { throw new Error("Module 2 registration failed"); }, + () => {} + ], runtime); + + expect(errors.length).toBe(1); + expect(errors[0]!.error!.toString()).toContain("Module 2 registration failed"); +}); + + diff --git a/packages/msw/src/index.ts b/packages/msw/src/index.ts index 00bf92fcb..58a11b616 100644 --- a/packages/msw/src/index.ts +++ b/packages/msw/src/index.ts @@ -1,4 +1,5 @@ export * from "./mswPlugin.ts"; export * from "./requestHandlerRegistry.ts"; +export * from "./setMswAsStarted.ts"; export * from "./useIsMswReady.ts"; diff --git a/packages/msw/src/requestHandlerRegistry.ts b/packages/msw/src/requestHandlerRegistry.ts index a302ac27a..6a1b0e7cc 100644 --- a/packages/msw/src/requestHandlerRegistry.ts +++ b/packages/msw/src/requestHandlerRegistry.ts @@ -1,9 +1,14 @@ import type { RestHandler } from "msw"; +import { isMswStarted } from "./setMswAsStarted.ts"; export class RequestHandlerRegistry { readonly #handlers: RestHandler[] = []; add(handlers: RestHandler[]) { + if (isMswStarted()) { + throw new Error("[squide] MSW request handlers cannot be registered once MSW is started. Did you defer the registration of a MSW request handler?"); + } + this.#handlers.push(...handlers); } diff --git a/packages/msw/src/setMswAsStarted.ts b/packages/msw/src/setMswAsStarted.ts new file mode 100644 index 000000000..50ebe011a --- /dev/null +++ b/packages/msw/src/setMswAsStarted.ts @@ -0,0 +1,9 @@ +let isStarted = false; + +export function setMswAsStarted() { + isStarted = true; +} + +export function isMswStarted() { + return isStarted; +} diff --git a/packages/msw/src/useIsMswReady.ts b/packages/msw/src/useIsMswReady.ts index afe4a3565..7540a9c72 100644 --- a/packages/msw/src/useIsMswReady.ts +++ b/packages/msw/src/useIsMswReady.ts @@ -1,11 +1,6 @@ import { useLogger } from "@squide/core"; import { useEffect, useState } from "react"; - -let isMswStarted = false; - -export function setMswAsStarted() { - isMswStarted = true; -} +import { isMswStarted } from "./setMswAsStarted.ts"; export interface UseIsMswStartedOptions { // The interval is in milliseconds. @@ -22,7 +17,7 @@ export function useIsMswStarted(enabled: boolean, { interval = 10 }: UseIsMswSta useEffect(() => { if (enabled) { const intervalId = setInterval(() => { - if (isMswStarted) { + if (isMswStarted()) { logger.debug("[squide] %cMSW is ready%c.", "color: white; background-color: green;", ""); clearInterval(intervalId); diff --git a/packages/react-router/src/runtime.ts b/packages/react-router/src/runtime.ts index ddba04cb6..bc94b7f84 100644 --- a/packages/react-router/src/runtime.ts +++ b/packages/react-router/src/runtime.ts @@ -64,7 +64,7 @@ export class Runtime extends AbstractRuntime { if (pendingRegistrations.size > 0) { if (pendingRegistrations.has(ManagedRoutes.$name!)) { // eslint-disable-next-line max-len - throw new Error("[squide] The \"ManagedRoutes\" outlet route is missing from the router configuration. The \"ManagedRoutes\" outlet route must be added as a children of an hoisted route. Did you forget to include the \"ManagedRoutes\" outlet route or hoist the parent route that includes the \"ManagedRoutes\" outlet route?"); + throw new Error("[squide] The ManagedRoutes placeholder is missing from the router configuration. The ManagedRoutes placeholder must be defined as a children of an hoisted route. Did you include a ManagedRoutes placeholder and hoist the ManagedRoutes placeholder's parent route?"); } let message = `[squide] ${pendingRegistrations.size} parent route${pendingRegistrations.size !== 1 ? "s" : ""} were expected to be registered but ${pendingRegistrations.size !== 0 ? "are" : "is"} missing:\r\n\r\n`; @@ -86,8 +86,9 @@ export class Runtime extends AbstractRuntime { message += `If you are certain that the parent route${pendingRegistrations.size !== 1 ? "s" : ""} has been registered, make sure that the following conditions are met:\r\n`; message += "- The missing parent routes \"path\" or \"name\" property perfectly match the provided \"parentPath\" or \"parentName\" (make sure that there's no leading or trailing \"/\" that differs).\r\n"; - message += "- The missing parent routes has been registered with the \"registerRoute()\" function. A route cannot be registered under a parent route that has not be registered with the \"registerRoute()\" function.\r\n"; - message += "For more information about nested routes, refers to https://gsoft-inc.github.io/wl-squide/reference/runtime/runtime-class/#register-routes-under-a-specific-nested-layout-route."; + message += "- The missing parent routes has been registered with the runtime.registerRoute function. A route cannot be registered under a parent route that has not be registered with the runtime.registerRoute function.\r\n"; + message += "For more information about nested routes, refers to https://gsoft-inc.github.io/wl-squide/reference/runtime/runtime-class/#register-nested-routes-under-an-existing-route.\r\n"; + message += "For more information about the ManagedRoutes placeholder, refers to https://gsoft-inc.github.io/wl-squide/reference/routing/managedroutes."; if (this._mode === "development") { throw new Error(message); diff --git a/packages/webpack-module-federation/jest.config.ts b/packages/webpack-module-federation/jest.config.ts index ecc6a2f7a..85c21ac72 100644 --- a/packages/webpack-module-federation/jest.config.ts +++ b/packages/webpack-module-federation/jest.config.ts @@ -24,6 +24,11 @@ const config: Config = { ...pathsToModuleNameMapper(compilerOptions.paths, { prefix: "" }) + }, + globals: { + __webpack_share_scopes__: { + default: {} + } } }; diff --git a/packages/webpack-module-federation/src/completeModuleRegistration.ts b/packages/webpack-module-federation/src/completeModuleRegistration.ts deleted file mode 100644 index 616281553..000000000 --- a/packages/webpack-module-federation/src/completeModuleRegistration.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { completeLocalModuleRegistration, type LocalModuleRegistrationError, type Logger } from "@squide/core"; -import { completeRemoteModuleRegistration, type RemoteModuleRegistrationError } from "./registerRemoteModules.ts"; - -export function completeModuleRegistration(logger: Logger, data?: TData) { - const promise: Promise[] = [ - completeLocalModuleRegistration(logger, data), - completeRemoteModuleRegistration(logger, data) - ]; - - return Promise.allSettled(promise).then(([locaModulesErrors, remoteModulesErrors]) => { - return { - locaModulesErrors: locaModulesErrors as unknown as LocalModuleRegistrationError, - remoteModulesErrors: remoteModulesErrors as unknown as RemoteModuleRegistrationError - }; - }); -} diff --git a/packages/webpack-module-federation/src/completeModuleRegistrations.ts b/packages/webpack-module-federation/src/completeModuleRegistrations.ts new file mode 100644 index 000000000..62a0cdccf --- /dev/null +++ b/packages/webpack-module-federation/src/completeModuleRegistrations.ts @@ -0,0 +1,16 @@ +import { completeLocalModuleRegistrations, type AbstractRuntime, type LocalModuleRegistrationError } from "@squide/core"; +import { completeRemoteModuleRegistrations, type RemoteModuleRegistrationError } from "./registerRemoteModules.ts"; + +export function completeModuleRegistrations(runtime: TRuntime, data?: TData) { + const promise: Promise[] = [ + completeLocalModuleRegistrations(runtime, data), + completeRemoteModuleRegistrations(runtime, data) + ]; + + return Promise.allSettled(promise).then(([locaModuleErrors, remoteModuleErrors]) => { + return { + locaModuleErrors: locaModuleErrors as unknown as LocalModuleRegistrationError, + remoteModuleErrors: remoteModuleErrors as unknown as RemoteModuleRegistrationError + }; + }); +} diff --git a/packages/webpack-module-federation/src/index.ts b/packages/webpack-module-federation/src/index.ts index e7f08068f..6753a2290 100644 --- a/packages/webpack-module-federation/src/index.ts +++ b/packages/webpack-module-federation/src/index.ts @@ -1,4 +1,4 @@ -export * from "./completeModuleRegistration.ts"; +export * from "./completeModuleRegistrations.ts"; export * from "./loadRemote.ts"; export * from "./registerRemoteModules.ts"; export * from "./remoteDefinition.ts"; diff --git a/packages/webpack-module-federation/src/loadRemote.ts b/packages/webpack-module-federation/src/loadRemote.ts index 948072e38..0aaf556e2 100644 --- a/packages/webpack-module-federation/src/loadRemote.ts +++ b/packages/webpack-module-federation/src/loadRemote.ts @@ -64,9 +64,13 @@ function loadRemoteScript(url: string, { timeoutDelay = 2000 }: LoadRemoteScript export type LoadRemoteOptions = LoadRemoteScriptOptions; +// TBD: Alex helpppp +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type LoadRemoteFunction = (url: string, containerName: string, moduleName: string, options?: LoadRemoteOptions) => Promise; + // Implementation of https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers. // It's done this way rather than using the managed mecanism provided with ModuleFederationPlugin config because it's doesn't throw an error if a module is not available. -export async function loadRemote(url: string, containerName: string, moduleName: string, options: LoadRemoteOptions = {}) { +export const loadRemote: LoadRemoteFunction = async (url: string, containerName: string, moduleName: string, options: LoadRemoteOptions = {}) => { await loadRemoteScript(url, options); // Initializes the share scope. It fills the scope with known provided modules from this build and all remotes. @@ -91,4 +95,4 @@ export async function loadRemote(url: string, containerName: string, moduleName: } return factory(); -} +}; diff --git a/packages/webpack-module-federation/src/registerRemoteModules.ts b/packages/webpack-module-federation/src/registerRemoteModules.ts index c62a30442..2c3dc8cc2 100644 --- a/packages/webpack-module-federation/src/registerRemoteModules.ts +++ b/packages/webpack-module-federation/src/registerRemoteModules.ts @@ -1,29 +1,16 @@ -import { isFunction, isNil, registerModule, type AbstractRuntime, type DeferedRegisterationFunction, type Logger, type ModuleRegistrationStatus } from "@squide/core"; -import { loadRemote } from "./loadRemote.ts"; +import { isFunction, isNil, registerModule, type AbstractRuntime, type DeferredRegisterationFunction, type Logger, type ModuleRegistrationStatus } from "@squide/core"; +import { loadRemote as loadModuleFederationRemote, type LoadRemoteFunction } from "./loadRemote.ts"; import { RemoteEntryPoint, RemoteModuleName, type RemoteDefinition } from "./remoteDefinition.ts"; -let registrationStatus: ModuleRegistrationStatus = "none"; - -export function getRemoteModulesRegistrationStatus() { - return registrationStatus; -} - -// Added to facilitate the unit tests. -export function resetRemoteModulesRegistrationStatus() { - registrationStatus = "none"; -} - -interface DeferedRegistration { +interface DeferredRegistration { url: string; containerName: string; index: string; - fct: DeferedRegisterationFunction; + fct: DeferredRegisterationFunction; } -const deferedRegistrations: DeferedRegistration[] = []; - -export interface RegisterRemoteModulesOptions { - context?: unknown; +export interface RegisterRemoteModulesOptions { + context?: TContext; } export interface RemoteModuleRegistrationError { @@ -37,113 +24,157 @@ export interface RemoteModuleRegistrationError { error: unknown; } -export async function registerRemoteModules(remotes: RemoteDefinition[], runtime: AbstractRuntime, { context }: RegisterRemoteModulesOptions = {}) { - if (registrationStatus !== "none") { - throw new Error("[squide] [remote] registerRemoteModules() can only be called once."); +export class RemoteModuleRegistry { + #registrationStatus: ModuleRegistrationStatus = "none"; + + readonly #deferredRegistrations: DeferredRegistration[] = []; + readonly #loadRemote: LoadRemoteFunction; + + constructor(loadRemote: LoadRemoteFunction) { + this.#loadRemote = loadRemote; + } + + #logSharedScope(logger: Logger) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (__webpack_share_scopes__) { + logger.debug( + "[squide] [remote] Module Federation shared scope is available:", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + __webpack_share_scopes__.default + ); + } } - const errors: RemoteModuleRegistrationError[] = []; + async registerModules(remotes: RemoteDefinition[], runtime: TRuntime, { context }: RegisterRemoteModulesOptions = {}) { + const errors: RemoteModuleRegistrationError[] = []; - runtime.logger.information(`[squide] [remote] Found ${remotes.length} remote module${remotes.length !== 1 ? "s" : ""} to register.`); + if (this.#registrationStatus !== "none") { + throw new Error("[squide] [remote] The registerRemoteModules function can only be called once."); + } - registrationStatus = "in-progress"; + runtime.logger.debug(`[squide] [remote] Found ${remotes.length} remote module${remotes.length !== 1 ? "s" : ""} to register.`); - await Promise.allSettled(remotes.map(async (x, index) => { - let remoteUrl; + this.#registrationStatus = "in-progress"; - const containerName = x.name; + await Promise.allSettled(remotes.map(async (x, index) => { + let remoteUrl; - try { - // Is included in the try/catch becase the URL could be invalid and cause an error. - remoteUrl = new URL(RemoteEntryPoint, x.url).toString(); + const containerName = x.name; - runtime.logger.information(`[squide] [remote] ${index + 1}/${remotes.length} Loading module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); + try { + // Is included in the try/catch becase the URL could be invalid and cause an error. + remoteUrl = new URL(RemoteEntryPoint, x.url).toString(); - const module = await loadRemote(remoteUrl, containerName, RemoteModuleName); + runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Loading module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - if (isNil(module.register)) { - throw new Error(`[squide] [remote] A "register" function is not available for module "${RemoteModuleName}" of container "${containerName}" from remote "${remoteUrl}". Make sure your remote "./register.js" file export a function named "register".`); - } + const module = await this.#loadRemote(remoteUrl, containerName, RemoteModuleName); - runtime.logger.information(`[squide] [remote] ${index + 1}/${remotes.length} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); + if (isNil(module.register)) { + throw new Error(`[squide] [remote] A "register" function is not available for module "${RemoteModuleName}" of container "${containerName}" from remote "${remoteUrl}". Make sure your remote "./register.js" file export a function named "register".`); + } - const optionalDeferedRegistration = await registerModule(module.register, runtime, context); + runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - if (isFunction(optionalDeferedRegistration)) { - deferedRegistrations.push({ - url: remoteUrl, - containerName: x.name, - index: `${index + 1}/${remotes.length}`, - fct: optionalDeferedRegistration + const optionalDeferedRegistration = await registerModule(module.register, runtime, context); + + if (isFunction(optionalDeferedRegistration)) { + this.#deferredRegistrations.push({ + url: remoteUrl, + containerName: x.name, + index: `${index + 1}/${remotes.length}`, + fct: optionalDeferedRegistration + }); + } + + runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Container "${containerName}" of remote "${remoteUrl}" registration completed.`); + } catch (error: unknown) { + runtime.logger.error( + `[squide] [remote] ${index + 1}/${remotes.length} An error occured while registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`, + error + ); + + errors.push({ + url: remoteUrl ?? `Partial URL is: "${x.url}"`, + containerName, + moduleName: RemoteModuleName, + error }); } + })); - runtime.logger.information(`[squide] [remote] ${index + 1}/${remotes.length} Container "${containerName}" of remote "${remoteUrl}" registration completed.`); - } catch (error: unknown) { - runtime.logger.error( - `[squide] [remote] ${index + 1}/${remotes.length} An error occured while registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`, - error - ); + this.#registrationStatus = this.#deferredRegistrations.length > 0 ? "registered" : "ready"; + + if (this.#registrationStatus === "ready") { + this.#logSharedScope(runtime.logger); + } + + return errors; + } - errors.push({ - url: remoteUrl ?? `Partial URL is: "${x.url}"`, - containerName, - moduleName: RemoteModuleName, - error - }); + async completeRegistrations(runtime: TRuntime, data?: TData) { + const errors: RemoteModuleRegistrationError[] = []; + + if (this.#registrationStatus === "none" || this.#registrationStatus === "in-progress") { + throw new Error("[squide] [remote] The completeRemoteModuleRegistration function can only be called once the registerRemoteModules function terminated."); } - })); - registrationStatus = deferedRegistrations.length > 0 ? "registered" : "ready"; + if (this.#registrationStatus !== "registered" && this.#deferredRegistrations.length > 0) { + throw new Error("[squide] [remote] The completeRemoteModuleRegistration function can only be called once."); + } - return errors; -} + if (this.#registrationStatus === "ready") { + // No defered registrations were returned by the remote modules, skip the completion process. + return Promise.resolve(errors); + } -export async function completeRemoteModuleRegistration(logger: Logger, data?: TData) { - if (registrationStatus === "none" || registrationStatus === "in-progress") { - throw new Error("[squide] [remote] completeRemoteModuleRegistration() can only be called once registerRemoteModules() terminated."); - } + this.#registrationStatus = "in-completion"; - if (registrationStatus !== "registered" && deferedRegistrations.length > 0) { - throw new Error("[squide] [remote] completeRemoteModuleRegistration() can only be called once."); - } + await Promise.allSettled(this.#deferredRegistrations.map(async ({ url, containerName, index, fct: deferredRegister }) => { + runtime.logger.debug(`[squide] [remote] ${index} Completing registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); - if (registrationStatus === "ready") { - // No defered registrations were returned by the remote modules, skip the completion process. - return Promise.resolve(); - } + try { + await deferredRegister(data); + } catch (error: unknown) { + runtime.logger.error( + `[squide] [remote] ${index} An error occured while completing the registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`, + error + ); - registrationStatus = "in-completion"; + errors.push({ + url, + containerName, + moduleName: RemoteModuleName, + error + }); + } - const errors: RemoteModuleRegistrationError[] = []; + runtime.logger.debug(`[squide] [remote] ${index} Completed registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); + })); - await Promise.allSettled(deferedRegistrations.map(({ url, containerName, index, fct }) => { - let optionalPromise; + this.#registrationStatus = "ready"; - logger.information(`[squide] [remote] ${index} Completing registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); + this.#logSharedScope(runtime.logger); - try { - optionalPromise = fct(data); - } catch (error: unknown) { - logger.error( - `[squide] [remote] ${index} An error occured while completing the registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`, - error - ); + return errors; + } - errors.push({ - url, - containerName, - moduleName: RemoteModuleName, - error - }); - } + get registrationStatus() { + return this.#registrationStatus; + } +} - logger.information(`[squide] [remote] ${index} Completed registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); +const remoteModuleRegistry = new RemoteModuleRegistry(loadModuleFederationRemote); - return optionalPromise; - })); +export function registerRemoteModules(remotes: RemoteDefinition[], runtime: TRuntime, options?: RegisterRemoteModulesOptions) { + return remoteModuleRegistry.registerModules(remotes, runtime, options); +} - registrationStatus = "ready"; +export function completeRemoteModuleRegistrations(runtime: TRuntime, data?: TData) { + return remoteModuleRegistry.completeRegistrations(runtime, data); +} - return errors; +export function getRemoteModulesRegistrationStatus() { + return remoteModuleRegistry.registrationStatus; } diff --git a/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts b/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts new file mode 100644 index 000000000..993b3f45f --- /dev/null +++ b/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts @@ -0,0 +1,257 @@ +import { AbstractRuntime } from "@squide/core"; +import { RemoteModuleRegistry } from "../src/registerRemoteModules.ts"; + +function simulateDelay(delay: number) { + return new Promise(resolve => { + setTimeout(() => { + resolve(undefined); + }, delay); + }); +} + +class DummyRuntime extends AbstractRuntime { + registerRoute() { + throw new Error("Method not implemented."); + } + + get routes() { + return []; + } + + registerNavigationItem() { + throw new Error("Method not implemented."); + } + + getNavigationItems() { + return []; + } +} + +const runtime = new DummyRuntime(); + +test("when called before registerRemoteModules, throw an error", async () => { + const registry = new RemoteModuleRegistry(jest.fn()); + + await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeRemoteModuleRegistration function can only be called once the registerRemoteModules function terminated/); +}); + +test("when called twice, throw an error", async () => { + const loadRemote = jest.fn().mockResolvedValue({ + register: () => () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" } + ], runtime); + + await registry.completeRegistrations(runtime); + + await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeRemoteModuleRegistration function can only be called once/); +}); + +test("when called for the first time but the registration status is already \"ready\", return a resolving promise", async () => { + // When there's no deferred modules, the status should be "ready". + const loadRemote = jest.fn().mockResolvedValue({ + register: () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" } + ], runtime); + + expect(registry.registrationStatus).toBe("ready"); + + await registry.completeRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("can complete all the deferred modules registration", async () => { + const register1 = jest.fn(); + const register2 = jest.fn(); + const register3 = jest.fn(); + + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => register1 + }) + .mockResolvedValueOnce({ + register: () => register2 + }) + .mockResolvedValueOnce({ + register: () => register3 + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await registry.completeRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register2).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when all the deferred modules are registered, set the status to \"ready\"", async () => { + const loadRemote = jest.fn().mockResolvedValue({ + register: () => () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(registry.registrationStatus).toBe("registered"); + + await registry.completeRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("when a deferred module is asynchronous, the function can be awaited", async () => { + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => () => {} + }) + .mockResolvedValueOnce({ + register: () => async () => { + await simulateDelay(10); + + hasBeenCompleted = true; + } + }) + .mockResolvedValueOnce({ + register: () => () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + let hasBeenCompleted = false; + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await registry.completeRegistrations(runtime); + + expect(hasBeenCompleted).toBeTruthy(); +}); + +test("when a deferred module registration fail, register the remaining deferred modules", async () => { + const register1 = jest.fn(); + const register3 = jest.fn(); + + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => register1 + }) + .mockResolvedValueOnce({ + register: () => () => { throw new Error("Module 2 registration failed"); } + }) + .mockResolvedValueOnce({ + register: () => register3 + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await registry.completeRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a deferred module registration fail, return the error", async () => { + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => () => {} + }) + .mockResolvedValueOnce({ + register: () => () => { throw new Error("Module 2 registration failed"); } + }) + .mockResolvedValueOnce({ + register: () => () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + const errors = await registry.completeRegistrations(runtime); + + expect(errors.length).toBe(1); + expect(errors[0]!.error!.toString()).toContain("Module 2 registration failed"); +}); + +test("when data is provided, all the deferred module registrations receive the data object", async () => { + const register1 = jest.fn(); + const register2 = jest.fn(); + const register3 = jest.fn(); + + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => register1 + }) + .mockResolvedValueOnce({ + register: () => register2 + }) + .mockResolvedValueOnce({ + register: () => register3 + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + const data = { + foo: "bar" + }; + + await registry.completeRegistrations(runtime, data); + + expect(register1).toHaveBeenCalledWith(data); + expect(register2).toHaveBeenCalledWith(data); + expect(register3).toHaveBeenCalledWith(data); +}); + + diff --git a/packages/webpack-module-federation/tests/registerRemoteModules.test.ts b/packages/webpack-module-federation/tests/registerRemoteModules.test.ts new file mode 100644 index 000000000..ad61c5a23 --- /dev/null +++ b/packages/webpack-module-federation/tests/registerRemoteModules.test.ts @@ -0,0 +1,150 @@ +import { AbstractRuntime } from "@squide/core"; +import { RemoteModuleRegistry } from "../src/registerRemoteModules.ts"; + +class DummyRuntime extends AbstractRuntime { + registerRoute() { + throw new Error("Method not implemented."); + } + + get routes() { + return []; + } + + registerNavigationItem() { + throw new Error("Method not implemented."); + } + + getNavigationItems() { + return []; + } +} + +const runtime = new DummyRuntime(); + +test("can register all the modules", async () => { + const register1 = jest.fn(); + const register2 = jest.fn(); + const register3 = jest.fn(); + + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: register1 + }) + .mockResolvedValueOnce({ + register: register2 + }) + .mockResolvedValueOnce({ + register: register3 + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(register1).toHaveBeenCalled(); + expect(register2).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when called twice, throw an error", async () => { + const loadRemote = jest.fn().mockResolvedValue({ + register: () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([{ name: "Dummy-1", url: "http://anything1.com" }], runtime); + + await expect(async () => registry.registerModules([{ name: "Dummy-1", url: "http://anything1.com" }], runtime)).rejects.toThrow(/The registerRemoteModules function can only be called once/); +}); + +test("when there are no deferred modules, once all the modules are registered, set the status to \"ready\"", async () => { + const loadRemote = jest.fn().mockResolvedValue({ + register: () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" } + ], runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("when there are deferred modules, once all the modules are registered, set the status to \"registered\"", async () => { + const loadRemote = jest.fn().mockResolvedValue({ + register: () => () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" } + ], runtime); + + expect(registry.registrationStatus).toBe("registered"); +}); + +test("when a module registration fail, register the remaining modules", async () => { + const register1 = jest.fn(); + const register3 = jest.fn(); + + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: register1 + }) + .mockResolvedValueOnce({ + register: () => { throw new Error("Module 2 registration failed"); } + }) + .mockResolvedValueOnce({ + register: register3 + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(register1).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a module registration fail, return the error", async () => { + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => {} + }) + .mockResolvedValueOnce({ + register: () => { throw new Error("Module 2 registration failed"); } + }) + .mockResolvedValueOnce({ + register: () => {} + }); + + const registry = new RemoteModuleRegistry(loadRemote); + + const errors = await registry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(errors.length).toBe(1); + expect(errors[0]!.error!.toString()).toContain("Module 2 registration failed"); +}); diff --git a/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx b/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx index c59198625..f71645655 100644 --- a/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx +++ b/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx @@ -1,197 +1,197 @@ -import { AbstractRuntime, RuntimeContext, registerLocalModules, resetLocalModulesRegistrationStatus } from "@squide/core"; -import { act, renderHook } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { loadRemote } from "../src/loadRemote.ts"; -import { registerRemoteModules, resetRemoteModulesRegistrationStatus } from "../src/registerRemoteModules.ts"; -import { useAreModulesReady } from "../src/useAreModulesReady.ts"; - -// The mock implementation is defined directly in the tests. -jest.mock("../src/loadRemote.ts"); - -// The interval at which the hook will perform a check to determine if the modules are ready. -const CheckInterval = 10; - -class DummyRuntime extends AbstractRuntime { - registerRoute() { - throw new Error("Method not implemented."); - } - - get routes() { - return []; - } - - registerNavigationItem() { - throw new Error("Method not implemented."); - } - - getNavigationItems() { - return []; - } -} - -function renderWithRuntime(runtime: AbstractRuntime) { - return renderHook(() => useAreModulesReady({ interval: CheckInterval }), { - wrapper: ({ children }: { children?: ReactNode }) => ( - - {children} - - ) - }); -} - -beforeEach(() => { - // Since the module registration status variables are singletons, - // they are not reseted between the tests. - resetLocalModulesRegistrationStatus(); - resetRemoteModulesRegistrationStatus(); - - // Typing a mocked imported function is too complicated. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if (loadRemote.mockClear) { - // Typing a mocked imported function is too complicated. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - loadRemote.mockClear(); - } - - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.useRealTimers(); -}); - -test("when only local modules are registered, return true when all the local modules are registered", async () => { - const runtime = new DummyRuntime(); - - await registerLocalModules([ - () => {}, - () => {}, - () => {} - ], runtime); - - const { result } = renderWithRuntime(runtime); - - expect(result.current).toBeFalsy(); - - // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 - act(() => { - jest.advanceTimersByTime(CheckInterval + 1); - }); - - expect(result.current).toBeTruthy(); -}); - -test("when only remote modules are registered, return true when all the remote modules are registered", async () => { - // Typing a mocked imported function is too complicated. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - loadRemote.mockResolvedValue({ - register: jest.fn() - }); - - const runtime = new DummyRuntime(); - - await registerRemoteModules([ - { name: "Dummy-1", url: "http://anything1.com" }, - { name: "Dummy-2", url: "http://anything2.com" }, - { name: "Dummy-3", url: "http://anything3.com" } - ], runtime); - - const { result } = renderWithRuntime(runtime); - - expect(result.current).toBeFalsy(); - - // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 - act(() => { - jest.advanceTimersByTime(CheckInterval + 1); - }); - - expect(result.current).toBeTruthy(); -}); - -test("when local and remote modules are registered, return true when all the remote modules are registered", async () => { - // Typing a mocked imported function is too complicated. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - loadRemote.mockResolvedValue({ - register: jest.fn() - }); - - const runtime = new DummyRuntime(); - - await registerLocalModules([ - () => {}, - () => {}, - () => {} - ], runtime); - - await registerRemoteModules([ - { name: "Dummy-1", url: "http://anything1.com" }, - { name: "Dummy-2", url: "http://anything2.com" }, - { name: "Dummy-3", url: "http://anything3.com" } - ], runtime); - - const { result } = renderWithRuntime(runtime); - - expect(result.current).toBeFalsy(); +// import { AbstractRuntime, RuntimeContext, registerLocalModules, resetLocalModulesRegistrationStatus } from "@squide/core"; +// import { act, renderHook } from "@testing-library/react"; +// import type { ReactNode } from "react"; +// import { loadRemote } from "../src/loadRemote.ts"; +// import { registerRemoteModules, resetRemoteModulesRegistrationStatus } from "../src/registerRemoteModules.ts"; +// import { useAreModulesReady } from "../src/useAreModulesReady.ts"; + +// // The mock implementation is defined directly in the tests. +// jest.mock("../src/loadRemote.ts"); + +// // The interval at which the hook will perform a check to determine if the modules are ready. +// const CheckInterval = 10; + +// class DummyRuntime extends AbstractRuntime { +// registerRoute() { +// throw new Error("Method not implemented."); +// } + +// get routes() { +// return []; +// } + +// registerNavigationItem() { +// throw new Error("Method not implemented."); +// } + +// getNavigationItems() { +// return []; +// } +// } + +// function renderWithRuntime(runtime: AbstractRuntime) { +// return renderHook(() => useAreModulesReady({ interval: CheckInterval }), { +// wrapper: ({ children }: { children?: ReactNode }) => ( +// +// {children} +// +// ) +// }); +// } + +// beforeEach(() => { +// // Since the module registration status variables are singletons, +// // they are not reseted between the tests. +// resetLocalModulesRegistrationStatus(); +// resetRemoteModulesRegistrationStatus(); + +// // Typing a mocked imported function is too complicated. +// // eslint-disable-next-line @typescript-eslint/ban-ts-comment +// // @ts-ignore +// if (loadRemote.mockClear) { +// // Typing a mocked imported function is too complicated. +// // eslint-disable-next-line @typescript-eslint/ban-ts-comment +// // @ts-ignore +// loadRemote.mockClear(); +// } + +// jest.useFakeTimers(); +// }); + +// afterEach(() => { +// jest.useRealTimers(); +// }); + +// test("when only local modules are registered, return true when all the local modules are registered", async () => { +// const runtime = new DummyRuntime(); + +// await registerLocalModules([ +// () => {}, +// () => {}, +// () => {} +// ], runtime); + +// const { result } = renderWithRuntime(runtime); + +// expect(result.current).toBeFalsy(); + +// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 +// act(() => { +// jest.advanceTimersByTime(CheckInterval + 1); +// }); + +// expect(result.current).toBeTruthy(); +// }); + +// test("when only remote modules are registered, return true when all the remote modules are registered", async () => { +// // Typing a mocked imported function is too complicated. +// // eslint-disable-next-line @typescript-eslint/ban-ts-comment +// // @ts-ignore +// loadRemote.mockResolvedValue({ +// register: jest.fn() +// }); + +// const runtime = new DummyRuntime(); + +// await registerRemoteModules([ +// { name: "Dummy-1", url: "http://anything1.com" }, +// { name: "Dummy-2", url: "http://anything2.com" }, +// { name: "Dummy-3", url: "http://anything3.com" } +// ], runtime); + +// const { result } = renderWithRuntime(runtime); + +// expect(result.current).toBeFalsy(); + +// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 +// act(() => { +// jest.advanceTimersByTime(CheckInterval + 1); +// }); + +// expect(result.current).toBeTruthy(); +// }); + +// test("when local and remote modules are registered, return true when all the remote modules are registered", async () => { +// // Typing a mocked imported function is too complicated. +// // eslint-disable-next-line @typescript-eslint/ban-ts-comment +// // @ts-ignore +// loadRemote.mockResolvedValue({ +// register: jest.fn() +// }); + +// const runtime = new DummyRuntime(); + +// await registerLocalModules([ +// () => {}, +// () => {}, +// () => {} +// ], runtime); + +// await registerRemoteModules([ +// { name: "Dummy-1", url: "http://anything1.com" }, +// { name: "Dummy-2", url: "http://anything2.com" }, +// { name: "Dummy-3", url: "http://anything3.com" } +// ], runtime); + +// const { result } = renderWithRuntime(runtime); + +// expect(result.current).toBeFalsy(); - // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 - act(() => { - jest.advanceTimersByTime(CheckInterval + 1); - }); +// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 +// act(() => { +// jest.advanceTimersByTime(CheckInterval + 1); +// }); - expect(result.current).toBeTruthy(); -}); +// expect(result.current).toBeTruthy(); +// }); -test("when a local module registration fail, return true when all the other modules are registered", async () => { - const runtime = new DummyRuntime(); +// test("when a local module registration fail, return true when all the other modules are registered", async () => { +// const runtime = new DummyRuntime(); - await registerLocalModules([ - () => {}, - () => { throw new Error("Registration failed!"); }, - () => {} - ], runtime); +// await registerLocalModules([ +// () => {}, +// () => { throw new Error("Registration failed!"); }, +// () => {} +// ], runtime); - const { result } = renderWithRuntime(runtime); +// const { result } = renderWithRuntime(runtime); - expect(result.current).toBeFalsy(); +// expect(result.current).toBeFalsy(); - // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 - act(() => { - jest.advanceTimersByTime(CheckInterval + 1); - }); +// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 +// act(() => { +// jest.advanceTimersByTime(CheckInterval + 1); +// }); - expect(result.current).toBeTruthy(); -}); +// expect(result.current).toBeTruthy(); +// }); -test("when a remote module registration fail, return true when all the other modules are registered", async () => { - const resolvedValue = { - register: jest.fn() - }; +// test("when a remote module registration fail, return true when all the other modules are registered", async () => { +// const resolvedValue = { +// register: jest.fn() +// }; - // Typing a mocked imported function is too complicated. - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - loadRemote.mockResolvedValueOnce(resolvedValue).mockResolvedValueOnce(resolvedValue).mockRejectedValueOnce(null); +// // Typing a mocked imported function is too complicated. +// // eslint-disable-next-line @typescript-eslint/ban-ts-comment +// // @ts-ignore +// loadRemote.mockResolvedValueOnce(resolvedValue).mockResolvedValueOnce(resolvedValue).mockRejectedValueOnce(null); - const runtime = new DummyRuntime(); +// const runtime = new DummyRuntime(); - await registerRemoteModules([ - { name: "Dummy-1", url: "http://anything1.com" }, - { name: "Dummy-2", url: "http://anything2.com" }, - { name: "Dummy-3", url: "http://anything3.com" } - ], runtime); +// await registerRemoteModules([ +// { name: "Dummy-1", url: "http://anything1.com" }, +// { name: "Dummy-2", url: "http://anything2.com" }, +// { name: "Dummy-3", url: "http://anything3.com" } +// ], runtime); - const { result } = renderWithRuntime(runtime); +// const { result } = renderWithRuntime(runtime); - expect(result.current).toBeFalsy(); +// expect(result.current).toBeFalsy(); - // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 - act(() => { - jest.advanceTimersByTime(CheckInterval + 1); - }); +// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 +// act(() => { +// jest.advanceTimersByTime(CheckInterval + 1); +// }); - expect(result.current).toBeTruthy(); -}); +// expect(result.current).toBeTruthy(); +// }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 625e2ba29..e0e3ef9bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,21 @@ importers: specifier: 5.0.1 version: 5.0.1 devDependencies: + '@swc/core': + specifier: 1.3.93 + version: 1.3.93(@swc/helpers@0.5.3) + '@swc/helpers': + specifier: 0.5.3 + version: 0.5.3 + '@swc/jest': + specifier: 0.2.29 + version: 0.2.29(@swc/core@1.3.93) + '@testing-library/react': + specifier: 14.0.0 + version: 14.0.0(react-dom@18.2.0)(react@18.2.0) + '@types/jest': + specifier: 29.5.5 + version: 29.5.5 '@types/react': specifier: 18.2.28 version: 18.2.28 @@ -60,18 +75,27 @@ importers: '@workleap/eslint-plugin': specifier: 3.0.0 version: 3.0.0(@typescript-eslint/parser@6.8.0)(eslint@8.51.0)(jest@29.7.0)(typescript@5.2.2) + '@workleap/swc-configs': + specifier: 2.1.2 + version: 2.1.2(@swc/core@1.3.93)(@swc/helpers@0.5.3)(@swc/jest@0.2.29)(browserslist@4.22.1) '@workleap/tsup-configs': specifier: 3.0.1 version: 3.0.1(tsup@7.2.0)(typescript@5.2.2) '@workleap/typescript-configs': specifier: 3.0.2 version: 3.0.2(typescript@5.2.2) + jest: + specifier: 29.7.0 + version: 29.7.0(@types/node@20.8.7)(ts-node@10.9.1) react: specifier: 18.2.0 version: 18.2.0 react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) + ts-jest: + specifier: 29.1.1 + version: 29.1.1(@babel/core@7.23.2)(esbuild@0.18.20)(jest@29.7.0)(typescript@5.2.2) tsup: specifier: 7.2.0 version: 7.2.0(@swc/core@1.3.93)(postcss@8.4.31)(ts-node@10.9.1)(typescript@5.2.2) @@ -1422,7 +1446,7 @@ packages: engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.22.19 + '@babel/types': 7.23.0 dev: true /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.22.15(@babel/core@7.23.2): @@ -9886,6 +9910,7 @@ packages: /file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + requiresBuild: true dev: true /filename-reserved-regex@3.0.0: diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index cca0fbbca..cb8efab6b 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -1,7 +1,7 @@ import { FeatureFlagsContext, SubscriptionContext, TelemetryServiceContext, useTelemetryService, type FeatureFlags, type Session, type SessionManager, type Subscription, type TelemetryService } from "@endpoints/shared"; import { useIsMswStarted } from "@squide/msw"; -import { useIsRouteMatchProtected, useLogger, useRoutes, type Logger } from "@squide/react-router"; -import { completeModuleRegistration, useAreModulesReady, useAreModulesRegistered } from "@squide/webpack-module-federation"; +import { useIsRouteMatchProtected, useLogger, useRoutes, useRuntime, type Logger } from "@squide/react-router"; +import { completeModuleRegistrations, useAreModulesReady, useAreModulesRegistered } from "@squide/webpack-module-federation"; import axios from "axios"; import { useEffect, useMemo, useState } from "react"; import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; @@ -122,6 +122,7 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, ar const [featureFlags, setFeatureFlags] = useState(); const [subscription, setSubscription] = useState(); + const runtime = useRuntime(); const logger = useLogger(); const location = useLocation(); const telemetryService = useTelemetryService(); @@ -176,12 +177,12 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, ar useEffect(() => { if (areModulesRegistered && isMswStarted && isPublicDataLoaded) { if (!areModulesReady) { - completeModuleRegistration(logger, { + completeModuleRegistrations(runtime, { featureFlags }); } } - }, [logger, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); + }, [runtime, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); useEffect(() => { telemetryService?.track(`Navigated to the "${location.pathname}" page.`); diff --git a/samples/endpoints/shell/src/register.tsx b/samples/endpoints/shell/src/register.tsx index edee54a27..72b637a64 100644 --- a/samples/endpoints/shell/src/register.tsx +++ b/samples/endpoints/shell/src/register.tsx @@ -119,13 +119,3 @@ export function registerShell(sessionManager: SessionManager, { host }: Register return register; } - -// export function registerShell(sessionManager: SessionManager, { host }: RegisterShellOptions = {}) { -// const register: ModuleRegisterFunction = runtime => { -// registerRoutes(runtime, sessionManager, host); - -// return registerMsw(runtime); -// }; - -// return register; -// } From 0ef93ef008dc723f6722ea7f8ebc6c7061a30c0a Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Tue, 24 Oct 2023 20:45:28 -0400 Subject: [PATCH 09/22] Added more tests --- .../src/federation/registerLocalModules.ts | 6 +- .../completeLocalModuleRegistrations.test.ts | 34 +- .../core/tests/registerLocalModules.test.ts | 4 +- .../webpack-module-federation/src/index.ts | 4 +- .../src/registerRemoteModules.ts | 16 +- .../src/useAreModulesReady.ts | 17 +- .../src/useAreModulesRegistered.ts | 18 +- .../tests/areModulesReady.test.tsx | 293 ++++++++++++++++++ .../tests/areModulesRegistered.test.tsx | 191 ++++++++++++ .../completeRemoteModuleRegistrations.test.ts | 36 +-- .../tests/registerRemoteModules.test.ts | 4 +- .../tests/useAreModulesReady.test.tsx | 197 ------------ 12 files changed, 564 insertions(+), 256 deletions(-) create mode 100644 packages/webpack-module-federation/tests/areModulesReady.test.tsx create mode 100644 packages/webpack-module-federation/tests/areModulesRegistered.test.tsx delete mode 100644 packages/webpack-module-federation/tests/useAreModulesReady.test.tsx diff --git a/packages/core/src/federation/registerLocalModules.ts b/packages/core/src/federation/registerLocalModules.ts index 7f00c431c..ce6faf6e1 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -64,7 +64,7 @@ export class LocalModuleRegistry { return errors; } - async completeRegistrations(runtime: TRuntime, data?: TData) { + async completeModuleRegistrations(runtime: TRuntime, data?: TData) { const errors: LocalModuleRegistrationError[] = []; if (this.#registrationStatus === "none" || this.#registrationStatus === "in-progress") { @@ -118,9 +118,9 @@ export function registerLocalModules(runtime: TRuntime, data?: TData) { - return localModuleRegistry.completeRegistrations(runtime, data); + return localModuleRegistry.completeModuleRegistrations(runtime, data); } -export function getLocalModulesRegistrationStatus() { +export function getLocalModuleRegistrationStatus() { return localModuleRegistry.registrationStatus; } diff --git a/packages/core/tests/completeLocalModuleRegistrations.test.ts b/packages/core/tests/completeLocalModuleRegistrations.test.ts index d4b207644..5d1985bf7 100644 --- a/packages/core/tests/completeLocalModuleRegistrations.test.ts +++ b/packages/core/tests/completeLocalModuleRegistrations.test.ts @@ -32,7 +32,7 @@ const runtime = new DummyRuntime(); test("when called before registerLocalModules, throw an error", async () => { const registry = new LocalModuleRegistry(); - await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeLocalModuleRegistration function can only be called once the registerLocalModules function terminated/); + await expect(() => registry.completeModuleRegistrations(runtime)).rejects.toThrow(/The completeLocalModuleRegistration function can only be called once the registerLocalModules function terminated/); }); test("when called twice, throw an error", async () => { @@ -43,9 +43,9 @@ test("when called twice, throw an error", async () => { () => () => {} ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); - await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeLocalModuleRegistration function can only be called once/); + await expect(() => registry.completeModuleRegistrations(runtime)).rejects.toThrow(/The completeLocalModuleRegistration function can only be called once/); }); test("when called for the first time but the registration status is already \"ready\", return a resolving promise", async () => { @@ -59,12 +59,12 @@ test("when called for the first time but the registration status is already \"re expect(registry.registrationStatus).toBe("ready"); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(registry.registrationStatus).toBe("ready"); }); -test("can complete all the deferred modules registration", async () => { +test("can complete all the deferred registrations", async () => { const registry = new LocalModuleRegistry(); const register1 = jest.fn(); @@ -77,14 +77,14 @@ test("can complete all the deferred modules registration", async () => { () => register3 ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(register1).toHaveBeenCalled(); expect(register2).toHaveBeenCalled(); expect(register3).toHaveBeenCalled(); }); -test("when all the deferred modules are registered, set the status to \"ready\"", async () => { +test("when all the deferred registrations are completed, set the status to \"ready\"", async () => { const registry = new LocalModuleRegistry(); await registry.registerModules([ @@ -94,12 +94,12 @@ test("when all the deferred modules are registered, set the status to \"ready\"" expect(registry.registrationStatus).toBe("registered"); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(registry.registrationStatus).toBe("ready"); }); -test("when a deferred module is asynchronous, the function can be awaited", async () => { +test("when a deferred registration is asynchronous, the function can be awaited", async () => { const registry = new LocalModuleRegistry(); let hasBeenCompleted = false; @@ -114,12 +114,12 @@ test("when a deferred module is asynchronous, the function can be awaited", asyn () => () => {} ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(hasBeenCompleted).toBeTruthy(); }); -test("when a deferred module registration fail, register the remaining deferred modules", async () => { +test("when a deferred registration fail, complete the remaining deferred registrations", async () => { const registry = new LocalModuleRegistry(); const register1 = jest.fn(); @@ -131,25 +131,25 @@ test("when a deferred module registration fail, register the remaining deferred () => register3 ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(register1).toHaveBeenCalled(); expect(register3).toHaveBeenCalled(); }); -test("when a deferred module registration fail, return the error", async () => { +test("when a deferred registration fail, return the error", async () => { const registry = new LocalModuleRegistry(); await registry.registerModules([ () => () => {}, - () => () => { throw new Error("Module 2 registration failed"); }, + () => () => { throw new Error("Module 2 deferred registration failed"); }, () => () => {} ], runtime); - const errors = await registry.completeRegistrations(runtime); + const errors = await registry.completeModuleRegistrations(runtime); expect(errors.length).toBe(1); - expect(errors[0]!.error!.toString()).toContain("Module 2 registration failed"); + expect(errors[0]!.error!.toString()).toContain("Module 2 deferred registration failed"); }); test("when data is provided, all the deferred module registrations receive the data object", async () => { @@ -169,7 +169,7 @@ test("when data is provided, all the deferred module registrations receive the d foo: "bar" }; - await registry.completeRegistrations(runtime, data); + await registry.completeModuleRegistrations(runtime, data); expect(register1).toHaveBeenCalledWith(data); expect(register2).toHaveBeenCalledWith(data); diff --git a/packages/core/tests/registerLocalModules.test.ts b/packages/core/tests/registerLocalModules.test.ts index c4ad53e90..f81743154 100644 --- a/packages/core/tests/registerLocalModules.test.ts +++ b/packages/core/tests/registerLocalModules.test.ts @@ -73,7 +73,7 @@ test("when called twice, throw an error", async () => { await expect(async () => registry.registerModules([() => {}], runtime)).rejects.toThrow(/The registerLocalModules function can only be called once/); }); -test("when there are no deferred modules, once all the modules are registered, set the status to \"ready\"", async () => { +test("when there are no deferred registrations, once all the modules are registered, set the status to \"ready\"", async () => { const registry = new LocalModuleRegistry(); await registry.registerModules([ @@ -84,7 +84,7 @@ test("when there are no deferred modules, once all the modules are registered, s expect(registry.registrationStatus).toBe("ready"); }); -test("when there are deferred modules, once all the modules are registered, set the status to \"registered\"", async () => { +test("when there are deferred registrations, once all the modules are registered, set the status to \"registered\"", async () => { const registry = new LocalModuleRegistry(); await registry.registerModules([ diff --git a/packages/webpack-module-federation/src/index.ts b/packages/webpack-module-federation/src/index.ts index 6753a2290..7e006fe98 100644 --- a/packages/webpack-module-federation/src/index.ts +++ b/packages/webpack-module-federation/src/index.ts @@ -2,6 +2,6 @@ export * from "./completeModuleRegistrations.ts"; export * from "./loadRemote.ts"; export * from "./registerRemoteModules.ts"; export * from "./remoteDefinition.ts"; -export * from "./useAreModulesReady.ts"; -export * from "./useAreModulesRegistered.ts"; +export { useAreModulesReady } from "./useAreModulesReady.ts"; +export { areModulesRegistered } from "./useAreModulesRegistered.ts"; diff --git a/packages/webpack-module-federation/src/registerRemoteModules.ts b/packages/webpack-module-federation/src/registerRemoteModules.ts index 2c3dc8cc2..cf1244e31 100644 --- a/packages/webpack-module-federation/src/registerRemoteModules.ts +++ b/packages/webpack-module-federation/src/registerRemoteModules.ts @@ -113,7 +113,7 @@ export class RemoteModuleRegistry { return errors; } - async completeRegistrations(runtime: TRuntime, data?: TData) { + async completeModuleRegistrations(runtime: TRuntime, data?: TData) { const errors: RemoteModuleRegistrationError[] = []; if (this.#registrationStatus === "none" || this.#registrationStatus === "in-progress") { @@ -163,6 +163,11 @@ export class RemoteModuleRegistry { get registrationStatus() { return this.#registrationStatus; } + + // Required to test hooks that dependent on the registration status. + resetRegistrationStatus() { + this.#registrationStatus = "none"; + } } const remoteModuleRegistry = new RemoteModuleRegistry(loadModuleFederationRemote); @@ -172,9 +177,14 @@ export function registerRemoteModules(runtime: TRuntime, data?: TData) { - return remoteModuleRegistry.completeRegistrations(runtime, data); + return remoteModuleRegistry.completeModuleRegistrations(runtime, data); } -export function getRemoteModulesRegistrationStatus() { +export function getRemoteModuleRegistrationStatus() { return remoteModuleRegistry.registrationStatus; } + +// Required to test hooks that dependent on the registration status. +export function resetRemoteModuleRegistrationStatus() { + +} diff --git a/packages/webpack-module-federation/src/useAreModulesReady.ts b/packages/webpack-module-federation/src/useAreModulesReady.ts index 4976ac99e..cbf1bf2bb 100644 --- a/packages/webpack-module-federation/src/useAreModulesReady.ts +++ b/packages/webpack-module-federation/src/useAreModulesReady.ts @@ -1,16 +1,21 @@ import { useEffect, useState } from "react"; -import { getLocalModulesRegistrationStatus, useRuntime } from "@squide/core"; -import { getRemoteModulesRegistrationStatus } from "./registerRemoteModules.ts"; +import { getLocalModuleRegistrationStatus, useRuntime, type ModuleRegistrationStatus } from "@squide/core"; +import { getRemoteModuleRegistrationStatus } from "./registerRemoteModules.ts"; export interface UseAreModulesReadyOptions { // The interval is in milliseconds. interval?: number; } -function areModulesReady() { - return (getLocalModulesRegistrationStatus() === "none" || getLocalModulesRegistrationStatus() === "ready") && - (getRemoteModulesRegistrationStatus() === "none" || getRemoteModulesRegistrationStatus() === "ready"); +export function areModulesReady(localModuleRegistrationStatus: ModuleRegistrationStatus, remoteModuleRegistrationStatus: ModuleRegistrationStatus) { + if (localModuleRegistrationStatus === "none" && remoteModuleRegistrationStatus === "none") { + return false; + } + + // The registration for local or remote modules could be "none" if an application doesn't register either local or remote modules. + return (localModuleRegistrationStatus === "none" || localModuleRegistrationStatus === "ready") && + (remoteModuleRegistrationStatus === "none" || remoteModuleRegistrationStatus === "ready"); } export function useAreModulesReady({ interval = 10 }: UseAreModulesReadyOptions = {}) { @@ -22,7 +27,7 @@ export function useAreModulesReady({ interval = 10 }: UseAreModulesReadyOptions // Perform a reload once the modules are registered. useEffect(() => { const intervalId = setInterval(() => { - if (areModulesReady()) { + if (areModulesReady(getLocalModuleRegistrationStatus(), getRemoteModuleRegistrationStatus())) { // Must clear interval before calling "_completeRegistration" in case there's an error. clearInterval(intervalId); diff --git a/packages/webpack-module-federation/src/useAreModulesRegistered.ts b/packages/webpack-module-federation/src/useAreModulesRegistered.ts index d2f7324de..69d10f6f3 100644 --- a/packages/webpack-module-federation/src/useAreModulesRegistered.ts +++ b/packages/webpack-module-federation/src/useAreModulesRegistered.ts @@ -1,16 +1,22 @@ import { useEffect, useState } from "react"; -import { getLocalModulesRegistrationStatus } from "@squide/core"; -import { getRemoteModulesRegistrationStatus } from "./registerRemoteModules.ts"; +import { getLocalModuleRegistrationStatus, type ModuleRegistrationStatus } from "@squide/core"; +import { getRemoteModuleRegistrationStatus } from "./registerRemoteModules.ts"; export interface UseAreModulesRegisteredOptions { // The interval is in milliseconds. interval?: number; } -function areModulesRegistered() { - return (getLocalModulesRegistrationStatus() === "none" || getLocalModulesRegistrationStatus() === "registered") && - (getRemoteModulesRegistrationStatus() === "none" || getRemoteModulesRegistrationStatus() === "registered"); +export function areModulesRegistered(localModuleRegistrationStatus: ModuleRegistrationStatus, remoteModuleRegistrationStatus: ModuleRegistrationStatus) { + if (localModuleRegistrationStatus === "none" && remoteModuleRegistrationStatus === "none") { + return false; + } + + // The registration for local or remote modules could be "none" if an application doesn't register either local or remote modules. + // The registration statuses could be at "ready" if there's no deferred registrations. + return (localModuleRegistrationStatus === "none" || localModuleRegistrationStatus === "registered" || localModuleRegistrationStatus === "ready") && + (remoteModuleRegistrationStatus === "none" || remoteModuleRegistrationStatus === "registered" || remoteModuleRegistrationStatus === "ready"); } export function useAreModulesRegistered({ interval = 10 }: UseAreModulesRegisteredOptions = {}) { @@ -20,7 +26,7 @@ export function useAreModulesRegistered({ interval = 10 }: UseAreModulesRegister // Perform a reload once the modules are registered. useEffect(() => { const intervalId = setInterval(() => { - if (areModulesRegistered()) { + if (areModulesRegistered(getLocalModuleRegistrationStatus(), getRemoteModuleRegistrationStatus())) { clearInterval(intervalId); setAreModulesRegistered(true); diff --git a/packages/webpack-module-federation/tests/areModulesReady.test.tsx b/packages/webpack-module-federation/tests/areModulesReady.test.tsx new file mode 100644 index 000000000..18df81c8c --- /dev/null +++ b/packages/webpack-module-federation/tests/areModulesReady.test.tsx @@ -0,0 +1,293 @@ +// The areModulesReady function is tested instead of the useAreModulesReady hook because it requires less mocking and +// kind of provide the same coverage as the only important logic to test for that hook is the check to validate whether +// or not the module registrations is considered as ready or not. + +import { AbstractRuntime, LocalModuleRegistry } from "@squide/core"; +import { RemoteModuleRegistry } from "../src/registerRemoteModules.ts"; +import { areModulesReady } from "../src/useAreModulesReady.ts"; + +class DummyRuntime extends AbstractRuntime { + registerRoute() { + throw new Error("Method not implemented."); + } + + get routes() { + return []; + } + + registerNavigationItem() { + throw new Error("Method not implemented."); + } + + getNavigationItems() { + return []; + } +} + +const runtime = new DummyRuntime(); + +/* +- when local module deferred registrations and remote module deferred registrations are registered and only the local module deferred registrations are completed, return false +- when local module deferred registrations and remote module deferred registrations are registered and only the remote module deferred registrations are completed, return false + +*/ + +test("when no modules are registered, return false", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); + +test("when only local modules are registered and they are ready, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => {}, + () => {}, + () => {} + ], runtime); + + await localModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only remote modules are registered and they are ready, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await remoteModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only local module deferred registrations are registered and they are ready, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await localModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only remote module deferred registrations are registered and they are ready, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + + await remoteModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local module deferred registrations and remote module deferred registrations are registered and they are ready, return", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await localModuleRegistry.completeModuleRegistrations(runtime); + await remoteModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local module deferred registrations and remote modules are registered and they are ready, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await localModuleRegistry.completeModuleRegistrations(runtime); + await remoteModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local modules and remote module deferred registrations are registered and they are ready, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => {}, + () => {}, + () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await localModuleRegistry.completeModuleRegistrations(runtime); + await remoteModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only local module deferred registrations are registered and they are not completed, return false", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); + +test("when only remote module deferred registrations are registered and they are not completed, return false", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); + +test("when local module deferred registrations and remote module deferred registrations are registered and they are not completed, return false", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); + +test("when local module deferred registrations and remote module deferred registrations are registered and only the local module deferred registrations are completed, return false", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await localModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); + +test("when local module deferred registrations and remote module deferred registrations are registered and only the remote module deferred registrations are completed, return false", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + await remoteModuleRegistry.completeModuleRegistrations(runtime); + + expect(areModulesReady(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); diff --git a/packages/webpack-module-federation/tests/areModulesRegistered.test.tsx b/packages/webpack-module-federation/tests/areModulesRegistered.test.tsx new file mode 100644 index 000000000..8ef8728c3 --- /dev/null +++ b/packages/webpack-module-federation/tests/areModulesRegistered.test.tsx @@ -0,0 +1,191 @@ +// The areModulesRegistered function is tested instead of the useAreModulesRegistered hook because it requires less mocking and +// kind of provide the same coverage as the only important logic to test for that hook is the check to validate whether +// or not the module registrations is considered as registered or not. + +import { AbstractRuntime, LocalModuleRegistry } from "@squide/core"; +import { RemoteModuleRegistry } from "../src/registerRemoteModules.ts"; +import { areModulesRegistered } from "../src/useAreModulesRegistered.ts"; + +class DummyRuntime extends AbstractRuntime { + registerRoute() { + throw new Error("Method not implemented."); + } + + get routes() { + return []; + } + + registerNavigationItem() { + throw new Error("Method not implemented."); + } + + getNavigationItems() { + return []; + } +} + +const runtime = new DummyRuntime(); + +test("when no modules are registered, return false", () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeFalsy(); +}); + +test("when only local modules are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => {}, + () => {}, + () => {} + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only remote modules are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local modules and remote modules are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => {}, + () => {}, + () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only local module deferred registrations are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when only remote module deferred registrations are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local module deferred registrations and remote module deferred registrations are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local module deferred registrations and remote modules are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => {} + })); + + await localModuleRegistry.registerModules([ + () => () => {}, + () => () => {}, + () => () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + +test("when local modules and remote module deferred registrations are registered, return true", async () => { + const localModuleRegistry = new LocalModuleRegistry(); + + const remoteModuleRegistry = new RemoteModuleRegistry(jest.fn().mockResolvedValue({ + register: () => () => {} + })); + + await localModuleRegistry.registerModules([ + () => {}, + () => {}, + () => {} + ], runtime); + + await remoteModuleRegistry.registerModules([ + { name: "Dummy-1", url: "http://anything1.com" }, + { name: "Dummy-2", url: "http://anything2.com" }, + { name: "Dummy-3", url: "http://anything3.com" } + ], runtime); + + expect(areModulesRegistered(localModuleRegistry.registrationStatus, remoteModuleRegistry.registrationStatus)).toBeTruthy(); +}); + + diff --git a/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts b/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts index 993b3f45f..74459ddb5 100644 --- a/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts +++ b/packages/webpack-module-federation/tests/completeRemoteModuleRegistrations.test.ts @@ -32,7 +32,7 @@ const runtime = new DummyRuntime(); test("when called before registerRemoteModules, throw an error", async () => { const registry = new RemoteModuleRegistry(jest.fn()); - await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeRemoteModuleRegistration function can only be called once the registerRemoteModules function terminated/); + await expect(() => registry.completeModuleRegistrations(runtime)).rejects.toThrow(/The completeRemoteModuleRegistration function can only be called once the registerRemoteModules function terminated/); }); test("when called twice, throw an error", async () => { @@ -47,9 +47,9 @@ test("when called twice, throw an error", async () => { { name: "Dummy-2", url: "http://anything2.com" } ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); - await expect(() => registry.completeRegistrations(runtime)).rejects.toThrow(/The completeRemoteModuleRegistration function can only be called once/); + await expect(() => registry.completeModuleRegistrations(runtime)).rejects.toThrow(/The completeRemoteModuleRegistration function can only be called once/); }); test("when called for the first time but the registration status is already \"ready\", return a resolving promise", async () => { @@ -67,12 +67,12 @@ test("when called for the first time but the registration status is already \"re expect(registry.registrationStatus).toBe("ready"); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(registry.registrationStatus).toBe("ready"); }); -test("can complete all the deferred modules registration", async () => { +test("can complete all the deferred registrations", async () => { const register1 = jest.fn(); const register2 = jest.fn(); const register3 = jest.fn(); @@ -98,14 +98,14 @@ test("can complete all the deferred modules registration", async () => { { name: "Dummy-3", url: "http://anything3.com" } ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(register1).toHaveBeenCalled(); expect(register2).toHaveBeenCalled(); expect(register3).toHaveBeenCalled(); }); -test("when all the deferred modules are registered, set the status to \"ready\"", async () => { +test("when all the deferred registrations are completed, set the status to \"ready\"", async () => { const loadRemote = jest.fn().mockResolvedValue({ register: () => () => {} }); @@ -120,12 +120,12 @@ test("when all the deferred modules are registered, set the status to \"ready\"" expect(registry.registrationStatus).toBe("registered"); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(registry.registrationStatus).toBe("ready"); }); -test("when a deferred module is asynchronous, the function can be awaited", async () => { +test("when a deferred registration is asynchronous, the function can be awaited", async () => { const loadRemote = jest.fn(); loadRemote @@ -153,12 +153,12 @@ test("when a deferred module is asynchronous, the function can be awaited", asyn { name: "Dummy-3", url: "http://anything3.com" } ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(hasBeenCompleted).toBeTruthy(); }); -test("when a deferred module registration fail, register the remaining deferred modules", async () => { +test("when a deferred registration fail, complete the remaining deferred registrations", async () => { const register1 = jest.fn(); const register3 = jest.fn(); @@ -183,13 +183,13 @@ test("when a deferred module registration fail, register the remaining deferred { name: "Dummy-3", url: "http://anything3.com" } ], runtime); - await registry.completeRegistrations(runtime); + await registry.completeModuleRegistrations(runtime); expect(register1).toHaveBeenCalled(); expect(register3).toHaveBeenCalled(); }); -test("when a deferred module registration fail, return the error", async () => { +test("when a deferred registration fail, return the error", async () => { const loadRemote = jest.fn(); loadRemote @@ -197,7 +197,7 @@ test("when a deferred module registration fail, return the error", async () => { register: () => () => {} }) .mockResolvedValueOnce({ - register: () => () => { throw new Error("Module 2 registration failed"); } + register: () => () => { throw new Error("Module 2 deferred registration failed"); } }) .mockResolvedValueOnce({ register: () => () => {} @@ -211,13 +211,13 @@ test("when a deferred module registration fail, return the error", async () => { { name: "Dummy-3", url: "http://anything3.com" } ], runtime); - const errors = await registry.completeRegistrations(runtime); + const errors = await registry.completeModuleRegistrations(runtime); expect(errors.length).toBe(1); - expect(errors[0]!.error!.toString()).toContain("Module 2 registration failed"); + expect(errors[0]!.error!.toString()).toContain("Module 2 deferred registration failed"); }); -test("when data is provided, all the deferred module registrations receive the data object", async () => { +test("when data is provided, all the deferred registrations receive the data object", async () => { const register1 = jest.fn(); const register2 = jest.fn(); const register3 = jest.fn(); @@ -247,7 +247,7 @@ test("when data is provided, all the deferred module registrations receive the d foo: "bar" }; - await registry.completeRegistrations(runtime, data); + await registry.completeModuleRegistrations(runtime, data); expect(register1).toHaveBeenCalledWith(data); expect(register2).toHaveBeenCalledWith(data); diff --git a/packages/webpack-module-federation/tests/registerRemoteModules.test.ts b/packages/webpack-module-federation/tests/registerRemoteModules.test.ts index ad61c5a23..f461ae989 100644 --- a/packages/webpack-module-federation/tests/registerRemoteModules.test.ts +++ b/packages/webpack-module-federation/tests/registerRemoteModules.test.ts @@ -64,7 +64,7 @@ test("when called twice, throw an error", async () => { await expect(async () => registry.registerModules([{ name: "Dummy-1", url: "http://anything1.com" }], runtime)).rejects.toThrow(/The registerRemoteModules function can only be called once/); }); -test("when there are no deferred modules, once all the modules are registered, set the status to \"ready\"", async () => { +test("when there are no deferred registrations, once all the modules are registered, set the status to \"ready\"", async () => { const loadRemote = jest.fn().mockResolvedValue({ register: () => {} }); @@ -79,7 +79,7 @@ test("when there are no deferred modules, once all the modules are registered, s expect(registry.registrationStatus).toBe("ready"); }); -test("when there are deferred modules, once all the modules are registered, set the status to \"registered\"", async () => { +test("when there are deferred registrations, once all the modules are registered, set the status to \"registered\"", async () => { const loadRemote = jest.fn().mockResolvedValue({ register: () => () => {} }); diff --git a/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx b/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx deleted file mode 100644 index f71645655..000000000 --- a/packages/webpack-module-federation/tests/useAreModulesReady.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -// import { AbstractRuntime, RuntimeContext, registerLocalModules, resetLocalModulesRegistrationStatus } from "@squide/core"; -// import { act, renderHook } from "@testing-library/react"; -// import type { ReactNode } from "react"; -// import { loadRemote } from "../src/loadRemote.ts"; -// import { registerRemoteModules, resetRemoteModulesRegistrationStatus } from "../src/registerRemoteModules.ts"; -// import { useAreModulesReady } from "../src/useAreModulesReady.ts"; - -// // The mock implementation is defined directly in the tests. -// jest.mock("../src/loadRemote.ts"); - -// // The interval at which the hook will perform a check to determine if the modules are ready. -// const CheckInterval = 10; - -// class DummyRuntime extends AbstractRuntime { -// registerRoute() { -// throw new Error("Method not implemented."); -// } - -// get routes() { -// return []; -// } - -// registerNavigationItem() { -// throw new Error("Method not implemented."); -// } - -// getNavigationItems() { -// return []; -// } -// } - -// function renderWithRuntime(runtime: AbstractRuntime) { -// return renderHook(() => useAreModulesReady({ interval: CheckInterval }), { -// wrapper: ({ children }: { children?: ReactNode }) => ( -// -// {children} -// -// ) -// }); -// } - -// beforeEach(() => { -// // Since the module registration status variables are singletons, -// // they are not reseted between the tests. -// resetLocalModulesRegistrationStatus(); -// resetRemoteModulesRegistrationStatus(); - -// // Typing a mocked imported function is too complicated. -// // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// // @ts-ignore -// if (loadRemote.mockClear) { -// // Typing a mocked imported function is too complicated. -// // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// // @ts-ignore -// loadRemote.mockClear(); -// } - -// jest.useFakeTimers(); -// }); - -// afterEach(() => { -// jest.useRealTimers(); -// }); - -// test("when only local modules are registered, return true when all the local modules are registered", async () => { -// const runtime = new DummyRuntime(); - -// await registerLocalModules([ -// () => {}, -// () => {}, -// () => {} -// ], runtime); - -// const { result } = renderWithRuntime(runtime); - -// expect(result.current).toBeFalsy(); - -// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 -// act(() => { -// jest.advanceTimersByTime(CheckInterval + 1); -// }); - -// expect(result.current).toBeTruthy(); -// }); - -// test("when only remote modules are registered, return true when all the remote modules are registered", async () => { -// // Typing a mocked imported function is too complicated. -// // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// // @ts-ignore -// loadRemote.mockResolvedValue({ -// register: jest.fn() -// }); - -// const runtime = new DummyRuntime(); - -// await registerRemoteModules([ -// { name: "Dummy-1", url: "http://anything1.com" }, -// { name: "Dummy-2", url: "http://anything2.com" }, -// { name: "Dummy-3", url: "http://anything3.com" } -// ], runtime); - -// const { result } = renderWithRuntime(runtime); - -// expect(result.current).toBeFalsy(); - -// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 -// act(() => { -// jest.advanceTimersByTime(CheckInterval + 1); -// }); - -// expect(result.current).toBeTruthy(); -// }); - -// test("when local and remote modules are registered, return true when all the remote modules are registered", async () => { -// // Typing a mocked imported function is too complicated. -// // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// // @ts-ignore -// loadRemote.mockResolvedValue({ -// register: jest.fn() -// }); - -// const runtime = new DummyRuntime(); - -// await registerLocalModules([ -// () => {}, -// () => {}, -// () => {} -// ], runtime); - -// await registerRemoteModules([ -// { name: "Dummy-1", url: "http://anything1.com" }, -// { name: "Dummy-2", url: "http://anything2.com" }, -// { name: "Dummy-3", url: "http://anything3.com" } -// ], runtime); - -// const { result } = renderWithRuntime(runtime); - -// expect(result.current).toBeFalsy(); - -// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 -// act(() => { -// jest.advanceTimersByTime(CheckInterval + 1); -// }); - -// expect(result.current).toBeTruthy(); -// }); - -// test("when a local module registration fail, return true when all the other modules are registered", async () => { -// const runtime = new DummyRuntime(); - -// await registerLocalModules([ -// () => {}, -// () => { throw new Error("Registration failed!"); }, -// () => {} -// ], runtime); - -// const { result } = renderWithRuntime(runtime); - -// expect(result.current).toBeFalsy(); - -// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 -// act(() => { -// jest.advanceTimersByTime(CheckInterval + 1); -// }); - -// expect(result.current).toBeTruthy(); -// }); - -// test("when a remote module registration fail, return true when all the other modules are registered", async () => { -// const resolvedValue = { -// register: jest.fn() -// }; - -// // Typing a mocked imported function is too complicated. -// // eslint-disable-next-line @typescript-eslint/ban-ts-comment -// // @ts-ignore -// loadRemote.mockResolvedValueOnce(resolvedValue).mockResolvedValueOnce(resolvedValue).mockRejectedValueOnce(null); - -// const runtime = new DummyRuntime(); - -// await registerRemoteModules([ -// { name: "Dummy-1", url: "http://anything1.com" }, -// { name: "Dummy-2", url: "http://anything2.com" }, -// { name: "Dummy-3", url: "http://anything3.com" } -// ], runtime); - -// const { result } = renderWithRuntime(runtime); - -// expect(result.current).toBeFalsy(); - -// // To justify the usage of act, refer to: https://github.com/testing-library/react-hooks-testing-library/issues/241 -// act(() => { -// jest.advanceTimersByTime(CheckInterval + 1); -// }); - -// expect(result.current).toBeTruthy(); -// }); From 1161df41015afe9a25b54ae5ceae4a6947c58a6d Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Tue, 24 Oct 2023 23:47:45 -0400 Subject: [PATCH 10/22] Added a "features" section to the defineConfigs --- docs/reference/msw/MswPlugin.md | 6 +- .../webpack/defineBuildHostConfig.md | 27 +++ .../webpack/defineBuildRemoteModuleConfig.md | 27 +++ docs/reference/webpack/defineDevHostConfig.md | 27 +++ .../webpack/defineDevRemoteModuleConfig.md | 27 +++ .../src/defineConfig.ts | 71 +++++--- .../webpack-module-federation/src/index.ts | 2 +- .../__snapshots__/defineConfig.test.ts.snap | 172 ++++++++++++++++++ .../tests/defineConfig.test.ts | 80 ++++++-- samples/endpoints/host/swc.build.js | 20 +- samples/endpoints/host/webpack.build.js | 14 +- samples/endpoints/host/webpack.dev.js | 4 + .../endpoints/remote-module/webpack.build.js | 3 + .../endpoints/remote-module/webpack.dev.js | 3 + 14 files changed, 441 insertions(+), 42 deletions(-) diff --git a/docs/reference/msw/MswPlugin.md b/docs/reference/msw/MswPlugin.md index 13b2d59a4..8ace13526 100644 --- a/docs/reference/msw/MswPlugin.md +++ b/docs/reference/msw/MswPlugin.md @@ -22,10 +22,12 @@ None ## Usage -!!!info -Do not include MSW in production code. To address this, we recommend conditionally importing the code that includes the [msw](https://www.npmjs.com/package/msw) package based on an environment variable. +!!! warning +Don't forget to [activate the msw feature](../webpack/defineDevHostConfig.md#activate-optional-features) on the host application as well as every remote module. !!! +Do not include MSW in production code. To address this, we recommend conditionally importing the code that includes the [msw](https://www.npmjs.com/package/msw) package based on an environment variable. + To do so, first use [cross-env](https://www.npmjs.com/package/cross-env) to define a `USE_MSW` environment variable in a PNPM script: ```json package.json diff --git a/docs/reference/webpack/defineBuildHostConfig.md b/docs/reference/webpack/defineBuildHostConfig.md index f23aa9c5d..32ac14171 100644 --- a/docs/reference/webpack/defineBuildHostConfig.md +++ b/docs/reference/webpack/defineBuildHostConfig.md @@ -22,6 +22,9 @@ const webpackConfig = defineBuildHostConfig(swcConfig: {}, applicationName, publ - `options`: An optional object literal of options: - Accepts most of webpack `definedBuildConfig` [predefined options](https://gsoft-inc.github.io/wl-web-configs/webpack/configure-build/#3-set-predefined-options). - `htmlWebpackPluginOptions`: An optional object literal accepting any property of the [HtmlWebpackPlugin](https://github.com/jantimon/html-webpack-plugin#options). + - `features`: An optional object literal of feature switches to define additional shared dependencies. + - `router`: Currently hardcoded to `"react-router"` as it's the only supported router (`@squide/react-router` and `@react-router-dom` are currently considered as default shared dependencies). + - `msw`: Whether or not to add `@squide/msw` as a shared dependency. - `sharedDependencies`: An optional object literal of additional (or updated) module federation shared dependencies. - `moduleFederationPluginOptions`: An optional object literal of [ModuleFederationPlugin](https://webpack.js.org/plugins/module-federation-plugin/) options. @@ -41,6 +44,11 @@ The `defineBuildHostConfig` function will add the following shared dependencies For the full shared dependencies configuration, have a look at the [defineConfig.ts](https://github.com/gsoft-inc/wl-squide/blob/main/packages/webpack-module-federation/src/defineConfig.ts) file on GitHub. +## Optional shared dependencies + +The following shared dependencies can be added through feature switches: +- [`@squide/msw`](https://www.npmjs.com/package/@squide/msw) + ## Usage ### Define a webpack config @@ -54,6 +62,25 @@ import { swcConfig } from "./swc.build.js"; export default defineBuildHostConfig(swcConfig, "host", "http://localhost:8080/"); ``` +### Activate additional features + +!!!info +Features must be activated on the host application as well as every remote module. +!!! + +```js !#7-9 host/webpack.build.js +// @ts-check + +import { defineBuildHostConfig } from "@squide/webpack-module-federation/defineConfig.js"; +import { swcConfig } from "./swc.build.js"; + +export default defineBuildHostConfig(swcConfig, "host", "http://localhost:8080/", { + features: { + msw: true + } +}); +``` + ### Specify additional shared dependencies !!!info diff --git a/docs/reference/webpack/defineBuildRemoteModuleConfig.md b/docs/reference/webpack/defineBuildRemoteModuleConfig.md index 7959bb1ed..3c85d16cd 100644 --- a/docs/reference/webpack/defineBuildRemoteModuleConfig.md +++ b/docs/reference/webpack/defineBuildRemoteModuleConfig.md @@ -21,6 +21,9 @@ const webpackConfig = defineBuildRemoteModuleConfig(swcConfig: {}, applicationNa - `publicPath`: The remote module application public path. - `options`: An optional object literal of options: - Accepts most of webpack `definedDevConfig` [predefined options](https://gsoft-inc.github.io/wl-web-configs/webpack/configure-dev/#3-set-predefined-options). + - `features`: An optional object literal of feature switches to define additional shared dependencies. + - `router`: Currently hardcoded to `"react-router"` as it's the only supported router (`@squide/react-router` and `@react-router-dom` are currently considered as default shared dependencies). + - `msw`: Whether or not to add `@squide/msw` as a shared dependency. - `sharedDependencies`: An optional object literal of additional (or updated) module federation shared dependencies. - `moduleFederationPluginOptions`: An optional object literal of [ModuleFederationPlugin](https://webpack.js.org/plugins/module-federation-plugin/) options. @@ -63,6 +66,11 @@ The `defineBuildRemoteModuleConfig` function will add the following shared depen For the full shared dependencies configuration, have a look at the [defineConfig.ts](https://github.com/gsoft-inc/wl-squide/blob/main/packages/webpack-module-federation/src/defineConfig.ts) file on Github. +## Optional shared dependencies + +The following shared dependencies can be added through feature switches: +- [`@squide/msw`](https://www.npmjs.com/package/@squide/msw) + ## Usage ### Define a webpack config @@ -76,6 +84,25 @@ import { swcConfig } from "./swc.build.js"; export default defineBuildRemoteModuleConfig(swcConfig, "remote1", "http://localhost:8080/"); ``` +### Activate additional features + +!!!info +Features must be activated on the host application as well as every remote module. +!!! + +```js !#7-9 remote-module/webpack.build.js +// @ts-check + +import { defineBuildRemoteModuleConfig } from "@squide/webpack-module-federation/defineConfig.js"; +import { swcConfig } from "./swc.build.js"; + +export default defineBuildRemoteModuleConfig(swcConfig, "remote1", "http://localhost:8080/", { + features: { + msw: true + } +}); +``` + ### Specify additional shared dependencies !!!info diff --git a/docs/reference/webpack/defineDevHostConfig.md b/docs/reference/webpack/defineDevHostConfig.md index 3c61e754b..4d946111f 100644 --- a/docs/reference/webpack/defineDevHostConfig.md +++ b/docs/reference/webpack/defineDevHostConfig.md @@ -22,6 +22,9 @@ const webpackConfig = defineDevHostConfig(swcConfig: {}, applicationName, port, - `options`: An optional object literal of options: - Accepts most of webpack `definedDevConfig` [predefined options](https://gsoft-inc.github.io/wl-web-configs/webpack/configure-dev/#3-set-predefined-options). - `htmlWebpackPluginOptions`: An optional object literal accepting any property of the [HtmlWebpackPlugin](https://github.com/jantimon/html-webpack-plugin#options). + - `features`: An optional object literal of feature switches to define additional shared dependencies. + - `router`: Currently hardcoded to `"react-router"` as it's the only supported router (`@squide/react-router` and `@react-router-dom` are currently considered as default shared dependencies). + - `msw`: Whether or not to add `@squide/msw` as a shared dependency. - `sharedDependencies`: An optional object literal of additional (or updated) module federation shared dependencies. - `moduleFederationPluginOptions`: An optional object literal of [ModuleFederationPlugin](https://webpack.js.org/plugins/module-federation-plugin/) options. @@ -41,6 +44,11 @@ The `defineDevHostConfig` function will add the following shared dependencies as For the full shared dependencies configuration, have a look at the [defineConfig.ts](https://github.com/gsoft-inc/wl-squide/blob/main/packages/webpack-module-federation/src/defineConfig.ts) file on Github. +## Optional shared dependencies + +The following shared dependencies can be added through feature switches: +- [`@squide/msw`](https://www.npmjs.com/package/@squide/msw) + ## Usage ### Define a webpack config @@ -54,6 +62,25 @@ import { swcConfig } from "./swc.dev.js"; export default defineDevHostConfig(swcConfig, "host", 8080); ``` +### Activate optional features + +!!!info +Features must be activated on the host application as well as every remote module. +!!! + +```js !#7-9 host/webpack.dev.js +// @ts-check + +import { defineDevHostConfig } from "@squide/webpack-module-federation/defineConfig.js"; +import { swcConfig } from "./swc.dev.js"; + +export default defineDevHostConfig(swcConfig, "host", 8080, { + features: { + msw: true + } +}); +``` + ### Specify additional shared dependencies !!!info diff --git a/docs/reference/webpack/defineDevRemoteModuleConfig.md b/docs/reference/webpack/defineDevRemoteModuleConfig.md index 84d65af9a..9946ad4ec 100644 --- a/docs/reference/webpack/defineDevRemoteModuleConfig.md +++ b/docs/reference/webpack/defineDevRemoteModuleConfig.md @@ -21,6 +21,9 @@ const webpackConfig = defineDevRemoteModuleConfig(swcConfig: {}, applicationName - `port`: The remote module application port. - `options`: An optional object literal of options: - Accepts most of webpack `definedDevConfig` [predefined options](https://gsoft-inc.github.io/wl-web-configs/webpack/configure-dev/#3-set-predefined-options). + - `features`: An optional object literal of feature switches to define additional shared dependencies. + - `router`: Currently hardcoded to `"react-router"` as it's the only supported router (`@squide/react-router` and `@react-router-dom` are currently considered as default shared dependencies). + - `msw`: Whether or not to add `@squide/msw` as a shared dependency. - `sharedDependencies`: An optional object literal of additional (or updated) module federation shared dependencies. - `moduleFederationPluginOptions`: An optional object literal of [ModuleFederationPlugin](https://webpack.js.org/plugins/module-federation-plugin/) options. @@ -63,6 +66,11 @@ The `defineDevRemoteModuleConfig` function will add the following shared depende For the full shared dependencies configuration, have a look at the [defineConfig.ts](https://github.com/gsoft-inc/wl-squide/blob/main/packages/webpack-module-federation/src/defineConfig.ts) file on Github. +## Optional shared dependencies + +The following shared dependencies can be added through feature switches: +- [`@squide/msw`](https://www.npmjs.com/package/@squide/msw) + ## Usage ### Define a webpack config @@ -76,6 +84,25 @@ import { swcConfig } from "./swc.dev.js"; export default defineDevRemoteModuleConfig(swcConfig, "remote1", 8080); ``` +### Activate additional features + +!!!info +Features must be activated on the host application as well as every remote module. +!!! + +```js !#7-9 remote-module/webpack.dev.js +// @ts-check + +import { defineDevRemoteModuleConfig } from "@squide/webpack-module-federation/defineConfig.js"; +import { swcConfig } from "./swc.dev.js"; + +export default defineDevRemoteModuleConfig(swcConfig, "remote1", 8080, { + features: { + msw: true + } +}); +``` + ### Specify additional shared dependencies !!!info diff --git a/packages/webpack-module-federation/src/defineConfig.ts b/packages/webpack-module-federation/src/defineConfig.ts index 8f70546d5..19f582033 100644 --- a/packages/webpack-module-federation/src/defineConfig.ts +++ b/packages/webpack-module-federation/src/defineConfig.ts @@ -11,7 +11,7 @@ export type ModuleFederationPluginOptions = ConstructorParameters { @@ -74,16 +91,20 @@ const forceNamedChunkIdsTransformer: WebpackConfigTransformer = (config: Webpack //////////////////////////// Host ///////////////////////////// +export interface DefineHostModuleFederationPluginOptions extends ModuleFederationPluginOptions { + features?: Features; +} + // The function return type is mandatory, otherwise we got an error TS4058. export function defineHostModuleFederationPluginOptions(applicationName: string, options: DefineHostModuleFederationPluginOptions): ModuleFederationPluginOptions { const { - router = "react-router", + features = {}, shared = {}, ...rest } = options; - const defaultSharedDependencies = resolveDefaultSharedDependencies(router, true); + const defaultSharedDependencies = resolveDefaultSharedDependencies(features, true); return { name: applicationName, @@ -100,7 +121,7 @@ export function defineHostModuleFederationPluginOptions(applicationName: string, export interface DefineDevHostConfigOptions extends Omit { htmlWebpackPluginOptions?: HtmlWebpackPlugin.Options; - router?: Router; + features?: Features; sharedDependencies?: ModuleFederationPluginOptions["shared"]; moduleFederationPluginOptions?: ModuleFederationPluginOptions; } @@ -112,9 +133,9 @@ export function defineDevHostConfig(swcConfig: SwcConfig, applicationName: strin cache = false, plugins = [], htmlWebpackPluginOptions, - router, + features, sharedDependencies, - moduleFederationPluginOptions = defineHostModuleFederationPluginOptions(applicationName, { router, shared: sharedDependencies }), + moduleFederationPluginOptions = defineHostModuleFederationPluginOptions(applicationName, { features, shared: sharedDependencies }), ...webpackOptions } = options; @@ -134,7 +155,7 @@ export function defineDevHostConfig(swcConfig: SwcConfig, applicationName: strin export interface DefineBuildHostConfigOptions extends Omit { htmlWebpackPluginOptions?: HtmlWebpackPlugin.Options; - router?: Router; + features?: Features; sharedDependencies?: ModuleFederationPluginOptions["shared"]; moduleFederationPluginOptions?: ModuleFederationPluginOptions; } @@ -147,9 +168,9 @@ export function defineBuildHostConfig(swcConfig: SwcConfig, applicationName: str plugins = [], htmlWebpackPluginOptions, transformers = [], - router, + features, sharedDependencies, - moduleFederationPluginOptions = defineHostModuleFederationPluginOptions(applicationName, { router, shared: sharedDependencies }), + moduleFederationPluginOptions = defineHostModuleFederationPluginOptions(applicationName, { features, shared: sharedDependencies }), ...webpackOptions } = options; @@ -173,19 +194,19 @@ export function defineBuildHostConfig(swcConfig: SwcConfig, applicationName: str //////////////////////////// Remote ///////////////////////////// export interface DefineRemoteModuleFederationPluginOptions extends ModuleFederationPluginOptions { - router?: Router; + features?: Features; } // The function return type is mandatory, otherwise we got an error TS4058. export function defineRemoteModuleFederationPluginOptions(applicationName: string, options: DefineRemoteModuleFederationPluginOptions): ModuleFederationPluginOptions { const { - router = "react-router", + features = {}, exposes = {}, shared = {}, ...rest } = options; - const defaultSharedDependencies = resolveDefaultSharedDependencies(router, false); + const defaultSharedDependencies = resolveDefaultSharedDependencies(features, false); return { name: applicationName, @@ -222,7 +243,7 @@ const devRemoteModuleTransformer: WebpackConfigTransformer = (config: WebpackCon }; export interface DefineDevRemoteModuleConfigOptions extends Omit { - router?: Router; + features?: Features; sharedDependencies?: ModuleFederationPluginOptions["shared"]; moduleFederationPluginOptions?: ModuleFederationPluginOptions; } @@ -235,9 +256,9 @@ export function defineDevRemoteModuleConfig(swcConfig: SwcConfig, applicationNam plugins = [], htmlWebpackPlugin = false, transformers = [], - router, + features, sharedDependencies, - moduleFederationPluginOptions = defineRemoteModuleFederationPluginOptions(applicationName, { router, shared: sharedDependencies }), + moduleFederationPluginOptions = defineRemoteModuleFederationPluginOptions(applicationName, { features, shared: sharedDependencies }), ...webpackOptions } = options; @@ -263,7 +284,7 @@ export function defineDevRemoteModuleConfig(swcConfig: SwcConfig, applicationNam } export interface DefineBuildRemoteModuleConfigOptions extends Omit { - router?: Router; + features?: Features; sharedDependencies?: ModuleFederationPluginOptions["shared"]; moduleFederationPluginOptions?: ModuleFederationPluginOptions; } @@ -276,9 +297,9 @@ export function defineBuildRemoteModuleConfig(swcConfig: SwcConfig, applicationN plugins = [], htmlWebpackPlugin = false, transformers = [], - router, + features, sharedDependencies, - moduleFederationPluginOptions = defineRemoteModuleFederationPluginOptions(applicationName, { router, shared: sharedDependencies }), + moduleFederationPluginOptions = defineRemoteModuleFederationPluginOptions(applicationName, { features, shared: sharedDependencies }), ...webpackOptions } = options; diff --git a/packages/webpack-module-federation/src/index.ts b/packages/webpack-module-federation/src/index.ts index 7e006fe98..3d81bc488 100644 --- a/packages/webpack-module-federation/src/index.ts +++ b/packages/webpack-module-federation/src/index.ts @@ -3,5 +3,5 @@ export * from "./loadRemote.ts"; export * from "./registerRemoteModules.ts"; export * from "./remoteDefinition.ts"; export { useAreModulesReady } from "./useAreModulesReady.ts"; -export { areModulesRegistered } from "./useAreModulesRegistered.ts"; +export { useAreModulesRegistered } from "./useAreModulesRegistered.ts"; diff --git a/packages/webpack-module-federation/tests/__snapshots__/defineConfig.test.ts.snap b/packages/webpack-module-federation/tests/__snapshots__/defineConfig.test.ts.snap index d677f77af..886e95d28 100644 --- a/packages/webpack-module-federation/tests/__snapshots__/defineConfig.test.ts.snap +++ b/packages/webpack-module-federation/tests/__snapshots__/defineConfig.test.ts.snap @@ -156,6 +156,47 @@ exports[`defineBuildHostConfig when additional shared dependencies are provided, } `; +exports[`defineBuildHostConfig when msw is activated, add msw shared dependency 1`] = ` +{ + "index": 3, + "plugin": ModuleFederationPlugin { + "_options": { + "name": "host", + "shared": { + "@squide/core": { + "eager": true, + "singleton": true, + }, + "@squide/msw": { + "eager": true, + "singleton": true, + }, + "@squide/react-router": { + "eager": true, + "singleton": true, + }, + "@squide/webpack-module-federation": { + "eager": true, + "singleton": true, + }, + "react": { + "eager": true, + "singleton": true, + }, + "react-dom": { + "eager": true, + "singleton": true, + }, + "react-router-dom": { + "eager": true, + "singleton": true, + }, + }, + }, + }, +} +`; + exports[`defineBuildHostConfig when overriding options are provided for a default shared dependency, use the consumer option 1`] = ` { "index": 3, @@ -394,6 +435,51 @@ exports[`defineBuildRemoteModuleConfig when additional shared dependencies are p } `; +exports[`defineBuildRemoteModuleConfig when msw is activated, add msw shared dependency 1`] = ` +{ + "index": 2, + "plugin": ModuleFederationPlugin { + "_options": { + "exposes": { + "./register": "./src/register", + }, + "filename": "remoteEntry.js", + "name": "remote1", + "shared": { + "@squide/core": { + "eager": undefined, + "singleton": true, + }, + "@squide/msw": { + "eager": undefined, + "singleton": true, + }, + "@squide/react-router": { + "eager": undefined, + "singleton": true, + }, + "@squide/webpack-module-federation": { + "eager": undefined, + "singleton": true, + }, + "react": { + "eager": undefined, + "singleton": true, + }, + "react-dom": { + "eager": undefined, + "singleton": true, + }, + "react-router-dom": { + "eager": undefined, + "singleton": true, + }, + }, + }, + }, +} +`; + exports[`defineBuildRemoteModuleConfig when overriding options are provided for a default shared dependency, use the consumer option 1`] = ` { "index": 2, @@ -624,6 +710,47 @@ exports[`defineDevHostConfig when additional shared dependencies are provided, a } `; +exports[`defineDevHostConfig when msw is activated, add msw shared dependency 1`] = ` +{ + "index": 2, + "plugin": ModuleFederationPlugin { + "_options": { + "name": "host", + "shared": { + "@squide/core": { + "eager": true, + "singleton": true, + }, + "@squide/msw": { + "eager": true, + "singleton": true, + }, + "@squide/react-router": { + "eager": true, + "singleton": true, + }, + "@squide/webpack-module-federation": { + "eager": true, + "singleton": true, + }, + "react": { + "eager": true, + "singleton": true, + }, + "react-dom": { + "eager": true, + "singleton": true, + }, + "react-router-dom": { + "eager": true, + "singleton": true, + }, + }, + }, + }, +} +`; + exports[`defineDevHostConfig when overriding options are provided for a default shared dependency, use the consumer option 1`] = ` { "index": 2, @@ -862,6 +989,51 @@ exports[`defineDevRemoteModuleConfig when additional shared dependencies are pro } `; +exports[`defineDevRemoteModuleConfig when msw is activated, add msw shared dependency 1`] = ` +{ + "index": 1, + "plugin": ModuleFederationPlugin { + "_options": { + "exposes": { + "./register": "./src/register", + }, + "filename": "remoteEntry.js", + "name": "remote1", + "shared": { + "@squide/core": { + "eager": undefined, + "singleton": true, + }, + "@squide/msw": { + "eager": undefined, + "singleton": true, + }, + "@squide/react-router": { + "eager": undefined, + "singleton": true, + }, + "@squide/webpack-module-federation": { + "eager": undefined, + "singleton": true, + }, + "react": { + "eager": undefined, + "singleton": true, + }, + "react-dom": { + "eager": undefined, + "singleton": true, + }, + "react-router-dom": { + "eager": undefined, + "singleton": true, + }, + }, + }, + }, +} +`; + exports[`defineDevRemoteModuleConfig when overriding options are provided for a default shared dependency, use the consumer option 1`] = ` { "index": 1, diff --git a/packages/webpack-module-federation/tests/defineConfig.test.ts b/packages/webpack-module-federation/tests/defineConfig.test.ts index dab9c5e85..48b6c6fbe 100644 --- a/packages/webpack-module-federation/tests/defineConfig.test.ts +++ b/packages/webpack-module-federation/tests/defineConfig.test.ts @@ -100,9 +100,23 @@ describe("defineDevHostConfig", () => { test("when the router is not react-router, do not add react-router shared dependencies", () => { const config = defineDevHostConfig(SwcConfig, "host", 8080, { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - router: "another-router" + features: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + router: "another-router" + } + }); + + const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); + + expect(result).toMatchSnapshot(); + }); + + test("when msw is activated, add msw shared dependency", () => { + const config = defineDevHostConfig(SwcConfig, "host", 8080, { + features: { + msw: true + } }); const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); @@ -208,9 +222,23 @@ describe("defineBuildHostConfig", () => { test("when the router is not react-router, do not add react-router shared dependencies", () => { const config = defineBuildHostConfig(SwcConfig, "host", "http://localhost:8080/", { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - router: "another-router" + features: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + router: "another-router" + } + }); + + const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); + + expect(result).toMatchSnapshot(); + }); + + test("when msw is activated, add msw shared dependency", () => { + const config = defineBuildHostConfig(SwcConfig, "host", "http://localhost:8080/", { + features: { + msw: true + } }); const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); @@ -327,9 +355,23 @@ describe("defineDevRemoteModuleConfig", () => { test("when the router is not react-router, do not add react-router shared dependencies", () => { const config = defineDevRemoteModuleConfig(SwcConfig, "remote1", 8081, { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - router: "another-router" + features: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + router: "another-router" + } + }); + + const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); + + expect(result).toMatchSnapshot(); + }); + + test("when msw is activated, add msw shared dependency", () => { + const config = defineDevRemoteModuleConfig(SwcConfig, "remote1", 8081, { + features: { + msw: true + } }); const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); @@ -444,9 +486,23 @@ describe("defineBuildRemoteModuleConfig", () => { test("when the router is not react-router, do not add react-router shared dependencies", () => { const config = defineBuildRemoteModuleConfig(SwcConfig, "remote1", "http://localhost:8081/", { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - router: "another-router" + features: { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + router: "another-router" + } + }); + + const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); + + expect(result).toMatchSnapshot(); + }); + + test("when msw is activated, add msw shared dependency", () => { + const config = defineBuildRemoteModuleConfig(SwcConfig, "remote1", "http://localhost:8081/", { + features: { + msw: true + } }); const result = findPlugin(config, matchConstructorName(webpack.container.ModuleFederationPlugin.name)); diff --git a/samples/endpoints/host/swc.build.js b/samples/endpoints/host/swc.build.js index 7e0820823..102093c04 100644 --- a/samples/endpoints/host/swc.build.js +++ b/samples/endpoints/host/swc.build.js @@ -4,4 +4,22 @@ import { browserslistToSwc, defineBuildConfig } from "@workleap/swc-configs"; const targets = browserslistToSwc(); -export const swcConfig = defineBuildConfig(targets); +console.log("@@@@@@@@@@@@@@@@@@@@@@", targets); + +function tempTransformer(config) { + // config.minify = false; + // config.jsc.loose = true; + + // config.jsc.transform.react.useBuiltins = false; + // config.jsc.keepClassNames = true; + + // config.jsc.transform.useDefineForClassFields = false; + + config.jsc.minify.mangle = false; + + return config; +} + +export const swcConfig = defineBuildConfig(targets, { + transformers: [tempTransformer] +}); diff --git a/samples/endpoints/host/webpack.build.js b/samples/endpoints/host/webpack.build.js index 28b1cc447..dd4e84a37 100644 --- a/samples/endpoints/host/webpack.build.js +++ b/samples/endpoints/host/webpack.build.js @@ -6,8 +6,19 @@ import { swcConfig } from "./swc.build.js"; // The trailing / is very important, otherwise paths will not be resolved correctly. const publicPath = process.env.NETLIFY === "true" ? "https://squide-endpoints-host.netlify.app/" : "http://localhost:8080/"; +function tempTransformer(config) { + config.optimization.removeEmptyChunks = false; + + return config; +} + export default defineBuildHostConfig(swcConfig, "host", publicPath, { + optimize: false, sharedDependencies: { + "@squide/msw": { + singleton: true, + eager: true + }, "@endpoints/shared": { singleton: true, eager: true @@ -16,6 +27,7 @@ export default defineBuildHostConfig(swcConfig, "host", publicPath, { environmentVariables: { "NETLIFY": process.env.NETLIFY === "true", "USE_MSW": process.env.USE_MSW === "true" - } + }, + transformers: [tempTransformer] }); diff --git a/samples/endpoints/host/webpack.dev.js b/samples/endpoints/host/webpack.dev.js index 2678e2d89..d0136b611 100644 --- a/samples/endpoints/host/webpack.dev.js +++ b/samples/endpoints/host/webpack.dev.js @@ -6,6 +6,10 @@ import { swcConfig } from "./swc.dev.js"; export default defineDevHostConfig(swcConfig, "host", 8080, { overlay: false, sharedDependencies: { + "@squide/msw": { + singleton: true, + eager: true + }, "@endpoints/shared": { singleton: true, eager: true diff --git a/samples/endpoints/remote-module/webpack.build.js b/samples/endpoints/remote-module/webpack.build.js index b3043d1b9..cd1d1f0f0 100644 --- a/samples/endpoints/remote-module/webpack.build.js +++ b/samples/endpoints/remote-module/webpack.build.js @@ -8,6 +8,9 @@ const publicPath = process.env.NETLIFY === "true" ? "https://squide-endpoints-re export default defineBuildRemoteModuleConfig(swcConfig, "remote1", publicPath, { sharedDependencies: { + "@squide/msw": { + singleton: true + }, "@endpoints/shared": { singleton: true } diff --git a/samples/endpoints/remote-module/webpack.dev.js b/samples/endpoints/remote-module/webpack.dev.js index 8e9a76c0d..fff94c830 100644 --- a/samples/endpoints/remote-module/webpack.dev.js +++ b/samples/endpoints/remote-module/webpack.dev.js @@ -10,6 +10,9 @@ let config; if (!process.env.ISOLATED) { config = defineDevRemoteModuleConfig(swcConfig, "remote1", 8081, { sharedDependencies: { + "@squide/msw": { + singleton: true + }, "@endpoints/shared": { singleton: true } From b5295670866d2ad77a94eea1ca93d0bb52029af0 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Wed, 25 Oct 2023 21:11:25 -0400 Subject: [PATCH 11/22] More docs --- docs/reference/default.md | 5 +- .../completeLocalModuleRegistrations.md | 116 ++++++++++++++++ .../completeModuleRegistrations.md | 131 ++++++++++++++++++ .../completeRemoteModuleRegistrations.md | 128 +++++++++++++++++ .../registration/registerLocalModules.md | 76 +++++++++- .../registration/registerRemoteModules.md | 80 ++++++++++- .../registration/registrationStatus.md | 18 --- .../registration/useAreModulesReady.md | 8 +- .../registration/useAreModulesRegistered.md | 106 ++++++++++++++ .../src/completeModuleRegistrations.ts | 21 +-- samples/endpoints/host/swc.build.js | 24 ++-- samples/endpoints/host/webpack.build.js | 10 +- 12 files changed, 666 insertions(+), 57 deletions(-) create mode 100644 docs/reference/registration/completeLocalModuleRegistrations.md create mode 100644 docs/reference/registration/completeModuleRegistrations.md create mode 100644 docs/reference/registration/completeRemoteModuleRegistrations.md delete mode 100644 docs/reference/registration/registrationStatus.md create mode 100644 docs/reference/registration/useAreModulesRegistered.md diff --git a/docs/reference/default.md b/docs/reference/default.md index 0b74801d3..e5a2ab292 100644 --- a/docs/reference/default.md +++ b/docs/reference/default.md @@ -26,7 +26,10 @@ expanded: true - [registerLocalModules](registration/registerLocalModules.md) - [registerRemoteModules](registration/registerRemoteModules.md) -- [registrationStatus](registration/registrationStatus.md) +- [completeModuleRegistrations](registration/completeModuleRegistrations.md) +- [completeLocalModuleRegistrations](registration/completeLocalModuleRegistrations.md) +- [completeRemoteModuleRegistrations](registration/completeRemoteModuleRegistrations.md) +- [useAreModulesRegistered](registration/useAreModulesRegistered.md) - [useAreModulesReady](registration/useAreModulesReady.md) ### Routing diff --git a/docs/reference/registration/completeLocalModuleRegistrations.md b/docs/reference/registration/completeLocalModuleRegistrations.md new file mode 100644 index 000000000..912cb2910 --- /dev/null +++ b/docs/reference/registration/completeLocalModuleRegistrations.md @@ -0,0 +1,116 @@ +--- +toc: + depth: 2-3 +order: 70 +--- + +# completeLocalModuleRegistrations + +Completes the registration process for modules that have been registred using [registerLocalModules](./registerLocalModules.md) by executing the registered **deferred registration** functions. + +!!!info +This function should only be used by applications that support [deferred registrations](./registerLocalModules.md#defer-the-registration-of-routes-or-navigation-items). +!!! + +## Reference + +```ts +completeLocalModuleRegistrations(runtime, data?) +``` + +### Parameters + +- `runtime`: A `Runtime` instance. +- `data`: An optional object with data to forward to the deferred registration functions. + +### Returns + +A `Promise` object with an array of `LocalModuleRegistrationError` if any error happens during the completion of the local modules registration process. + +- `LocalModuleRegistrationError`: + - `error`: The original error object. + +## Usage + +### Complete local module registrations + +```tsx !#15,18 host/src/bootstrap.tsx +import { completeLocalModuleRegistrations, registerLocalModules, Runtime } from "@squide/react-router"; +import { register } from "@sample/local-module"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +await registerLocalModules([register], runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +// Complete the local module registrations with the feature flags data. +await completeLocalModuleRegistrations(runtime, { featureFlags }); +``` + +```tsx !#19-32 local-module/src/register.tsx +import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; +import type { AppContext, FeatureFlags } from "@sample/shared"; +import { AboutPage } from "./AboutPage.tsx"; +import { FeatureAPage } from "./FeatureAPage.tsx"; + +export function register: ModuleRegisterFunction(runtime, context) { + runtime.registerRoute({ + path: "/about", + element: + }); + + runtime.registerNavigationItem({ + $label: "About", + to: "/about" + }); + + // Once the feature flags has been loaded by the host application, by completing the module registrations process, + // the deferred registration function will be called with the feature flags data. + return ({ featureFlags }: { featureFlags: FeatureFlags }) => { + // Only register the "feature-a" route and navigation item if the feature is active. + if (featureFlags.featureA) { + runtime.registerRoute({ + path: "/feature-a", + element: + }); + + runtime.registerNavigationItem({ + $label: "Feature A", + to: "/feature-a" + }); + } + }; +} +``` + +### Handle the completion errors + +```tsx !#17-19 host/src/bootstrap.tsx +import { completeLocalModuleRegistrations, registerLocalModules, Runtime } from "@squide/react-router"; +import { register } from "@sample/local-module"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +await registerLocalModules([register], runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +await (completeLocalModuleRegistrations(runtime, { featureFlags })).forEach(x => { + console.log(x); +}); +``` diff --git a/docs/reference/registration/completeModuleRegistrations.md b/docs/reference/registration/completeModuleRegistrations.md new file mode 100644 index 000000000..3bd9e21d4 --- /dev/null +++ b/docs/reference/registration/completeModuleRegistrations.md @@ -0,0 +1,131 @@ +--- +toc: + depth: 2-3 +order: 80 +--- + +# completeModuleRegistrations + +Completes the registration process for modules that have been registred using [registerLocalModules](./registerLocalModules.md) and [registerRemoteModules](./registerRemoteModules.md) by executing the registered **deferred registration** functions. + +This function serves as a utility for executing both [completeLocalModuleRegistrations](./completeLocalModuleRegistrations.md) and [completeRemoteModuleRegistrations](./completeRemoteModuleRegistrations.md) in a single call. + +## Reference + +```ts +completeModuleRegistrations(runtime, data?) +``` + +### Parameters + +- `runtime`: A `Runtime` instance. +- `data`: An optional object with data to forward to the deferred registration functions. + +### Returns + +- A `Promise` object with the following properties: + - `localModuleErrors`: An array of [LocalModuleRegistrationError](./completeLocalModuleRegistrations.md#returns) if any error happens during the completion of the local modules registration process. + - `remoteModuleErrors`: An array of [RemoteModuleRegistrationError](./completeRemoteModuleRegistrations.md#returns) if any error happens during the completion of the remote modules registration process. + +## Usage + +### Complete module registrations + +```tsx !#16-17,24 host/src/bootstrap.tsx +import { completeLocalModuleRegistrations, registerLocalModules, Runtime } from "@squide/react-router"; +import { completeRemoteModuleRegistrations, registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; +import { register } from "@sample/local-module"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +const Remotes: RemoteDefinition = [ + { name: "remote1", url: "http://localhost:8081" } +]; + +await registerLocalModules([register], runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +// Complete the local module and remote module registrations with the feature flags data. +await completeModuleRegistrations(runtime, { featureFlags }); +``` + +```tsx !#19-32 remote-module/src/register.tsx +import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; +import type { AppContext, FeatureFlags } from "@sample/shared"; +import { AboutPage } from "./AboutPage.tsx"; +import { FeatureAPage } from "./FeatureAPage.tsx"; + +export function register: ModuleRegisterFunction(runtime, context) { + runtime.registerRoute({ + path: "/about", + element: + }); + + runtime.registerNavigationItem({ + $label: "About", + to: "/about" + }); + + // Once the feature flags has been loaded by the host application, by completing the module registrations process, + // the deferred registration function will be called with the feature flags data. + return ({ featureFlags }: { featureFlags: FeatureFlags }) => { + // Only register the "feature-a" route and navigation item if the feature is active. + if (featureFlags.featureA) { + runtime.registerRoute({ + path: "/feature-a", + element: + }); + + runtime.registerNavigationItem({ + $label: "Feature A", + to: "/feature-a" + }); + } + }; +} +``` + +### Handle the completion errors + +```tsx !#23-31 host/src/bootstrap.tsx +import { completeLocalModuleRegistrations, registerLocalModules, Runtime } from "@squide/react-router"; +import { completeRemoteModuleRegistrations, registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; +import { register } from "@sample/local-module"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +const Remotes: RemoteDefinition = [ + { name: "remote1", url: "http://localhost:8081" } +]; + +await registerLocalModules([register], runtime, { context }); +await registerRemoteModules(Remotes, runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +const errors = await completeModuleRegistrations(runtime, { featureFlags }); + +errors.localModuleErrors.forEach(x => { + console.log(x); +}); + +errors.remoteModuleErrors.forEach(x => { + console.log(x); +}); +``` diff --git a/docs/reference/registration/completeRemoteModuleRegistrations.md b/docs/reference/registration/completeRemoteModuleRegistrations.md new file mode 100644 index 000000000..4157188d6 --- /dev/null +++ b/docs/reference/registration/completeRemoteModuleRegistrations.md @@ -0,0 +1,128 @@ +--- +toc: + depth: 2-3 +order: 60 +--- + +# completeRemoteModuleRegistrations + +Completes the registration process for modules that have been registred using [registerRemoteModules](./registerRemoteModules.md) by executing the registered **deferred registration** functions. + +!!!info +This function should only be used by applications that support [deferred registrations](./registerRemoteModules.md#defer-the-registration-of-routes-or-navigation-items). +!!! + +## Reference + +```ts +completeRemoteModuleRegistrations(runtime, data?) +``` + +### Parameters + +- `runtime`: A `Runtime` instance. +- `data`: An optional object with data to forward to the deferred registration functions. + +### Returns + +A `Promise` object with an array of `RemoteModuleRegistrationError` if any error happens during the completion of the remote modules registration process. + +- `RemoteModuleRegistrationError`: + - `url`: The URL of the module federation remote that failed to load. + - `containerName`: The name of the [dynamic container](https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers) that Squide attempted to recover. + - `moduleName`: The name of the module that Squide attempted to recover. + - `error`: The original error object. + +## Usage + +### Complete remote module registrations + +```tsx !#19,22 host/src/bootstrap.tsx +import { Runtime } from "@squide/react-router"; +import { completeRemoteModuleRegistrations, registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +const Remotes: RemoteDefinition = [ + { name: "remote1", url: "http://localhost:8081" } +]; + +await registerRemoteModules(Remotes, runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +// Complete the remote module registrations with the feature flags data. +await completeRemoteModuleRegistrations(runtime, { featureFlags }); +``` + +```tsx !#19-32 remote-module/src/register.tsx +import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; +import type { AppContext, FeatureFlags } from "@sample/shared"; +import { AboutPage } from "./AboutPage.tsx"; +import { FeatureAPage } from "./FeatureAPage.tsx"; + +export function register: ModuleRegisterFunction(runtime, context) { + runtime.registerRoute({ + path: "/about", + element: + }); + + runtime.registerNavigationItem({ + $label: "About", + to: "/about" + }); + + // Once the feature flags has been loaded by the host application, by completing the module registrations process, + // the deferred registration function will be called with the feature flags data. + return ({ featureFlags }: { featureFlags: FeatureFlags }) => { + // Only register the "feature-a" route and navigation item if the feature is active. + if (featureFlags.featureA) { + runtime.registerRoute({ + path: "/feature-a", + element: + }); + + runtime.registerNavigationItem({ + $label: "Feature A", + to: "/feature-a" + }); + } + }; +} +``` + +### Handle the completion errors + +```tsx !#21-23 host/src/bootstrap.tsx +import { Runtime } from "@squide/react-router"; +import { completeRemoteModuleRegistrations, registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +const Remotes: RemoteDefinition = [ + { name: "remote1", url: "http://localhost:8081" } +]; + +await registerRemoteModules(Remotes, runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +await (completeRemoteModuleRegistrations(runtime, { featureFlags })).forEach(x => { + console.log(x); +}); +``` + diff --git a/docs/reference/registration/registerLocalModules.md b/docs/reference/registration/registerLocalModules.md index 804202175..095706dbb 100644 --- a/docs/reference/registration/registerLocalModules.md +++ b/docs/reference/registration/registerLocalModules.md @@ -1,11 +1,12 @@ --- toc: depth: 2-3 +order: 100 --- # registerLocalModules -Register one or many local module(s). During the registration process, the specified registration function will be invoked with a `Runtime` instance and an optional `context` object. +Register one or many local module(s). During the registration process, the specified registration function will be invoked with a `Runtime` instance and an optional `context` object. To **defer the registration** of specific routes or navigation items, a registration function can return an anonymous function. > A local module is a regular module that is part of the **host application build** and is bundled at build time, as opposed to remote module which is loaded at runtime from a remote server. Local modules are particularly valuable when undergoing a **migration** from a monolithic application to a federated application or when **launching a new product** with an evolving business domain. @@ -50,12 +51,12 @@ await registerLocalModules([register], runtime, { context }); ```tsx !#5-15 local-module/src/register.tsx import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import type { AppContext } from "@sample/shared"; -import { About } from "./About.tsx"; +import { AboutPage } from "./AboutPage.tsx"; export function register: ModuleRegisterFunction(runtime, context) { runtime.registerRoute({ path: "/about", - element: + element: }); runtime.registerNavigationItem({ @@ -65,6 +66,75 @@ export function register: ModuleRegisterFunction(runtime, c } ``` +### Defer the registration of routes or navigation items + +Sometimes, data must be fetched to determine which routes or navigation items should be registered by a given module. To address this, Squide offers a **two-phase registration mechanism**: + +1. The first phase allows modules to register their routes and navigation items that are not dependent on initial data (in addition to their MSW request handlers when fake endpoints are available). + +2. The second phase enables modules to register routes and navigation items that are dependent on initial data. Such a use case would be determining whether a route should be registered based on a feature flag. We refer to this second phase as **deferred registrations**. + +To defer a registration to the second phase, a module registration function can **return an anonymous function**. Once the modules are registered and the [completeLocalModuleRegistrations](./completeLocalModuleRegistrations.md) function is called, the deferred registration functions will be executed. + +```tsx !#15,18 host/src/bootstrap.tsx +import { completeLocalModuleRegistrations, registerLocalModules, Runtime } from "@squide/react-router"; +import { register } from "@sample/local-module"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +await registerLocalModules([register], runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +// Complete the module registrations with the feature flags data. +await completeLocalModuleRegistrations(runtime, { featureFlags }); +``` + +```tsx !#19-32 local-module/src/register.tsx +import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; +import type { AppContext, FeatureFlags } from "@sample/shared"; +import { AboutPage } from "./AboutPage.tsx"; +import { FeatureAPage } from "./FeatureAPage.tsx"; + +export function register: ModuleRegisterFunction(runtime, context) { + runtime.registerRoute({ + path: "/about", + element: + }); + + runtime.registerNavigationItem({ + $label: "About", + to: "/about" + }); + + // Once the feature flags has been loaded by the host application, by completing the module registrations process, + // the deferred registration function will be called with the feature flags data. + return ({ featureFlags }: { featureFlags: FeatureFlags }) => { + // Only register the "feature-a" route and navigation item if the feature is active. + if (featureFlags.featureA) { + runtime.registerRoute({ + path: "/feature-a", + element: + }); + + runtime.registerNavigationItem({ + $label: "Feature A", + to: "/feature-a" + }); + } + }; +} +``` + +[!ref completeLocalModuleRegistrations](./completeLocalModuleRegistrations.md) + ### Handle the registration errors ```tsx !#11-13 host/src/bootstrap.tsx diff --git a/docs/reference/registration/registerRemoteModules.md b/docs/reference/registration/registerRemoteModules.md index d8e02f8e4..546fb6f01 100644 --- a/docs/reference/registration/registerRemoteModules.md +++ b/docs/reference/registration/registerRemoteModules.md @@ -1,11 +1,12 @@ --- toc: depth: 2-3 +order: 90 --- # registerRemoteModules -Register one or many remote module(s). During the registration process, the module `register` function will be invoked with a `Runtime` instance and an optional `context` object. +Register one or many remote module(s). During the registration process, the module `register` function will be invoked with a `Runtime` instance and an optional `context` object. To **defer the registration** of specific routes or navigation items, a registration function can return an anonymous function. > A remote module is a module that is not part of the current build but is **loaded at runtime** from a remote server. @@ -57,12 +58,12 @@ await registerRemoteModules(Remotes, runtime, { context }); ```tsx !#5-15 remote-module/src/register.tsx import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import type { AppContext } from "@sample/shared"; -import { About } from "./About.tsx"; +import { AboutPage } from "./AboutPage.tsx"; export function register: ModuleRegisterFunction(runtime, context) { runtime.registerRoute({ path: "/about", - element: + element: }); runtime.registerNavigationItem({ @@ -72,6 +73,79 @@ export function register: ModuleRegisterFunction(runtime, c } ``` +### Defer the registration of routes or navigation items + +Sometimes, data must be fetched to determine which routes or navigation items should be registered by a given module. To address this, Squide offers a **two-phase registration mechanism**: + +1. The first phase allows modules to register their routes and navigation items that are not dependent on initial data (in addition to their MSW request handlers when fake endpoints are available). + +2. The second phase enables modules to register routes and navigation items that are dependent on initial data. Such a use case would be determining whether a route should be registered based on a feature flag. We refer to this second phase as **deferred registrations**. + +To defer a registration to the second phase, a module registration function can **return an anonymous function**. Once the modules are registered and the [completeRemoteModuleRegistrations](./completeRemoteModuleRegistrations.md) function is called, the deferred registration functions will be executed. + +```tsx !#19,22 host/src/bootstrap.tsx +import { Runtime } from "@squide/react-router"; +import { completeRemoteModuleRegistrations, registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; +import { fetchFeatureFlags, type AppContext } from "@sample/shared"; + +const runtime = new Runtime(); + +const context: AppContext = { + name: "Test app" +}; + +const Remotes: RemoteDefinition = [ + { name: "remote1", url: "http://localhost:8081" } +]; + +await registerRemoteModules(Remotes, runtime, { context }); + +// Don't fetch data in the bootstrapping code for a real application. This is done here +// strictly for demonstration purpose. +const featureFlags = await fetchFeatureFlags(); + +// Complete the module registrations with the feature flags data. +await completeRemoteModuleRegistrations(runtime, { featureFlags }); +``` + +```tsx !#19-32 remote-module/src/register.tsx +import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; +import type { AppContext, FeatureFlags } from "@sample/shared"; +import { AboutPage } from "./AboutPage.tsx"; +import { FeatureAPage } from "./FeatureAPage.tsx"; + +export function register: ModuleRegisterFunction(runtime, context) { + runtime.registerRoute({ + path: "/about", + element: + }); + + runtime.registerNavigationItem({ + $label: "About", + to: "/about" + }); + + // Once the feature flags has been loaded by the host application, by completing the module registrations process, + // the deferred registration function will be called with the feature flags data. + return ({ featureFlags }: { featureFlags: FeatureFlags }) => { + // Only register the "feature-a" route and navigation item if the feature is active. + if (featureFlags.featureA) { + runtime.registerRoute({ + path: "/feature-a", + element: + }); + + runtime.registerNavigationItem({ + $label: "Feature A", + to: "/feature-a" + }); + } + }; +} +``` + +[!ref completeRemoteModuleRegistrations](./completeRemoteModuleRegistrations.md) + ### Handle the registration errors ```tsx !#15-17 host/src/bootstrap.tsx diff --git a/docs/reference/registration/registrationStatus.md b/docs/reference/registration/registrationStatus.md deleted file mode 100644 index 3a6e9331d..000000000 --- a/docs/reference/registration/registrationStatus.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -toc: - depth: 2-3 ---- - -# registrationStatus - -Variable indicating whether or not the remote modules registration process is completed. - -## Usage - -```ts -import { registrationStatus } from "@squide/webpack-module-federation"; - -if (registrationStatus !== "ready") { - // do something... -} -``` diff --git a/docs/reference/registration/useAreModulesReady.md b/docs/reference/registration/useAreModulesReady.md index 090b4f9c6..9ecbd5ece 100644 --- a/docs/reference/registration/useAreModulesReady.md +++ b/docs/reference/registration/useAreModulesReady.md @@ -5,7 +5,11 @@ toc: # useAreModulesReady -Force the application to re-render once all the modules are registered. Without this hook, the page is rendered with an empty router as it happens before the remote modules registered their routes and navigation items. +Force the application to re-render once the registration process has been completed for all the modules. Without this hook, the page is rendered with an empty router as it happens before the remote modules registered their routes and navigation items. + +!!!info +If your application supports [deferred registrations](./registerRemoteModules.md#defer-the-registration-of-routes-or-navigation-items), make sure to pair this hook with the [useAreModulesRegistered](./useAreModulesRegistered.md) hook. +!!! ## Reference @@ -20,7 +24,7 @@ const areModulesReady = useAreModulesReady(options?: { interval? }) ### Returns -A boolean indicating if the registration is completed. +A boolean indicating if the registration process is completed. ## Usage diff --git a/docs/reference/registration/useAreModulesRegistered.md b/docs/reference/registration/useAreModulesRegistered.md new file mode 100644 index 000000000..0b576d1f2 --- /dev/null +++ b/docs/reference/registration/useAreModulesRegistered.md @@ -0,0 +1,106 @@ +--- +toc: + depth: 2-3 +order: 50 +--- + +# useAreModulesRegistered + +Force the application to re-render once all the modules are registered. + +!!!info +This hook should only be used by applications that support [deferred registrations](./registerRemoteModules.md#defer-the-registration-of-routes-or-navigation-items) and should be pair with the [useAreModulesReady](./useAreModulesReady.md) hook. +!!! + +## Reference + +```ts +const areModulesRegistered = useAreModulesRegistered(options?: { interval? }) +``` + +### Parameters + +- `options`: An optional object literal of options: + - `interval`: The interval in milliseconds at which the hook is validating if the registration process is completed. + +### Returns + +A boolean indicating if the modules are registered. + +## Usage + +```tsx !#13-14 host/src/bootstrap.tsx +import { createRoot } from "react"; +import { registerLocalModules, Runtime } from "@squide/react-router"; +import { registerRemoteModules, type RemoteDefinition } from "@squide/webpack-module-federation"; +import { register } from "@sample/local-module"; +import { App } from "./App.tsx"; + +const runtime = new Runtime(); + +const Remotes: RemoteDefinition = [ + { name: "remote1", url: "http://localhost:8081" } +]; + +await registerLocalModules([register], runtime, { context }); +await registerRemoteModules(Remotes, runtime); + +const root = createRoot(document.getElementById("root")!); + +root.render( + + + +); +``` + +```tsx !#11,18-30 host/src/App.tsx +import { useMemo, useEffect } from "react"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; +import { completeModuleRegistrations, useAreModulesRegistered, useAreModulesReady } from "@squide/webpack-module-federation"; +import { useRoutes, useRuntime } from "@squide/react-router"; +import { fetchFeatureFlags, type FeatureFlags } from "@sample/shared"; + +export function App() { + const runtime = useRuntime(); + + // Re-render the application once all the modules are registered. + const areModulesRegistered = useAreModulesRegistered(); + + // Re-render the application once all the modules are registered. + // Otherwise, the remotes routes won't be added to the router as the router will be + // rendered before the remote modules registered their routes. + const areModulesReady = useAreModulesReady(); + + useEffect(() => { + // Once the modules are registered, fetch the feature flags data. + // The feature flags data cannot be fetched before the modules are registered because in development, + // it might be one of the modules that register the MSW request handlers for the feature flags data. + if (areModulesRegistered) { + fetchFeatureFlags() + .then(({ data }: { data?: FeatureFlags }) => { + // Execute the deferred registration functions with the feature flags data to complete + // the registration process. + completeModuleRegistrations(runtime, { featureFlags: data }); + }); + } + }, [runtime, areModulesRegistered]); + + const routes = useRoutes(); + + const router = useMemo(() => { + return createBrowserRouter(routes); + }, [routes]); + + if (!areModulesReady) { + return
Loading...
; + } + + return ( + Loading...} + /> + ); +} +``` diff --git a/packages/webpack-module-federation/src/completeModuleRegistrations.ts b/packages/webpack-module-federation/src/completeModuleRegistrations.ts index 62a0cdccf..30c58be38 100644 --- a/packages/webpack-module-federation/src/completeModuleRegistrations.ts +++ b/packages/webpack-module-federation/src/completeModuleRegistrations.ts @@ -1,15 +1,20 @@ -import { completeLocalModuleRegistrations, type AbstractRuntime, type LocalModuleRegistrationError } from "@squide/core"; -import { completeRemoteModuleRegistrations, type RemoteModuleRegistrationError } from "./registerRemoteModules.ts"; +import { completeLocalModuleRegistrations, getLocalModuleRegistrationStatus, type AbstractRuntime, type LocalModuleRegistrationError } from "@squide/core"; +import { completeRemoteModuleRegistrations, getRemoteModuleRegistrationStatus, type RemoteModuleRegistrationError } from "./registerRemoteModules.ts"; export function completeModuleRegistrations(runtime: TRuntime, data?: TData) { - const promise: Promise[] = [ - completeLocalModuleRegistrations(runtime, data), - completeRemoteModuleRegistrations(runtime, data) - ]; + const promise: Promise[] = []; - return Promise.allSettled(promise).then(([locaModuleErrors, remoteModuleErrors]) => { + if (getLocalModuleRegistrationStatus() !== "none") { + promise.push(completeLocalModuleRegistrations(runtime, data)); + } + + if (getRemoteModuleRegistrationStatus() !== "none") { + promise.push(completeRemoteModuleRegistrations(runtime, data)); + } + + return Promise.allSettled(promise).then(([localModuleErrors, remoteModuleErrors]) => { return { - locaModuleErrors: locaModuleErrors as unknown as LocalModuleRegistrationError, + localModuleErrors: localModuleErrors as unknown as LocalModuleRegistrationError, remoteModuleErrors: remoteModuleErrors as unknown as RemoteModuleRegistrationError }; }); diff --git a/samples/endpoints/host/swc.build.js b/samples/endpoints/host/swc.build.js index 102093c04..670cf1bb4 100644 --- a/samples/endpoints/host/swc.build.js +++ b/samples/endpoints/host/swc.build.js @@ -4,22 +4,20 @@ import { browserslistToSwc, defineBuildConfig } from "@workleap/swc-configs"; const targets = browserslistToSwc(); -console.log("@@@@@@@@@@@@@@@@@@@@@@", targets); - -function tempTransformer(config) { - // config.minify = false; - // config.jsc.loose = true; - - // config.jsc.transform.react.useBuiltins = false; - // config.jsc.keepClassNames = true; - - // config.jsc.transform.useDefineForClassFields = false; - - config.jsc.minify.mangle = false; +/** + * Temporary transformer to enable loose mode until https://github.com/swc-project/swc/issues/8178 is fixed. + * @typedef {import("@workleap/swc-configs").SwcConfig} SwcConfig + * @param {SwcConfig} config + * @returns {SwcConfig} + */ +function temporaryEnablingLooseMode(config) { + if (config && config.jsc) { + config.jsc.loose = true; + } return config; } export const swcConfig = defineBuildConfig(targets, { - transformers: [tempTransformer] + transformers: [temporaryEnablingLooseMode] }); diff --git a/samples/endpoints/host/webpack.build.js b/samples/endpoints/host/webpack.build.js index dd4e84a37..c15f05a10 100644 --- a/samples/endpoints/host/webpack.build.js +++ b/samples/endpoints/host/webpack.build.js @@ -6,14 +6,7 @@ import { swcConfig } from "./swc.build.js"; // The trailing / is very important, otherwise paths will not be resolved correctly. const publicPath = process.env.NETLIFY === "true" ? "https://squide-endpoints-host.netlify.app/" : "http://localhost:8080/"; -function tempTransformer(config) { - config.optimization.removeEmptyChunks = false; - - return config; -} - export default defineBuildHostConfig(swcConfig, "host", publicPath, { - optimize: false, sharedDependencies: { "@squide/msw": { singleton: true, @@ -27,7 +20,6 @@ export default defineBuildHostConfig(swcConfig, "host", publicPath, { environmentVariables: { "NETLIFY": process.env.NETLIFY === "true", "USE_MSW": process.env.USE_MSW === "true" - }, - transformers: [tempTransformer] + } }); From 29a4ce3f3794df5e7b198537c01891d3faef7c66 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Wed, 25 Oct 2023 22:36:42 -0400 Subject: [PATCH 12/22] More docs --- docs/guides/add-a-shared-dependency.md | 2 +- docs/guides/add-authentication.md | 2 +- docs/guides/default.md | 18 +++++++++--------- docs/guides/develop-a-module-in-isolation.md | 2 +- docs/guides/federated-tabs.md | 2 +- docs/guides/implement-a-custom-logger.md | 2 +- docs/guides/migrating-from-a-monolith.md | 2 +- docs/guides/override-a-react-context.md | 2 +- .../routing/useIsRouteMatchProtected.md | 9 +++++++-- docs/reference/runtime/runtime-class.md | 16 ++++++++++------ samples/endpoints/host/webpack.build.js | 7 +++---- samples/endpoints/host/webpack.dev.js | 7 +++---- .../endpoints/remote-module/webpack.build.js | 6 +++--- samples/endpoints/remote-module/webpack.dev.js | 6 +++--- 14 files changed, 45 insertions(+), 38 deletions(-) diff --git a/docs/guides/add-a-shared-dependency.md b/docs/guides/add-a-shared-dependency.md index 330675d36..bebed6aa6 100644 --- a/docs/guides/add-a-shared-dependency.md +++ b/docs/guides/add-a-shared-dependency.md @@ -57,7 +57,7 @@ If the version difference between a host application and a remote module is a ** Libraries matching the following criterias are strong candidates to be configured as shared dependencies: -- Medium to large libraries that are used by multiple modules.. +- Medium to large libraries that are used by multiple modules. - Libraries that requires a [single instance](#react-dependencies-requirements) to work properly (like `react`). - Libraries exporting [React contexts](#react-context-limitations). diff --git a/docs/guides/add-authentication.md b/docs/guides/add-authentication.md index 60a5b9ab9..0f35dbacb 100644 --- a/docs/guides/add-authentication.md +++ b/docs/guides/add-authentication.md @@ -263,7 +263,7 @@ export const registerHost: ModuleRegisterFunction = runtime => { children: [ { // The root error boundary is a named route to be able to nest - // the loging / logout page under it with the "parentName" optioné + // the loging / logout page under it with the "parentName" option. $name: "root-error-boundary", errorElement: , children: [ diff --git a/docs/guides/default.md b/docs/guides/default.md index 508e57a69..03b77654b 100644 --- a/docs/guides/default.md +++ b/docs/guides/default.md @@ -6,12 +6,12 @@ expanded: true # Guides -- [Override the host layout](override-the-host-layout.md) -- [Isolate module failures](isolate-module-failures.md) -- [Add authentication](add-authentication.md) -- [Implement a custom logger](implement-a-custom-logger.md) -- [Develop a module in isolation](develop-a-module-in-isolation.md) -- [Federated tabs](federated-tabs.md) -- [Add a shared dependency](add-a-shared-dependency.md) -- [Override a React context](override-a-react-context.md) -- [Migrating from a monolithic application](migrating-from-a-monolith.md) +- [Override the host layout](./override-the-host-layout.md) +- [Isolate module failures](./isolate-module-failures.md) +- [Add authentication](./add-authentication.md) +- [Develop a module in isolation](./develop-a-module-in-isolation.md) +- [Federated tabs](./federated-tabs.md) +- [Add a shared dependency](./add-a-shared-dependency.md) +- [Implement a custom logger](./implement-a-custom-logger.md) +- [Override a React context](./override-a-react-context.md) +- [Migrating from a monolithic application](./migrating-from-a-monolith.md) diff --git a/docs/guides/develop-a-module-in-isolation.md b/docs/guides/develop-a-module-in-isolation.md index 4c2c9f99f..352037d19 100644 --- a/docs/guides/develop-a-module-in-isolation.md +++ b/docs/guides/develop-a-module-in-isolation.md @@ -1,5 +1,5 @@ --- -order: 50 +order: 60 --- # Develop a module in isolation diff --git a/docs/guides/federated-tabs.md b/docs/guides/federated-tabs.md index 659d97551..ce0f25e3a 100644 --- a/docs/guides/federated-tabs.md +++ b/docs/guides/federated-tabs.md @@ -1,5 +1,5 @@ --- -order: 40 +order: 50 --- # Federated tabs diff --git a/docs/guides/implement-a-custom-logger.md b/docs/guides/implement-a-custom-logger.md index b1f05c30e..291a035a5 100644 --- a/docs/guides/implement-a-custom-logger.md +++ b/docs/guides/implement-a-custom-logger.md @@ -1,5 +1,5 @@ --- -order: 60 +order: 20 --- # Implement a custom logger diff --git a/docs/guides/migrating-from-a-monolith.md b/docs/guides/migrating-from-a-monolith.md index 82e000eb1..43dc188d6 100644 --- a/docs/guides/migrating-from-a-monolith.md +++ b/docs/guides/migrating-from-a-monolith.md @@ -1,5 +1,5 @@ --- -order: 10 +order: 0 label: Migrating from a monolith --- diff --git a/docs/guides/override-a-react-context.md b/docs/guides/override-a-react-context.md index be5b00866..c184ae317 100644 --- a/docs/guides/override-a-react-context.md +++ b/docs/guides/override-a-react-context.md @@ -1,5 +1,5 @@ --- -order: 20 +order: 10 label: Override a React context --- diff --git a/docs/reference/routing/useIsRouteMatchProtected.md b/docs/reference/routing/useIsRouteMatchProtected.md index e06d537e4..250b14e45 100644 --- a/docs/reference/routing/useIsRouteMatchProtected.md +++ b/docs/reference/routing/useIsRouteMatchProtected.md @@ -5,7 +5,9 @@ toc: # useIsRouteMatchProtected -Execute [React Router's matching algorithm](https://reactrouter.com/en/main/utils/match-routes) against the registered routes and a given `location` to determine if any route match the location and whether or not that matching route is protected. +Execute [React Router's matching algorithm](https://reactrouter.com/en/main/utils/match-routes) against the registered routes and a given `location` to determine if any route match the location and whether or not that matching route is `protected`. + +To take advantage of this hook, make sure to add a [$visibility hint](../runtime/runtime-class.md#register-a-public-route) to your public pages. ## Reference @@ -19,7 +21,7 @@ const isProtected = useIsRouteMatchProtected(locationArg) ### Returns -A `boolean` value indicating whether or not the matching route is protected. If no route match the given location, an `Error` is thrown. +A `boolean` value indicating whether or not the matching route is `protected`. If no route match the given location, an `Error` is thrown. ## Usage @@ -30,6 +32,8 @@ import { useLocation } from "react-router-dom"; import { useIsRouteMatchProtected } from "@squide/react-router"; const location = useLocation(); + +// Returns true if the matching route doesn't have a $visibility: "public" property. const isActiveRouteProtected = useIsRouteMatchProtected(location); ``` @@ -38,5 +42,6 @@ const isActiveRouteProtected = useIsRouteMatchProtected(location); ```ts import { useIsRouteMatchProtected } from "@squide/react-router"; +// Returns true if the matching route doesn't have a $visibility: "public" property. const isActiveRouteProtected = useIsRouteMatchProtected(window.location); ``` diff --git a/docs/reference/runtime/runtime-class.md b/docs/reference/runtime/runtime-class.md index c23787c58..15c01f2dd 100644 --- a/docs/reference/runtime/runtime-class.md +++ b/docs/reference/runtime/runtime-class.md @@ -102,10 +102,6 @@ By declaring a page as hoisted, the page will be rendered at the root of the rou ### Register a route with a different layout -!!!info -For a detailed walkthrough, read the guide on [how to override the host layout](/guides/override-the-host-layout.md). -!!! - ```tsx !#9,12,22 import { Page } from "./Page.tsx"; import { RemoteLayout } from "./RemoteLayout.tsx"; @@ -132,9 +128,11 @@ runtime.registerRoute({ }); ``` +[!ref text="For a detailed walkthrough, read the how to override the host layout guide"](../../guides/override-the-host-layout.md) + ### Register a public route -When registering a route, a hint can be provided, indicating if the route is intended to be displayed as a `public` or `protected` route. This is especially useful when dealing with code that conditionally fetch data for protected routes (e.g. a session). Don't forget to mark the route as hoisted with the `host` option if the route is nested under an authentication boundary. +When registering a route, a hint can be provided, indicating if the route is intended to be displayed as a `public` or `protected` route. This is especially useful when dealing with code that conditionally fetch data for protected routes (e.g. a session). ```tsx !#4,8 import { Page } from "./Page.tsx"; @@ -170,8 +168,14 @@ runtime.registerRoute({ }); ``` +If the route is nested under an authentication boundary, don't forget to either mark the route as [hoisted](#register-an-hoisted-route) or to [nest the route](#register-nested-routes-under-an-existing-route) under a public parent. + +!!!info +A `$visibility` hint only takes effect if your application is using the [useIsRouteMatchProtected](../routing/useIsRouteMatchProtected.md) hook. +!!! + !!!info -When no visibility hint is provided, a route is considered `protected`. +When no `$visibility` hint is provided, a route is considered `protected`. !!! ### Register a named route diff --git a/samples/endpoints/host/webpack.build.js b/samples/endpoints/host/webpack.build.js index c15f05a10..5f6dae879 100644 --- a/samples/endpoints/host/webpack.build.js +++ b/samples/endpoints/host/webpack.build.js @@ -7,11 +7,10 @@ import { swcConfig } from "./swc.build.js"; const publicPath = process.env.NETLIFY === "true" ? "https://squide-endpoints-host.netlify.app/" : "http://localhost:8080/"; export default defineBuildHostConfig(swcConfig, "host", publicPath, { + features: { + msw: true + }, sharedDependencies: { - "@squide/msw": { - singleton: true, - eager: true - }, "@endpoints/shared": { singleton: true, eager: true diff --git a/samples/endpoints/host/webpack.dev.js b/samples/endpoints/host/webpack.dev.js index d0136b611..9e416b0c0 100644 --- a/samples/endpoints/host/webpack.dev.js +++ b/samples/endpoints/host/webpack.dev.js @@ -5,11 +5,10 @@ import { swcConfig } from "./swc.dev.js"; export default defineDevHostConfig(swcConfig, "host", 8080, { overlay: false, + features: { + msw: true + }, sharedDependencies: { - "@squide/msw": { - singleton: true, - eager: true - }, "@endpoints/shared": { singleton: true, eager: true diff --git a/samples/endpoints/remote-module/webpack.build.js b/samples/endpoints/remote-module/webpack.build.js index cd1d1f0f0..71ce75458 100644 --- a/samples/endpoints/remote-module/webpack.build.js +++ b/samples/endpoints/remote-module/webpack.build.js @@ -7,10 +7,10 @@ import { swcConfig } from "./swc.build.js"; const publicPath = process.env.NETLIFY === "true" ? "https://squide-endpoints-remote-module.netlify.app/" : "http://localhost:8081/"; export default defineBuildRemoteModuleConfig(swcConfig, "remote1", publicPath, { + features: { + msw: true + }, sharedDependencies: { - "@squide/msw": { - singleton: true - }, "@endpoints/shared": { singleton: true } diff --git a/samples/endpoints/remote-module/webpack.dev.js b/samples/endpoints/remote-module/webpack.dev.js index fff94c830..6a6146aa0 100644 --- a/samples/endpoints/remote-module/webpack.dev.js +++ b/samples/endpoints/remote-module/webpack.dev.js @@ -9,10 +9,10 @@ let config; if (!process.env.ISOLATED) { config = defineDevRemoteModuleConfig(swcConfig, "remote1", 8081, { + features: { + msw: true + }, sharedDependencies: { - "@squide/msw": { - singleton: true - }, "@endpoints/shared": { singleton: true } From 0c0b640a5574ca5a420b1dac7067550c3e491023 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Wed, 25 Oct 2023 22:47:21 -0400 Subject: [PATCH 13/22] More docs --- docs/guides/add-a-shared-dependency.md | 2 +- docs/guides/default.md | 2 +- docs/guides/implement-a-custom-logger.md | 2 +- docs/guides/override-the-host-layout.md | 2 +- docs/reference/routing/ManagedRoutes.md | 15 +++------------ docs/reference/runtime/runtime-class.md | 2 +- 6 files changed, 8 insertions(+), 17 deletions(-) diff --git a/docs/guides/add-a-shared-dependency.md b/docs/guides/add-a-shared-dependency.md index bebed6aa6..27c528de9 100644 --- a/docs/guides/add-a-shared-dependency.md +++ b/docs/guides/add-a-shared-dependency.md @@ -1,5 +1,5 @@ --- -order: 30 +order: 20 label: Add a shared dependency --- diff --git a/docs/guides/default.md b/docs/guides/default.md index 03b77654b..855b6b557 100644 --- a/docs/guides/default.md +++ b/docs/guides/default.md @@ -11,7 +11,7 @@ expanded: true - [Add authentication](./add-authentication.md) - [Develop a module in isolation](./develop-a-module-in-isolation.md) - [Federated tabs](./federated-tabs.md) -- [Add a shared dependency](./add-a-shared-dependency.md) - [Implement a custom logger](./implement-a-custom-logger.md) +- [Add a shared dependency](./add-a-shared-dependency.md) - [Override a React context](./override-a-react-context.md) - [Migrating from a monolithic application](./migrating-from-a-monolith.md) diff --git a/docs/guides/implement-a-custom-logger.md b/docs/guides/implement-a-custom-logger.md index 291a035a5..446025739 100644 --- a/docs/guides/implement-a-custom-logger.md +++ b/docs/guides/implement-a-custom-logger.md @@ -1,5 +1,5 @@ --- -order: 20 +order: 30 --- # Implement a custom logger diff --git a/docs/guides/override-the-host-layout.md b/docs/guides/override-the-host-layout.md index 5aa1accbe..aa9a5ec31 100644 --- a/docs/guides/override-the-host-layout.md +++ b/docs/guides/override-the-host-layout.md @@ -71,7 +71,7 @@ export function RootLayout() { } ``` -In the previous code sample, the `RootLayout` serves as the default layout for the homepage as well as for every page (route) registered by a module that are not nested with either the [parentPath](../reference/runtime/runtime-class.md#register-nested-routes-under-an-existing-route) or the [parentName](../reference/runtime/runtime-class.md#register-a-named-route) option. +In the previous code sample, the `RootLayout` serves as the default layout for the homepage as well as for every page (route) registered by a module that are not nested under a parent route with either the [parentPath](../reference/runtime/runtime-class.md#register-nested-routes-under-an-existing-route) or the [parentName](../reference/runtime/runtime-class.md#register-a-named-route) option. For most pages, this is the behavior expected by the author. However, for pages such as a login, the default `RootLayout` isn't suitable because the page is not bound to a user session (the user is not even authenticated yet). diff --git a/docs/reference/routing/ManagedRoutes.md b/docs/reference/routing/ManagedRoutes.md index 997e4ef44..24b772d66 100644 --- a/docs/reference/routing/ManagedRoutes.md +++ b/docs/reference/routing/ManagedRoutes.md @@ -25,31 +25,22 @@ None ## Usage -The registration of the route including the `ManagedRoutes` placeholder must be [hoisted](../runtime/runtime-class.md#register-an-hoisted-route), otherwise there will be an infinite loop as the placeholder will render in the placeholder. +The route including the `ManagedRoutes` placeholder must be [hoisted](../runtime/runtime-class.md#register-an-hoisted-route); otherwise, there will be an infinite loop as the `ManagedRoutes` placeholder will render within itself. -```tsx !#20,27 shell/src/register.tsx +```tsx !#13,18 shell/src/register.tsx import { ManagedRoutes } from "@squide/react-router"; import { RootLayout } from "./RootLayout.tsx"; import { RootErrorBoundary } from "./RootErrorBoundary.tsx"; -import { AuthenticatedLayout } from "./AuthenticatedLayout.tsx"; runtime.registerRoute({ // Pathless route to declare a root layout. - $visibility: "public", element: , children: [ { // Pathless route to declare a root error boundary. - $visibility: "public", errorElement: , children: [ - { - // Pathless route to declare an authenticated layout. - element: - children: [ - ManagedRoutes - ] - } + ManagedRoutes ] } ] diff --git a/docs/reference/runtime/runtime-class.md b/docs/reference/runtime/runtime-class.md index 15c01f2dd..29e0de021 100644 --- a/docs/reference/runtime/runtime-class.md +++ b/docs/reference/runtime/runtime-class.md @@ -128,7 +128,7 @@ runtime.registerRoute({ }); ``` -[!ref text="For a detailed walkthrough, read the how to override the host layout guide"](../../guides/override-the-host-layout.md) +[!ref text="For a detailed walkthrough, refer to the how to override the host layout guide"](../../guides/override-the-host-layout.md) ### Register a public route From c1be6ae2d7f8b457475dffe2ba53ccca36afc459 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Wed, 25 Oct 2023 23:12:32 -0400 Subject: [PATCH 14/22] Updated docs --- docs/guides/add-a-shared-dependency.md | 2 +- docs/guides/override-a-react-context.md | 2 +- docs/reference/registration/useAreModulesRegistered.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/guides/add-a-shared-dependency.md b/docs/guides/add-a-shared-dependency.md index 27c528de9..b74fbb18a 100644 --- a/docs/guides/add-a-shared-dependency.md +++ b/docs/guides/add-a-shared-dependency.md @@ -1,5 +1,5 @@ --- -order: 20 +order: 10 label: Add a shared dependency --- diff --git a/docs/guides/override-a-react-context.md b/docs/guides/override-a-react-context.md index c184ae317..be5b00866 100644 --- a/docs/guides/override-a-react-context.md +++ b/docs/guides/override-a-react-context.md @@ -1,5 +1,5 @@ --- -order: 10 +order: 20 label: Override a React context --- diff --git a/docs/reference/registration/useAreModulesRegistered.md b/docs/reference/registration/useAreModulesRegistered.md index 0b576d1f2..7c28e7d02 100644 --- a/docs/reference/registration/useAreModulesRegistered.md +++ b/docs/reference/registration/useAreModulesRegistered.md @@ -6,7 +6,7 @@ order: 50 # useAreModulesRegistered -Force the application to re-render once all the modules are registered. +Force the application to re-render once all the modules are registered (but not ready). !!!info This hook should only be used by applications that support [deferred registrations](./registerRemoteModules.md#defer-the-registration-of-routes-or-navigation-items) and should be pair with the [useAreModulesReady](./useAreModulesReady.md) hook. From 207a3c70b73f89022c9433f18d478b7feddaf337 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Wed, 25 Oct 2023 23:13:03 -0400 Subject: [PATCH 15/22] Updated docs --- docs/guides/implement-a-custom-logger.md | 2 +- docs/guides/override-a-react-context.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/implement-a-custom-logger.md b/docs/guides/implement-a-custom-logger.md index 446025739..291a035a5 100644 --- a/docs/guides/implement-a-custom-logger.md +++ b/docs/guides/implement-a-custom-logger.md @@ -1,5 +1,5 @@ --- -order: 30 +order: 20 --- # Implement a custom logger diff --git a/docs/guides/override-a-react-context.md b/docs/guides/override-a-react-context.md index be5b00866..55241a4e1 100644 --- a/docs/guides/override-a-react-context.md +++ b/docs/guides/override-a-react-context.md @@ -1,5 +1,5 @@ --- -order: 20 +order: 30 label: Override a React context --- From 5d24072a5ee80a99bfc7b942db9c1fc1f87cd4c4 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Wed, 25 Oct 2023 23:13:33 -0400 Subject: [PATCH 16/22] Updated docs --- docs/guides/default.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/default.md b/docs/guides/default.md index 855b6b557..3117cb64c 100644 --- a/docs/guides/default.md +++ b/docs/guides/default.md @@ -11,7 +11,7 @@ expanded: true - [Add authentication](./add-authentication.md) - [Develop a module in isolation](./develop-a-module-in-isolation.md) - [Federated tabs](./federated-tabs.md) +- [Override a React context](./override-a-react-context.md) - [Implement a custom logger](./implement-a-custom-logger.md) - [Add a shared dependency](./add-a-shared-dependency.md) -- [Override a React context](./override-a-react-context.md) - [Migrating from a monolithic application](./migrating-from-a-monolith.md) From c45cadca4d33828d789ad9e93e3a69f344b35e1f Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Thu, 26 Oct 2023 10:42:28 -0400 Subject: [PATCH 17/22] Removed useless commented things --- .../tests/areModulesReady.test.tsx | 6 ---- samples/endpoints/shell/src/AppRouter.tsx | 35 ------------------- 2 files changed, 41 deletions(-) diff --git a/packages/webpack-module-federation/tests/areModulesReady.test.tsx b/packages/webpack-module-federation/tests/areModulesReady.test.tsx index 18df81c8c..54aa0f3ab 100644 --- a/packages/webpack-module-federation/tests/areModulesReady.test.tsx +++ b/packages/webpack-module-federation/tests/areModulesReady.test.tsx @@ -26,12 +26,6 @@ class DummyRuntime extends AbstractRuntime { const runtime = new DummyRuntime(); -/* -- when local module deferred registrations and remote module deferred registrations are registered and only the local module deferred registrations are completed, return false -- when local module deferred registrations and remote module deferred registrations are registered and only the remote module deferred registrations are completed, return false - -*/ - test("when no modules are registered, return false", async () => { const localModuleRegistry = new LocalModuleRegistry(); diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index cb8efab6b..7d5a5da70 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -6,41 +6,6 @@ import axios from "axios"; import { useEffect, useMemo, useState } from "react"; import { Outlet, RouterProvider, createBrowserRouter, useLocation } from "react-router-dom"; -/* -AppRouter - - loader - - onFetchInitialData -> (doit passer un "signal") - - onFetchSession - - onFetchProtectedData -> Si fournie, est inclus dans le isReady - (doit passer un "signal") - - waitForMsw - - rootRoute - Si fournis est-ce le parent de la root route du AppRouter? - - routerProviderOptions -*/ - -/* - -import { AppRouter as SquideAppRouter } from "@squide/shell"; - -export function AppRouter() { - const [subscription, setSubscription] = useState(); - - onFetchProtectedData() { - .... - - - } - - return ( - - - - - ) -} - -*/ - async function fetchPublicData( setFeatureFlags: (featureFlags: FeatureFlags) => void, logger: Logger From 10192df76b9a3f0d4d20b8ff4b247c4b71b9563c Mon Sep 17 00:00:00 2001 From: "alexandre.asselin" Date: Fri, 27 Oct 2023 11:08:34 -0400 Subject: [PATCH 18/22] Add typings for deferedRegistrationData --- .../core/src/federation/registerLocalModules.ts | 6 +++--- packages/core/src/federation/registerModule.ts | 8 +++----- .../src/registerRemoteModules.ts | 14 +++++++------- samples/endpoints/local-module/src/register.tsx | 12 ++++++------ samples/endpoints/remote-module/src/register.tsx | 10 +++++----- samples/endpoints/shell/src/AppRouter.tsx | 6 +++++- 6 files changed, 29 insertions(+), 27 deletions(-) diff --git a/packages/core/src/federation/registerLocalModules.ts b/packages/core/src/federation/registerLocalModules.ts index ce6faf6e1..baf041335 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -3,9 +3,9 @@ import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; import type { ModuleRegistrationStatus } from "./moduleRegistrationStatus.ts"; import { registerModule, type DeferredRegisterationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; -interface DeferredRegisteration { +interface DeferredRegisteration { index: string; - fct: DeferredRegisterationFunction; + fct: DeferredRegisterationFunction; } export interface RegisterLocalModulesOptions { @@ -42,7 +42,7 @@ export class LocalModuleRegistry { if (isFunction(optionalDeferedRegistration)) { this.#deferredRegistrations.push({ index: `${index + 1}/${registerFunctions.length}`, - fct: optionalDeferedRegistration + fct: optionalDeferedRegistration as DeferredRegisterationFunction }); } } catch (error: unknown) { diff --git a/packages/core/src/federation/registerModule.ts b/packages/core/src/federation/registerModule.ts index 29fdd830e..9d6e7f766 100644 --- a/packages/core/src/federation/registerModule.ts +++ b/packages/core/src/federation/registerModule.ts @@ -1,11 +1,9 @@ import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; -// TODO: Alex, helppppp! -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type DeferredRegisterationFunction = (data?: any) => Promise | void; +export type DeferredRegisterationFunction = (data?: TData) => Promise | void; -export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | DeferredRegisterationFunction | Promise | void; +export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | void> | DeferredRegisterationFunction | void; -export async function registerModule(register: ModuleRegisterFunction, runtime: AbstractRuntime, context?: unknown) { +export async function registerModule< TRuntime extends AbstractRuntime = AbstractRuntime, TContext = unknown, TData = unknown>(register: ModuleRegisterFunction, runtime: TRuntime, context?: TContext) { return register(runtime, context); } diff --git a/packages/webpack-module-federation/src/registerRemoteModules.ts b/packages/webpack-module-federation/src/registerRemoteModules.ts index cf1244e31..569d5d666 100644 --- a/packages/webpack-module-federation/src/registerRemoteModules.ts +++ b/packages/webpack-module-federation/src/registerRemoteModules.ts @@ -2,11 +2,11 @@ import { isFunction, isNil, registerModule, type AbstractRuntime, type DeferredR import { loadRemote as loadModuleFederationRemote, type LoadRemoteFunction } from "./loadRemote.ts"; import { RemoteEntryPoint, RemoteModuleName, type RemoteDefinition } from "./remoteDefinition.ts"; -interface DeferredRegistration { +interface DeferredRegistration { url: string; containerName: string; index: string; - fct: DeferredRegisterationFunction; + fct: DeferredRegisterationFunction; } export interface RegisterRemoteModulesOptions { @@ -47,7 +47,7 @@ export class RemoteModuleRegistry { } } - async registerModules(remotes: RemoteDefinition[], runtime: TRuntime, { context }: RegisterRemoteModulesOptions = {}) { + async registerModules(remotes: RemoteDefinition[], runtime: TRuntime, { context }: RegisterRemoteModulesOptions = {}) { const errors: RemoteModuleRegistrationError[] = []; if (this.#registrationStatus !== "none") { @@ -77,14 +77,14 @@ export class RemoteModuleRegistry { runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - const optionalDeferedRegistration = await registerModule(module.register, runtime, context); + const optionalDeferedRegistration = await registerModule(module.register, runtime, context); if (isFunction(optionalDeferedRegistration)) { this.#deferredRegistrations.push({ url: remoteUrl, containerName: x.name, index: `${index + 1}/${remotes.length}`, - fct: optionalDeferedRegistration + fct: optionalDeferedRegistration as DeferredRegisterationFunction }); } @@ -172,8 +172,8 @@ export class RemoteModuleRegistry { const remoteModuleRegistry = new RemoteModuleRegistry(loadModuleFederationRemote); -export function registerRemoteModules(remotes: RemoteDefinition[], runtime: TRuntime, options?: RegisterRemoteModulesOptions) { - return remoteModuleRegistry.registerModules(remotes, runtime, options); +export function registerRemoteModules(remotes: RemoteDefinition[], runtime: TRuntime, options?: RegisterRemoteModulesOptions) { + return remoteModuleRegistry.registerModules(remotes, runtime, options); } export function completeRemoteModuleRegistrations(runtime: TRuntime, data?: TData) { diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx index 3285d852f..a3088da93 100644 --- a/samples/endpoints/local-module/src/register.tsx +++ b/samples/endpoints/local-module/src/register.tsx @@ -1,4 +1,4 @@ -import type { FeatureFlags } from "@endpoints/shared"; +import type { DeferredRegistrationData } from "@endpoints/shell"; import { getMswPlugin } from "@squide/msw"; import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -27,7 +27,7 @@ function Providers({ children }: { children: ReactNode }) { ); } -function registerRoutes(runtime: Runtime) { +const registerRoutes: ModuleRegisterFunction = runtime => { runtime.registerRoute({ $visibility: "public", path: "/public", @@ -94,7 +94,7 @@ function registerRoutes(runtime: Runtime) { menuId: "/federated-tabs" }); - return ({ featureFlags }: { featureFlags?: FeatureFlags } = {}) => { + return ({ featureFlags } = {}) => { if (featureFlags?.featureA) { runtime.registerRoute({ path: "/feature-a", @@ -107,7 +107,7 @@ function registerRoutes(runtime: Runtime) { }); } }; -} +}; async function registerMsw(runtime: Runtime) { if (process.env.USE_MSW) { @@ -121,8 +121,8 @@ async function registerMsw(runtime: Runtime) { } } -export const registerLocalModule: ModuleRegisterFunction = async runtime => { +export const registerLocalModule: ModuleRegisterFunction = async runtime => { await registerMsw(runtime); - return registerRoutes(runtime); + return await registerRoutes(runtime); }; diff --git a/samples/endpoints/remote-module/src/register.tsx b/samples/endpoints/remote-module/src/register.tsx index 341b03b63..53efb6f82 100644 --- a/samples/endpoints/remote-module/src/register.tsx +++ b/samples/endpoints/remote-module/src/register.tsx @@ -1,9 +1,9 @@ -import type { FeatureFlags } from "@endpoints/shared"; +import type { DeferredRegistrationData } from "@endpoints/shell"; import { getMswPlugin } from "@squide/msw"; import type { ModuleRegisterFunction, Runtime } from "@squide/react-router"; import { Providers } from "./Providers.tsx"; -function registerRoutes(runtime: Runtime) { +const registerRoutes: ModuleRegisterFunction = runtime => { runtime.registerRoute({ path: "/federated-tabs/episodes", lazy: async () => { @@ -64,7 +64,7 @@ function registerRoutes(runtime: Runtime) { menuId: "/federated-tabs" }); - return ({ featureFlags }: { featureFlags?: FeatureFlags } = {}) => { + return ({ featureFlags } = {}) => { if (featureFlags?.featureB) { runtime.registerRoute({ path: "/feature-b", @@ -89,7 +89,7 @@ function registerRoutes(runtime: Runtime) { }); } }; -} +}; async function registerMsw(runtime: Runtime) { if (process.env.USE_MSW) { @@ -103,7 +103,7 @@ async function registerMsw(runtime: Runtime) { } } -export const register: ModuleRegisterFunction = async runtime => { +export const register: ModuleRegisterFunction = async runtime => { await registerMsw(runtime); return registerRoutes(runtime); diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index 7d5a5da70..ab053641f 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -76,6 +76,10 @@ interface RootRouteProps { areModulesReady: boolean; } +export interface DeferredRegistrationData { + featureFlags?: FeatureFlags; +} + // Most of the bootstrapping logic has been moved to this component because AppRouter // cannot leverage "useLocation" since it's depend on "RouterProvider". export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, areModulesReady }: RootRouteProps) { @@ -144,7 +148,7 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, ar if (!areModulesReady) { completeModuleRegistrations(runtime, { featureFlags - }); + } satisfies DeferredRegistrationData); } } }, [runtime, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); From 78d4fd7d37a88067981f715da18dab8f1205ed7d Mon Sep 17 00:00:00 2001 From: "alexandre.asselin" Date: Fri, 27 Oct 2023 11:09:55 -0400 Subject: [PATCH 19/22] rename registeration and defered to remove typos --- .../core/src/federation/registerLocalModules.ts | 16 ++++++++-------- packages/core/src/federation/registerModule.ts | 4 ++-- .../src/registerRemoteModules.ts | 12 ++++++------ 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/core/src/federation/registerLocalModules.ts b/packages/core/src/federation/registerLocalModules.ts index baf041335..6c5622441 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -1,11 +1,11 @@ import { isFunction } from "../index.ts"; import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; import type { ModuleRegistrationStatus } from "./moduleRegistrationStatus.ts"; -import { registerModule, type DeferredRegisterationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; +import { registerModule, type DeferredRegistrationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; -interface DeferredRegisteration { +interface DeferredRegistration { index: string; - fct: DeferredRegisterationFunction; + fct: DeferredRegistrationFunction; } export interface RegisterLocalModulesOptions { @@ -20,7 +20,7 @@ export interface LocalModuleRegistrationError { export class LocalModuleRegistry { #registrationStatus: ModuleRegistrationStatus = "none"; - readonly #deferredRegistrations: DeferredRegisteration[] = []; + readonly #deferredRegistrations: DeferredRegistration[] = []; async registerModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { const errors: LocalModuleRegistrationError[] = []; @@ -37,12 +37,12 @@ export class LocalModuleRegistry { runtime.logger.debug(`[squide] [local] ${index + 1}/${registerFunctions.length} Registering local module.`); try { - const optionalDeferedRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); + const optionalDeferredRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); - if (isFunction(optionalDeferedRegistration)) { + if (isFunction(optionalDeferredRegistration)) { this.#deferredRegistrations.push({ index: `${index + 1}/${registerFunctions.length}`, - fct: optionalDeferedRegistration as DeferredRegisterationFunction + fct: optionalDeferredRegistration as DeferredRegistrationFunction }); } } catch (error: unknown) { @@ -76,7 +76,7 @@ export class LocalModuleRegistry { } if (this.#registrationStatus === "ready") { - // No defered registrations were returned by the local modules, skip the completion process. + // No deferred registrations were returned by the local modules, skip the completion process. return Promise.resolve(errors); } diff --git a/packages/core/src/federation/registerModule.ts b/packages/core/src/federation/registerModule.ts index 9d6e7f766..8730fd943 100644 --- a/packages/core/src/federation/registerModule.ts +++ b/packages/core/src/federation/registerModule.ts @@ -1,8 +1,8 @@ import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; -export type DeferredRegisterationFunction = (data?: TData) => Promise | void; +export type DeferredRegistrationFunction = (data?: TData) => Promise | void; -export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | void> | DeferredRegisterationFunction | void; +export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | void> | DeferredRegistrationFunction | void; export async function registerModule< TRuntime extends AbstractRuntime = AbstractRuntime, TContext = unknown, TData = unknown>(register: ModuleRegisterFunction, runtime: TRuntime, context?: TContext) { return register(runtime, context); diff --git a/packages/webpack-module-federation/src/registerRemoteModules.ts b/packages/webpack-module-federation/src/registerRemoteModules.ts index 569d5d666..c7b5e5e02 100644 --- a/packages/webpack-module-federation/src/registerRemoteModules.ts +++ b/packages/webpack-module-federation/src/registerRemoteModules.ts @@ -1,4 +1,4 @@ -import { isFunction, isNil, registerModule, type AbstractRuntime, type DeferredRegisterationFunction, type Logger, type ModuleRegistrationStatus } from "@squide/core"; +import { isFunction, isNil, registerModule, type AbstractRuntime, type DeferredRegistrationFunction, type Logger, type ModuleRegistrationStatus } from "@squide/core"; import { loadRemote as loadModuleFederationRemote, type LoadRemoteFunction } from "./loadRemote.ts"; import { RemoteEntryPoint, RemoteModuleName, type RemoteDefinition } from "./remoteDefinition.ts"; @@ -6,7 +6,7 @@ interface DeferredRegistration { url: string; containerName: string; index: string; - fct: DeferredRegisterationFunction; + fct: DeferredRegistrationFunction; } export interface RegisterRemoteModulesOptions { @@ -77,14 +77,14 @@ export class RemoteModuleRegistry { runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - const optionalDeferedRegistration = await registerModule(module.register, runtime, context); + const optionalDeferredRegistration = await registerModule(module.register, runtime, context); - if (isFunction(optionalDeferedRegistration)) { + if (isFunction(optionalDeferredRegistration)) { this.#deferredRegistrations.push({ url: remoteUrl, containerName: x.name, index: `${index + 1}/${remotes.length}`, - fct: optionalDeferedRegistration as DeferredRegisterationFunction + fct: optionalDeferredRegistration as DeferredRegistrationFunction }); } @@ -125,7 +125,7 @@ export class RemoteModuleRegistry { } if (this.#registrationStatus === "ready") { - // No defered registrations were returned by the remote modules, skip the completion process. + // No deferred registrations were returned by the remote modules, skip the completion process. return Promise.resolve(errors); } From 3d9960c7fc31cfad495873f521437690ff983356 Mon Sep 17 00:00:00 2001 From: "alexandre.asselin" Date: Fri, 27 Oct 2023 11:27:05 -0400 Subject: [PATCH 20/22] fix build --- packages/core/src/federation/registerLocalModules.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/federation/registerLocalModules.ts b/packages/core/src/federation/registerLocalModules.ts index 6c5622441..7b514ab7c 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -22,7 +22,7 @@ export class LocalModuleRegistry { readonly #deferredRegistrations: DeferredRegistration[] = []; - async registerModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { + async registerModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { const errors: LocalModuleRegistrationError[] = []; if (this.#registrationStatus !== "none") { @@ -37,7 +37,7 @@ export class LocalModuleRegistry { runtime.logger.debug(`[squide] [local] ${index + 1}/${registerFunctions.length} Registering local module.`); try { - const optionalDeferredRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); + const optionalDeferredRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); if (isFunction(optionalDeferredRegistration)) { this.#deferredRegistrations.push({ @@ -113,7 +113,7 @@ export class LocalModuleRegistry { const localModuleRegistry = new LocalModuleRegistry(); -export function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, options?: RegisterLocalModulesOptions) { +export function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, options?: RegisterLocalModulesOptions) { return localModuleRegistry.registerModules(registerFunctions, runtime, options); } From 4ae1e4b81063ff493d1665bbc127d46cf25e1264 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 27 Oct 2023 13:23:29 -0400 Subject: [PATCH 21/22] Minor fixes --- packages/core/src/federation/registerModule.ts | 2 +- samples/endpoints/local-module/src/register.tsx | 2 +- samples/endpoints/shell/src/AppRouter.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/federation/registerModule.ts b/packages/core/src/federation/registerModule.ts index 8730fd943..5cd3ff19a 100644 --- a/packages/core/src/federation/registerModule.ts +++ b/packages/core/src/federation/registerModule.ts @@ -4,6 +4,6 @@ export type DeferredRegistrationFunction = (data?: TData) => Pr export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | void> | DeferredRegistrationFunction | void; -export async function registerModule< TRuntime extends AbstractRuntime = AbstractRuntime, TContext = unknown, TData = unknown>(register: ModuleRegisterFunction, runtime: TRuntime, context?: TContext) { +export async function registerModule(register: ModuleRegisterFunction, runtime: TRuntime, context?: TContext) { return register(runtime, context); } diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx index a3088da93..0a808fc88 100644 --- a/samples/endpoints/local-module/src/register.tsx +++ b/samples/endpoints/local-module/src/register.tsx @@ -124,5 +124,5 @@ async function registerMsw(runtime: Runtime) { export const registerLocalModule: ModuleRegisterFunction = async runtime => { await registerMsw(runtime); - return await registerRoutes(runtime); + return registerRoutes(runtime); }; diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx index ab053641f..abb74cd43 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -148,7 +148,7 @@ export function RootRoute({ waitForMsw, sessionManager, areModulesRegistered, ar if (!areModulesReady) { completeModuleRegistrations(runtime, { featureFlags - } satisfies DeferredRegistrationData); + }); } } }, [runtime, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); From 092f9d25401c4bebcb79ac6974d25a61fb31b173 Mon Sep 17 00:00:00 2001 From: patricklafrance Date: Fri, 27 Oct 2023 13:24:15 -0400 Subject: [PATCH 22/22] Clean up --- packages/webpack-module-federation/src/loadRemote.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/webpack-module-federation/src/loadRemote.ts b/packages/webpack-module-federation/src/loadRemote.ts index 0aaf556e2..d5213d81b 100644 --- a/packages/webpack-module-federation/src/loadRemote.ts +++ b/packages/webpack-module-federation/src/loadRemote.ts @@ -64,7 +64,6 @@ function loadRemoteScript(url: string, { timeoutDelay = 2000 }: LoadRemoteScript export type LoadRemoteOptions = LoadRemoteScriptOptions; -// TBD: Alex helpppp // eslint-disable-next-line @typescript-eslint/no-explicit-any export type LoadRemoteFunction = (url: string, containerName: string, moduleName: string, options?: LoadRemoteOptions) => Promise;