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..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,23 +196,23 @@ 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"; -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); +await registerRemoteModules(Remotes, runtime); - // Indicate to resources that are dependent on MSW that the service has been started. - setMswAsStarted(); - }); - } -}); +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. + const startMsw = (await import("../mocks/browser.ts")).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/add-a-shared-dependency.md b/docs/guides/add-a-shared-dependency.md index 330675d36..b74fbb18a 100644 --- a/docs/guides/add-a-shared-dependency.md +++ b/docs/guides/add-a-shared-dependency.md @@ -1,5 +1,5 @@ --- -order: 30 +order: 10 label: Add a shared dependency --- @@ -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..3117cb64c 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) +- [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) +- [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 fcd7e4b1f..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 @@ -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/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..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 --- 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/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/msw/MswPlugin.md b/docs/reference/msw/MswPlugin.md index 646dbdc26..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 @@ -93,7 +95,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/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 079131342..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. @@ -44,18 +45,18 @@ const context: AppContext = { name: "Test app" }; -registerLocalModules([register], runtime, { context }); +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,9 +66,78 @@ 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-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 +148,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..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. @@ -51,18 +52,18 @@ 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 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,9 +73,82 @@ 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-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 +163,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/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 5f2efba4f..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 @@ -36,7 +40,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/docs/reference/registration/useAreModulesRegistered.md b/docs/reference/registration/useAreModulesRegistered.md new file mode 100644 index 000000000..7c28e7d02 --- /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 (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. +!!! + +## 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/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/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..29e0de021 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, refer to 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/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/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/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..7b514ab7c 100644 --- a/packages/core/src/federation/registerLocalModules.ts +++ b/packages/core/src/federation/registerLocalModules.ts @@ -1,15 +1,15 @@ +import { isFunction } 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 DeferredRegistrationFunction, type ModuleRegisterFunction } from "./registerModule.ts"; -let registrationStatus: ModuleRegistrationStatus = "none"; - -export function getLocalModulesRegistrationStatus() { - return registrationStatus; +interface DeferredRegistration { + index: string; + fct: DeferredRegistrationFunction; } -export function resetLocalModulesRegistrationStatus() { - registrationStatus = "none"; +export interface RegisterLocalModulesOptions { + context?: TContext; } export interface LocalModuleRegistrationError { @@ -17,45 +17,110 @@ export interface LocalModuleRegistrationError { error: unknown; } -export interface RegisterLocalModulesOptions { - context?: TContext; -} +export class LocalModuleRegistry { + #registrationStatus: ModuleRegistrationStatus = "none"; -export async function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { - if (registrationStatus !== "none") { - throw new Error("[squide] [local] registerLocalModules() can only be called once."); - } + readonly #deferredRegistrations: DeferredRegistration[] = []; + + async registerModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, { context }: RegisterLocalModulesOptions = {}) { + const errors: LocalModuleRegistrationError[] = []; + + 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.`); + + try { + const optionalDeferredRegistration = await registerModule(x as ModuleRegisterFunction, runtime, context); + + if (isFunction(optionalDeferredRegistration)) { + this.#deferredRegistrations.push({ + index: `${index + 1}/${registerFunctions.length}`, + fct: optionalDeferredRegistration as DeferredRegistrationFunction + }); + } + } catch (error: unknown) { + runtime.logger.error( + `[squide] [local] ${index + 1}/${registerFunctions.length} An error occured while registering a local module.`, + error + ); + + errors.push({ + error + }); + } - const errors: LocalModuleRegistrationError[] = []; + runtime.logger.debug(`[squide] [local] ${index + 1}/${registerFunctions.length} Local module registration completed.`); + })); - runtime.logger.information(`[squide] [local] Found ${registerFunctions.length} local module${registerFunctions.length !== 1 ? "s" : ""} to register.`); + this.#registrationStatus = this.#deferredRegistrations.length > 0 ? "registered" : "ready"; - registrationStatus = "in-progress"; + return errors; + } - await Promise.allSettled(registerFunctions.map(async (x, index) => { - let optionalPromise; + async completeModuleRegistrations(runtime: TRuntime, data?: TData) { + const errors: LocalModuleRegistrationError[] = []; - runtime.logger.information(`[squide] [local] ${index + 1}/${registerFunctions.length} Registering local module.`); + 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."); + } - try { - optionalPromise = registerModule(x as ModuleRegisterFunction, runtime, context); - } catch (error: unknown) { - runtime.logger.error( - `[squide] [local] ${index + 1}/${registerFunctions.length} An error occured while registering a local module.`, - error - ); + if (this.#registrationStatus !== "registered" && this.#deferredRegistrations.length > 0) { + throw new Error("[squide] [local] The completeLocalModuleRegistration function can only be called once."); + } - errors.push({ - error - }); + if (this.#registrationStatus === "ready") { + // No deferred registrations were returned by the local modules, skip the completion process. + return Promise.resolve(errors); } - runtime.logger.information(`[squide] [local] ${index + 1}/${registerFunctions.length} Local module registration completed.`); + this.#registrationStatus = "in-completion"; + + await Promise.allSettled(this.#deferredRegistrations.map(async ({ index, fct: deferredRegister }) => { + runtime.logger.debug(`[squide] [local] ${index} Completing local module deferred registration.`); + + 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 + }); + } + + runtime.logger.debug(`[squide] [local] ${index} Completed local module deferred registration.`); + })); + + this.#registrationStatus = "ready"; - return optionalPromise; - })); + return errors; + } + + get registrationStatus() { + return this.#registrationStatus; + } +} - registrationStatus = "ready"; +const localModuleRegistry = new LocalModuleRegistry(); + +export function registerLocalModules(registerFunctions: ModuleRegisterFunction[], runtime: TRuntime, options?: RegisterLocalModulesOptions) { + return localModuleRegistry.registerModules(registerFunctions, runtime, options); +} + +export function completeLocalModuleRegistrations(runtime: TRuntime, data?: TData) { + return localModuleRegistry.completeModuleRegistrations(runtime, data); +} - return errors; +export function getLocalModuleRegistrationStatus() { + return localModuleRegistry.registrationStatus; } diff --git a/packages/core/src/federation/registerModule.ts b/packages/core/src/federation/registerModule.ts index 509513efd..5cd3ff19a 100644 --- a/packages/core/src/federation/registerModule.ts +++ b/packages/core/src/federation/registerModule.ts @@ -1,7 +1,9 @@ import type { AbstractRuntime } from "../runtime/abstractRuntime.ts"; -export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => void; +export type DeferredRegistrationFunction = (data?: TData) => Promise | void; -export async function registerModule(register: ModuleRegisterFunction, runtime: AbstractRuntime, context?: unknown) { +export type ModuleRegisterFunction = (runtime: TRuntime, context?: TContext) => Promise | void> | DeferredRegistrationFunction | void; + +export async function registerModule(register: ModuleRegisterFunction, runtime: TRuntime, context?: TContext) { 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 unknown { + return typeof value === "function"; +} + diff --git a/packages/core/swc.jest.ts b/packages/core/swc.jest.ts new file mode 100644 index 000000000..297e18199 --- /dev/null +++ b/packages/core/swc.jest.ts @@ -0,0 +1,6 @@ +import { defineJestConfig } from "@workleap/swc-configs"; + +export const swcConfig = defineJestConfig({ + react: true +}); + diff --git a/packages/core/tests/completeLocalModuleRegistrations.test.ts b/packages/core/tests/completeLocalModuleRegistrations.test.ts new file mode 100644 index 000000000..5d1985bf7 --- /dev/null +++ b/packages/core/tests/completeLocalModuleRegistrations.test.ts @@ -0,0 +1,179 @@ +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("when called before registerLocalModules, throw an error", async () => { + const registry = new LocalModuleRegistry(); + + 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 () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => () => {}, + () => () => {} + ], runtime); + + await registry.completeModuleRegistrations(runtime); + + 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 () => { + 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.completeModuleRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("can complete all the deferred registrations", 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.completeModuleRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register2).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when all the deferred registrations are completed, set the status to \"ready\"", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => () => {}, + () => () => {} + ], runtime); + + expect(registry.registrationStatus).toBe("registered"); + + await registry.completeModuleRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("when a deferred registration 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.completeModuleRegistrations(runtime); + + expect(hasBeenCompleted).toBeTruthy(); +}); + +test("when a deferred registration fail, complete the remaining deferred registrations", 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.completeModuleRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a deferred registration fail, return the error", async () => { + const registry = new LocalModuleRegistry(); + + await registry.registerModules([ + () => () => {}, + () => () => { throw new Error("Module 2 deferred registration failed"); }, + () => () => {} + ], runtime); + + const errors = await registry.completeModuleRegistrations(runtime); + + expect(errors.length).toBe(1); + 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 () => { + 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.completeModuleRegistrations(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..f81743154 --- /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 registrations, 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 registrations, 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/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/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/completeModuleRegistrations.ts b/packages/webpack-module-federation/src/completeModuleRegistrations.ts new file mode 100644 index 000000000..30c58be38 --- /dev/null +++ b/packages/webpack-module-federation/src/completeModuleRegistrations.ts @@ -0,0 +1,21 @@ +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[] = []; + + if (getLocalModuleRegistrationStatus() !== "none") { + promise.push(completeLocalModuleRegistrations(runtime, data)); + } + + if (getRemoteModuleRegistrationStatus() !== "none") { + promise.push(completeRemoteModuleRegistrations(runtime, data)); + } + + return Promise.allSettled(promise).then(([localModuleErrors, remoteModuleErrors]) => { + return { + localModuleErrors: localModuleErrors as unknown as LocalModuleRegistrationError, + remoteModuleErrors: remoteModuleErrors as unknown as RemoteModuleRegistrationError + }; + }); +} 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 0d1bd8420..3d81bc488 100644 --- a/packages/webpack-module-federation/src/index.ts +++ b/packages/webpack-module-federation/src/index.ts @@ -1,5 +1,7 @@ +export * from "./completeModuleRegistrations.ts"; export * from "./loadRemote.ts"; export * from "./registerRemoteModules.ts"; export * from "./remoteDefinition.ts"; -export * from "./useAreModulesReady.ts"; +export { useAreModulesReady } from "./useAreModulesReady.ts"; +export { useAreModulesRegistered } from "./useAreModulesRegistered.ts"; diff --git a/packages/webpack-module-federation/src/loadRemote.ts b/packages/webpack-module-federation/src/loadRemote.ts index 948072e38..d5213d81b 100644 --- a/packages/webpack-module-federation/src/loadRemote.ts +++ b/packages/webpack-module-federation/src/loadRemote.ts @@ -64,9 +64,12 @@ function loadRemoteScript(url: string, { timeoutDelay = 2000 }: LoadRemoteScript export type LoadRemoteOptions = LoadRemoteScriptOptions; +// 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 +94,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 a3e9deff1..c7b5e5e02 100644 --- a/packages/webpack-module-federation/src/registerRemoteModules.ts +++ b/packages/webpack-module-federation/src/registerRemoteModules.ts @@ -1,16 +1,16 @@ -import { isNil, registerModule, type AbstractRuntime, type ModuleRegistrationStatus } from "@squide/core"; -import { loadRemote } from "./loadRemote.ts"; +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"; -let registrationStatus: ModuleRegistrationStatus = "none"; - -export function getRemoteModulesRegistrationStatus() { - return registrationStatus; +interface DeferredRegistration { + url: string; + containerName: string; + index: string; + fct: DeferredRegistrationFunction; } -// Strictly for testing purpose. -export function resetRemoteModulesRegistrationStatus() { - registrationStatus = "none"; +export interface RegisterRemoteModulesOptions { + context?: TContext; } export interface RemoteModuleRegistrationError { @@ -24,59 +24,167 @@ export interface RemoteModuleRegistrationError { error: unknown; } -export interface RegisterRemoteModulesOptions { - context?: unknown; -} +export class RemoteModuleRegistry { + #registrationStatus: ModuleRegistrationStatus = "none"; + + readonly #deferredRegistrations: DeferredRegistration[] = []; + readonly #loadRemote: LoadRemoteFunction; -export async function registerRemoteModules(remotes: RemoteDefinition[], runtime: AbstractRuntime, { context }: RegisterRemoteModulesOptions = {}) { - if (registrationStatus !== "none") { - throw new Error("[squide] [remote] registerRemoteModules() can only be called once."); + constructor(loadRemote: LoadRemoteFunction) { + this.#loadRemote = loadRemote; } - const errors: RemoteModuleRegistrationError[] = []; + #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 + ); + } + } + + async registerModules(remotes: RemoteDefinition[], runtime: TRuntime, { context }: RegisterRemoteModulesOptions = {}) { + const errors: RemoteModuleRegistrationError[] = []; + + if (this.#registrationStatus !== "none") { + throw new Error("[squide] [remote] The registerRemoteModules function can only be called once."); + } + + runtime.logger.debug(`[squide] [remote] Found ${remotes.length} remote module${remotes.length !== 1 ? "s" : ""} to register.`); + + this.#registrationStatus = "in-progress"; + + await Promise.allSettled(remotes.map(async (x, index) => { + let remoteUrl; - runtime.logger.information(`[squide] [remote] Found ${remotes.length} remote module${remotes.length !== 1 ? "s" : ""} to register.`); + const containerName = x.name; - registrationStatus = "in-progress"; + try { + // Is included in the try/catch becase the URL could be invalid and cause an error. + remoteUrl = new URL(RemoteEntryPoint, x.url).toString(); - await Promise.allSettled(remotes.map(async (x, index) => { - let remoteUrl; + runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Loading module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - const containerName = x.name; + const module = await this.#loadRemote(remoteUrl, containerName, RemoteModuleName); - try { - // Is included in the try/catch becase the URL could be invalid and cause an error. - remoteUrl = new URL(RemoteEntryPoint, x.url).toString(); + 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".`); + } - runtime.logger.information(`[squide] [remote] ${index + 1}/${remotes.length} Loading module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); + runtime.logger.debug(`[squide] [remote] ${index + 1}/${remotes.length} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); - const module = await loadRemote(remoteUrl, containerName, RemoteModuleName); + const optionalDeferredRegistration = await registerModule(module.register, runtime, context); - 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".`); + if (isFunction(optionalDeferredRegistration)) { + this.#deferredRegistrations.push({ + url: remoteUrl, + containerName: x.name, + index: `${index + 1}/${remotes.length}`, + fct: optionalDeferredRegistration as DeferredRegistrationFunction + }); + } + + 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} Registering module "${RemoteModuleName}" from container "${containerName}" of remote "${remoteUrl}".`); + this.#registrationStatus = this.#deferredRegistrations.length > 0 ? "registered" : "ready"; - await registerModule(module.register, runtime, context); + if (this.#registrationStatus === "ready") { + this.#logSharedScope(runtime.logger); + } - 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 - ); + return errors; + } + + async completeModuleRegistrations(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."); + } - errors.push({ - url: remoteUrl ?? `Partial URL is: "${x.url}"`, - containerName, - moduleName: RemoteModuleName, - error - }); + if (this.#registrationStatus !== "registered" && this.#deferredRegistrations.length > 0) { + throw new Error("[squide] [remote] The completeRemoteModuleRegistration function can only be called once."); } - })); - registrationStatus = "ready"; + if (this.#registrationStatus === "ready") { + // No deferred registrations were returned by the remote modules, skip the completion process. + return Promise.resolve(errors); + } + + this.#registrationStatus = "in-completion"; + + 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}".`); + + 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 + ); + + errors.push({ + url, + containerName, + moduleName: RemoteModuleName, + error + }); + } + + runtime.logger.debug(`[squide] [remote] ${index} Completed registration for module "${RemoteModuleName}" from container "${containerName}" of remote "${url}".`); + })); + + this.#registrationStatus = "ready"; + + this.#logSharedScope(runtime.logger); + + return errors; + } + + get registrationStatus() { + return this.#registrationStatus; + } + + // Required to test hooks that dependent on the registration status. + resetRegistrationStatus() { + this.#registrationStatus = "none"; + } +} + +const remoteModuleRegistry = new RemoteModuleRegistry(loadModuleFederationRemote); + +export function registerRemoteModules(remotes: RemoteDefinition[], runtime: TRuntime, options?: RegisterRemoteModulesOptions) { + return remoteModuleRegistry.registerModules(remotes, runtime, options); +} + +export function completeRemoteModuleRegistrations(runtime: TRuntime, data?: TData) { + return remoteModuleRegistry.completeModuleRegistrations(runtime, data); +} + +export function getRemoteModuleRegistrationStatus() { + return remoteModuleRegistry.registrationStatus; +} + +// Required to test hooks that dependent on the registration status. +export function resetRemoteModuleRegistrationStatus() { - return errors; } diff --git a/packages/webpack-module-federation/src/useAreModulesReady.ts b/packages/webpack-module-federation/src/useAreModulesReady.ts index 41b73f936..cbf1bf2bb 100644 --- a/packages/webpack-module-federation/src/useAreModulesReady.ts +++ b/packages/webpack-module-federation/src/useAreModulesReady.ts @@ -1,17 +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() { - // Validating for "in-progress" instead of "ready" for the local module because "registerLocalModules" - // could never be called. - return getLocalModulesRegistrationStatus() !== "in-progress" && getRemoteModulesRegistrationStatus() !== "in-progress"; +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 = {}) { @@ -23,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 new file mode 100644 index 000000000..69d10f6f3 --- /dev/null +++ b/packages/webpack-module-federation/src/useAreModulesRegistered.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; + +import { getLocalModuleRegistrationStatus, type ModuleRegistrationStatus } from "@squide/core"; +import { getRemoteModuleRegistrationStatus } from "./registerRemoteModules.ts"; + +export interface UseAreModulesRegisteredOptions { + // The interval is in milliseconds. + interval?: number; +} + +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 = {}) { + // 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(getLocalModuleRegistrationStatus(), getRemoteModuleRegistrationStatus())) { + clearInterval(intervalId); + + setAreModulesRegistered(true); + } + }, interval); + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, []); + + return value; +} 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/areModulesReady.test.tsx b/packages/webpack-module-federation/tests/areModulesReady.test.tsx new file mode 100644 index 000000000..54aa0f3ab --- /dev/null +++ b/packages/webpack-module-federation/tests/areModulesReady.test.tsx @@ -0,0 +1,287 @@ +// 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(); + +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 new file mode 100644 index 000000000..74459ddb5 --- /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.completeModuleRegistrations(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.completeModuleRegistrations(runtime); + + 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 () => { + // 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.completeModuleRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("can complete all the deferred registrations", 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.completeModuleRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register2).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when all the deferred registrations are completed, 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.completeModuleRegistrations(runtime); + + expect(registry.registrationStatus).toBe("ready"); +}); + +test("when a deferred registration 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.completeModuleRegistrations(runtime); + + expect(hasBeenCompleted).toBeTruthy(); +}); + +test("when a deferred registration fail, complete the remaining deferred registrations", 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.completeModuleRegistrations(runtime); + + expect(register1).toHaveBeenCalled(); + expect(register3).toHaveBeenCalled(); +}); + +test("when a deferred registration fail, return the error", async () => { + const loadRemote = jest.fn(); + + loadRemote + .mockResolvedValueOnce({ + register: () => () => {} + }) + .mockResolvedValueOnce({ + register: () => () => { throw new Error("Module 2 deferred 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.completeModuleRegistrations(runtime); + + expect(errors.length).toBe(1); + expect(errors[0]!.error!.toString()).toContain("Module 2 deferred registration failed"); +}); + +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(); + + 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.completeModuleRegistrations(runtime, data); + + expect(register1).toHaveBeenCalledWith(data); + expect(register2).toHaveBeenCalledWith(data); + expect(register3).toHaveBeenCalledWith(data); +}); + + 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/packages/webpack-module-federation/tests/registerRemoteModules.test.ts b/packages/webpack-module-federation/tests/registerRemoteModules.test.ts new file mode 100644 index 000000000..f461ae989 --- /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 registrations, 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 registrations, 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 deleted file mode 100644 index c59198625..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(); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 554b7bcdb..e0e3ef9bb 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 @@ -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) @@ -99,7 +123,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 +157,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 +218,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 +1112,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 +1133,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) @@ -1416,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): @@ -3185,7 +3215,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 +3312,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 +3333,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 +3375,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 +3402,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 +3435,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 +3522,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 +3534,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 +3665,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 +3721,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 +5473,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 +5529,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 +5545,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 +5567,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 +5608,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 +5651,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 +5711,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 +5723,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 +5784,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 +5806,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 +8124,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 +9228,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 @@ -9855,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: @@ -11558,7 +11614,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 +11663,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 +11726,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 +11831,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 +11852,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 +11903,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 +11958,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 +11989,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 +12041,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 +12078,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 +12090,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 +12098,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 +12125,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 +13592,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 +13600,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 +14624,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 +14861,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 +16854,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 +16864,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 +16883,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..c3c82a3e4 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. + const startMsw = (await import("../mocks/browser.ts")).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/host/swc.build.js b/samples/endpoints/host/swc.build.js index 7e0820823..670cf1bb4 100644 --- a/samples/endpoints/host/swc.build.js +++ b/samples/endpoints/host/swc.build.js @@ -4,4 +4,20 @@ import { browserslistToSwc, defineBuildConfig } from "@workleap/swc-configs"; const targets = browserslistToSwc(); -export const swcConfig = defineBuildConfig(targets); +/** + * 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: [temporaryEnablingLooseMode] +}); diff --git a/samples/endpoints/host/webpack.build.js b/samples/endpoints/host/webpack.build.js index 28b1cc447..5f6dae879 100644 --- a/samples/endpoints/host/webpack.build.js +++ b/samples/endpoints/host/webpack.build.js @@ -7,6 +7,9 @@ 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: { "@endpoints/shared": { singleton: true, diff --git a/samples/endpoints/host/webpack.dev.js b/samples/endpoints/host/webpack.dev.js index 2678e2d89..9e416b0c0 100644 --- a/samples/endpoints/host/webpack.dev.js +++ b/samples/endpoints/host/webpack.dev.js @@ -5,6 +5,9 @@ import { swcConfig } from "./swc.dev.js"; export default defineDevHostConfig(swcConfig, "host", 8080, { overlay: false, + features: { + msw: true + }, sharedDependencies: { "@endpoints/shared": { singleton: true, 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/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/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/dev/index.tsx b/samples/endpoints/local-module/src/dev/index.tsx index dfd929f43..3a1495044 100644 --- a/samples/endpoints/local-module/src/dev/index.tsx +++ b/samples/endpoints/local-module/src/dev/index.tsx @@ -18,18 +18,17 @@ 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. 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); + const startMsw = (await import("../../mocks/browser.ts")).startMsw; - setMswAsStarted(); - }); + startMsw(mswPlugin.requestHandlers); + setMswAsStarted(); } const root = createRoot(document.getElementById("root")!); diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx index 8cecc8d9f..0a808fc88 100644 --- a/samples/endpoints/local-module/src/register.tsx +++ b/samples/endpoints/local-module/src/register.tsx @@ -1,3 +1,4 @@ +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"; @@ -26,7 +27,15 @@ function Providers({ children }: { children: ReactNode }) { ); } -function registerRoutes(runtime: Runtime) { +const registerRoutes: ModuleRegisterFunction = runtime => { + runtime.registerRoute({ + $visibility: "public", + path: "/public", + lazy: () => import("./PublicPage.tsx") + }, { + hoist: true + }); + runtime.registerRoute({ path: "/subscription", lazy: async () => { @@ -67,6 +76,11 @@ function registerRoutes(runtime: Runtime) { to: "/subscription" }); + runtime.registerNavigationItem({ + $label: "Public page", + to: "/public" + }); + runtime.registerNavigationItem({ $label: "Tabs", $priority: 100, @@ -79,7 +93,21 @@ function registerRoutes(runtime: Runtime) { }, { menuId: "/federated-tabs" }); -} + + return ({ 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) { if (process.env.USE_MSW) { @@ -93,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..3d997fbdf 100644 --- a/samples/endpoints/remote-module/src/dev/index.tsx +++ b/samples/endpoints/remote-module/src/dev/index.tsx @@ -20,18 +20,17 @@ 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. 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); + const startMsw = (await import("../../mocks/browser.ts")).startMsw; - setMswAsStarted(); - }); + startMsw(mswPlugin.requestHandlers); + setMswAsStarted(); } const root = createRoot(document.getElementById("root")!); diff --git a/samples/endpoints/remote-module/src/register.tsx b/samples/endpoints/remote-module/src/register.tsx index 36467f5ea..53efb6f82 100644 --- a/samples/endpoints/remote-module/src/register.tsx +++ b/samples/endpoints/remote-module/src/register.tsx @@ -1,8 +1,9 @@ +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 () => { @@ -62,7 +63,33 @@ function registerRoutes(runtime: Runtime) { }, { menuId: "/federated-tabs" }); -} + + return ({ 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) { if (process.env.USE_MSW) { @@ -76,8 +103,8 @@ async function registerMsw(runtime: Runtime) { } } -export const register: ModuleRegisterFunction = async runtime => { - registerRoutes(runtime); +export const register: ModuleRegisterFunction = async runtime => { + await registerMsw(runtime); - return registerMsw(runtime); + return registerRoutes(runtime); }; diff --git a/samples/endpoints/remote-module/webpack.build.js b/samples/endpoints/remote-module/webpack.build.js index b3043d1b9..71ce75458 100644 --- a/samples/endpoints/remote-module/webpack.build.js +++ b/samples/endpoints/remote-module/webpack.build.js @@ -7,6 +7,9 @@ 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: { "@endpoints/shared": { singleton: true diff --git a/samples/endpoints/remote-module/webpack.dev.js b/samples/endpoints/remote-module/webpack.dev.js index 8e9a76c0d..6a6146aa0 100644 --- a/samples/endpoints/remote-module/webpack.dev.js +++ b/samples/endpoints/remote-module/webpack.dev.js @@ -9,6 +9,9 @@ let config; if (!process.env.ISOLATED) { config = defineDevRemoteModuleConfig(swcConfig, "remote1", 8081, { + features: { + msw: true + }, sharedDependencies: { "@endpoints/shared": { singleton: true 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 46bd54d2a..abb74cd43 100644 --- a/samples/endpoints/shell/src/AppRouter.tsx +++ b/samples/endpoints/shell/src/AppRouter.tsx @@ -1,130 +1,209 @@ -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 { useIsRouteMatchProtected, useLogger, useRoutes } from "@squide/react-router"; -import { useAreModulesReady } 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"; -export function RootRoute() { - const location = useLocation(); - const telemetryService = useTelemetryService(); +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 + }; - useEffect(() => { - telemetryService?.track(`Navigated to the "${location.pathname}" page.`); - }, [location, telemetryService]); + logger.debug("[shell] %cFeature flags are ready%c:", "color: white; background-color: green;", "", featureFlags); - return ( - - ); + setFeatureFlags(featureFlags); + }); + + return featureFlagsPromise; } -export interface AppRouterProps { +async function fetchProtectedData( + setSession: (session: Session) => void, + setSubscription: (subscription: Subscription) => void, + logger: Logger +) { + 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(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); + }); + + 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 { waitForMsw: boolean; sessionManager: SessionManager; - telemetryService: TelemetryService; + areModulesRegistered: boolean; + areModulesReady: boolean; } -export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) { - const [isReady, setIsReady] = useState(false); +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) { + 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 runtime = useRuntime(); 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. - // 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 isActiveRouteProtected = useIsRouteMatchProtected(location); 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) { - if (isActiveRouteProtected) { - logger.debug(`[shell] Fetching session data as "${window.location}" is a protected route.`); + useEffect(() => { + if ((areModulesRegistered || areModulesReady) && isMswStarted) { + if (!isPublicDataLoaded) { + logger.debug("[shell] Fetching public initial data."); - const sessionPromise = axios.get("/api/session") - .then(({ data }) => { - const session: Session = { - user: { - id: data.userId, - name: data.username - } - }; + fetchPublicData(setFeatureFlags, logger).finally(() => { + setIsPublicDataLoaded(true); + }); + } + } + }, [logger, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded]); - logger.debug("[shell] %cSession is ready%c:", "color: white; background-color: green;", "", session); + useEffect(() => { + if ((areModulesRegistered || areModulesReady) && isMswStarted) { + if (isActiveRouteProtected) { + if (!isProtectedDataLoaded) { + logger.debug(`[shell] Fetching protected initial data as "${location.pathname}" is a protected route.`); + const setSession = (session: 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(() => { + setIsProtectedDataLoaded(true); }); + } } else { - logger.debug(`[shell] Passing through as "${window.location}" is a public route.`); + logger.debug(`[shell] Not fetching protected initial data as "${location.pathname}" is a public route.`); + } + } + }, [logger, location, sessionManager, areModulesRegistered, areModulesReady, isMswStarted, isActiveRouteProtected, isProtectedDataLoaded]); - setIsReady(true); + useEffect(() => { + if (areModulesRegistered && isMswStarted && isPublicDataLoaded) { + if (!areModulesReady) { + completeModuleRegistrations(runtime, { + featureFlags + }); } } - }, [areModulesReady, isMswStarted, isActiveRouteProtected, logger, sessionManager]); + }, [runtime, areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, featureFlags]); + + useEffect(() => { + telemetryService?.track(`Navigated to the "${location.pathname}" page.`); + }, [location, telemetryService]); + + if (!areModulesReady || !isMswStarted || !isPublicDataLoaded || (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 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(); const router = useMemo(() => { return createBrowserRouter([ { - element: , + element: ( + + ), children: routes } ]); - }, [routes]); - - if (!isReady) { - return
Loading...
; - } + }, [areModulesRegistered, areModulesReady, routes, waitForMsw, sessionManager]); 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) => { diff --git a/samples/endpoints/shell/src/register.tsx b/samples/endpoints/shell/src/register.tsx index f0dcc5522..72b637a64 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,19 +98,23 @@ 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;