diff --git a/.changeset/slimy-pumas-breathe.md b/.changeset/slimy-pumas-breathe.md
new file mode 100644
index 000000000..dcc8d4f7b
--- /dev/null
+++ b/.changeset/slimy-pumas-breathe.md
@@ -0,0 +1,5 @@
+---
+"@squide/firefly": patch
+---
+
+Fixing a remaining issue with deferred registrations that depends on protected data.
diff --git a/.changeset/thin-shoes-call.md b/.changeset/thin-shoes-call.md
new file mode 100644
index 000000000..179e27295
--- /dev/null
+++ b/.changeset/thin-shoes-call.md
@@ -0,0 +1,5 @@
+---
+"@squide/react-router": patch
+---
+
+Internal changes to `useRouteMatch` and `useIsRouteProtected`.
diff --git a/docs/reference/default.md b/docs/reference/default.md
index 6be01e395..1d0bace83 100644
--- a/docs/reference/default.md
+++ b/docs/reference/default.md
@@ -41,7 +41,7 @@ toc:
- [ManagedRoutes](./routing/ManagedRoutes.md)
- [useRenderedNavigationItems](./routing/useRenderedNavigationItems.md)
- [useRouteMatch](./routing/useRouteMatch.md)
-- [useIsRouteMatchProtected](./routing/useIsRouteMatchProtected.md)
+- [useIsRouteProtected](./routing/useIsRouteProtected.md)
### Logging
diff --git a/docs/reference/routing/useIsRouteMatchProtected.md b/docs/reference/routing/useIsRouteMatchProtected.md
deleted file mode 100644
index d5d0c6560..000000000
--- a/docs/reference/routing/useIsRouteMatchProtected.md
+++ /dev/null
@@ -1,51 +0,0 @@
----
-toc:
- depth: 2-3
----
-
-# 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`.
-
-> 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
-
-```ts
-const isProtected = useIsRouteMatchProtected(locationArg)
-```
-
-### Parameters
-
-- `locationArg`: The location to match the route paths against.
-- `options`: An optional object literal of options:
- - `throwWhenThereIsNoMatch`: Whether or not to throw an `Error` if no route match `locationArg`.
-
-### Returns
-
-A `boolean` value indicating whether or not the matching route is `protected`. If `throwWhenThereIsNoMatch` is enabled and no route match the given location, an `Error` is thrown.
-
-If `throwWhenThereIsNoMatch` is disabled and there's no route matching `locationArg`, `true` is returned.
-
-## Usage
-
-### Using `useLocation`
-
-```ts
-import { useLocation } from "react-router-dom";
-import { useIsRouteMatchProtected } from "@squide/firefly";
-
-const location = useLocation();
-
-// Returns true if the matching route doesn't have a $visibility: "public" property.
-const isActiveRouteProtected = useIsRouteMatchProtected(location);
-```
-
-### Using `window.location`
-
-```ts
-import { useIsRouteMatchProtected } from "@squide/firefly";
-
-// Returns true if the matching route doesn't have a $visibility: "public" property.
-const isActiveRouteProtected = useIsRouteMatchProtected(window.location);
-```
diff --git a/docs/reference/routing/useIsRouteProtected.md b/docs/reference/routing/useIsRouteProtected.md
new file mode 100644
index 000000000..fa6f55b86
--- /dev/null
+++ b/docs/reference/routing/useIsRouteProtected.md
@@ -0,0 +1,37 @@
+---
+order: -100
+toc:
+ depth: 2-3
+---
+
+# useIsRouteProtected
+
+Determine whether or not a route is considered as `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
+
+```ts
+const isProtected = useIsRouteProtected(route)
+```
+
+### Parameters
+
+- `route`: A `Route` object.
+
+### Returns
+
+A `boolean` value indicating whether or not the matching route is `protected`.
+
+## Usage
+
+```ts
+import { useLocation } from "react-router-dom";
+import { useIsRouteProtected, useRouteMatch } from "@squide/firefly";
+
+const location = useLocation();
+const route = useRouteMatch(location);
+
+const isActiveRouteProtected = useIsRouteProtected(route);
+```
diff --git a/docs/reference/routing/useRouteMatch.md b/docs/reference/routing/useRouteMatch.md
index 62d8d7cc1..7123c2457 100644
--- a/docs/reference/routing/useRouteMatch.md
+++ b/docs/reference/routing/useRouteMatch.md
@@ -16,10 +16,14 @@ const match = useRouteMatch(locationArg)
### Parameters
- `locationArg`: The location to match the route paths against.
+- `options`: An optional object literal of options:
+ - `throwWhenThereIsNoMatch`: Whether or not to throw an `Error` if no route match `locationArg`.
### Returns
-A `Route` object if there's a matching route, otherwise an `Error` is thrown.
+A `Route` object if there's a matching route, otherwise if `throwWhenThereIsNoMatch` is enabled and no route match the given location, an `Error` is thrown.
+
+If `throwWhenThereIsNoMatch` is disabled and there's no route matching `locationArg`, `undefined` is returned.
## Usage
@@ -30,6 +34,7 @@ import { useLocation } from "react-router-dom";
import { useRouteMatch } from "@squide/firefly";
const location = useLocation();
+
const activeRoute = useRouteMatch(location);
```
diff --git a/packages/firefly/src/AppRouter.tsx b/packages/firefly/src/AppRouter.tsx
index 0f6ddb574..318401da3 100644
--- a/packages/firefly/src/AppRouter.tsx
+++ b/packages/firefly/src/AppRouter.tsx
@@ -1,6 +1,6 @@
import { isNil, useLogOnceLogger } from "@squide/core";
import { useIsMswStarted } from "@squide/msw";
-import { useIsRouteMatchProtected, useRoutes, type Route } from "@squide/react-router";
+import { useIsRouteProtected, useRouteMatch, useRoutes, type Route } from "@squide/react-router";
import { useAreModulesReady, useAreModulesRegistered } from "@squide/webpack-module-federation";
import { cloneElement, useCallback, useEffect, useMemo, type ReactElement } from "react";
import { ErrorBoundary, useErrorBoundary } from "react-error-boundary";
@@ -154,7 +154,8 @@ export function BootstrappingRoute(props: BootstrappingRouteProps) {
useLoadPublicData(areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, onLoadPublicData);
// Only throw when there's no match if the modules has been registered, otherwise it's expected that there are no registered routes.
- const isActiveRouteProtected = useIsRouteMatchProtected(location, { throwWhenThereIsNoMatch: areModulesReady });
+ const activeRoute = useRouteMatch(location, { throwWhenThereIsNoMatch: areModulesReady });
+ const isActiveRouteProtected = useIsRouteProtected(activeRoute);
// Try to load the protected data if an handler is defined.
useLoadProtectedData(areModulesRegistered, areModulesReady, isMswStarted, isActiveRouteProtected, isProtectedDataLoaded, onLoadProtectedData);
@@ -170,7 +171,7 @@ export function BootstrappingRoute(props: BootstrappingRouteProps) {
}
}, [areModulesRegistered, areModulesReady, isMswStarted, isPublicDataLoaded, isProtectedDataLoaded, isActiveRouteProtected, onCompleteRegistrations]);
- if (!areModulesReady || !isMswStarted || !isPublicDataLoaded || (isActiveRouteProtected && !isProtectedDataLoaded)) {
+ if (!areModulesReady || !isMswStarted || !activeRoute || !isPublicDataLoaded || (isActiveRouteProtected && !isProtectedDataLoaded)) {
return fallbackElement;
}
@@ -221,6 +222,19 @@ export function AppRouter(props: AppRouterProps) {
}, [errorElement]);
return useMemo(() => {
+ // HACK:
+ // When there's a direct hit on a deferred route, since the route has not been registered yet (because it's a deferred registration),
+ // the React Router router instance doesn't know about that route and will therefore fallback to the no match route.
+ // If there's no custom no match route defined with path="*", React Router will not even bother trying to render a route and will defer to
+ // it's default no match route, which breaks the AppRouter logic.
+ // To circumvent this issue, if the application doesn't register a no match route, Squide add one by default.
+ if (!routes.some(x => x.path === "*")) {
+ routes.push({
+ path: "*",
+ lazy: () => import("./NoMatchRouteFallback.tsx")
+ });
+ }
+
return renderRouterProvider([
{
element: (
diff --git a/packages/firefly/src/NoMatchRouteFallback.tsx b/packages/firefly/src/NoMatchRouteFallback.tsx
new file mode 100644
index 000000000..624493ed1
--- /dev/null
+++ b/packages/firefly/src/NoMatchRouteFallback.tsx
@@ -0,0 +1,18 @@
+export function NoMatchRouteFallback() {
+ return (
+
+
404 not found
+
This page has been dynamically added by Squide to fix an issue with the AppRouter
component. Please replace this page in your application by a custom match page.
+
The code should be like the following:
+
+{`runtime.registerRoute({
+ $visibility: "public",
+ path: "*",
+ lazy: import("./NoMatchPage.tsx")
+});`}
+
+
+ )
+}
+
+export const Component = NoMatchRouteFallback;
diff --git a/packages/firefly/src/index.ts b/packages/firefly/src/index.ts
index ae0d9da33..8a046b335 100644
--- a/packages/firefly/src/index.ts
+++ b/packages/firefly/src/index.ts
@@ -4,5 +4,7 @@ export * from "@squide/react-router";
export * from "@squide/webpack-module-federation";
export * from "./AppRouter.tsx";
+export * from "./NoMatchRouteFallback.tsx";
+
export * from "./fireflyRuntime.tsx";
diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts
index 963aaa2e3..3a12c5957 100644
--- a/packages/react-router/src/index.ts
+++ b/packages/react-router/src/index.ts
@@ -2,7 +2,7 @@ export * from "./navigationItemRegistry.ts";
export * from "./outlets.ts";
export * from "./reactRouterRuntime.ts";
export * from "./routeRegistry.ts";
-export * from "./useIsRouteMatchProtected.ts";
+export * from "./useIsRouteProtected.ts";
export * from "./useNavigationItems.ts";
export * from "./useRenderedNavigationItems.tsx";
export * from "./useRouteMatch.ts";
diff --git a/packages/react-router/src/useIsRouteMatchProtected.ts b/packages/react-router/src/useIsRouteMatchProtected.ts
deleted file mode 100644
index 813be08b9..000000000
--- a/packages/react-router/src/useIsRouteMatchProtected.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { useRouteMatch } from "./useRouteMatch.ts";
-
-export interface UseIsRouteMatchProtectedOptions {
- throwWhenThereIsNoMatch?: boolean;
-}
-
-export function useIsRouteMatchProtected(locationArg: Partial, { throwWhenThereIsNoMatch = true } = {}) {
- const activeRoute = useRouteMatch(locationArg);
-
- if (!activeRoute) {
- if (throwWhenThereIsNoMatch) {
- throw new Error(`[squide] There's no matching route for the location: "${locationArg.pathname}". Did you add routes to React Router without using the runtime.registerRoute() function?`);
- }
-
- // An unregistrered route will be considered as "protected" by default to facilitate the implementation of deferred routes.
- // The issue is that when there's a direct hit on a deferred route, it cannot be determined whether or not a deferred route is public or protected
- // because the deferred route hasn't been registered yet (since it's a deferred route).
- // If that deferred route depends on protected data, if we don't return "true" here, the deferred route will be registered without providing the protected data
- // which will cause a runtime error.
- return true;
- }
-
- return activeRoute.$visibility === "protected";
-}
diff --git a/packages/react-router/src/useIsRouteProtected.ts b/packages/react-router/src/useIsRouteProtected.ts
new file mode 100644
index 000000000..0437454e8
--- /dev/null
+++ b/packages/react-router/src/useIsRouteProtected.ts
@@ -0,0 +1,24 @@
+import { Route } from "./routeRegistry.ts";
+
+export function useIsRouteProtected(route?: Route) {
+ if (!route) {
+ // HACK:
+ // An unregistrered route will be considered as "protected" by default to facilitate the implementation of deferred routes.
+ // The issue is that when there's a direct hit on a deferred route, it cannot be determined whether or not a deferred route is public or protected
+ // because the deferred route hasn't been registered yet (since it's a deferred route).
+ // If that deferred route depends on protected data, if we don't return "true" here, the deferred route will be registered without providing the protected data
+ // which will cause a runtime error.
+ return true;
+ }
+
+ if (route.path === "*") {
+ // HACK:
+ // With the current AppRouter implementation, when there's a direct hit to a deferred route, since the route has not been registered yet to
+ // the React Router router instance, the router is trying to render the no match route which confuse this hook as it would return a boolean value
+ // for the visibility of the no match route (which is usually public) rather than the visiblity of the route asked by the consumer (which may be
+ // protected). To circumvent this issue, true is returned for no match route. Anyway, that would really make sense to direct hit the no match route.
+ return true;
+ }
+
+ return route.$visibility === "protected";
+}
diff --git a/packages/react-router/src/useRouteMatch.ts b/packages/react-router/src/useRouteMatch.ts
index 70ed98e56..111f58bf5 100644
--- a/packages/react-router/src/useRouteMatch.ts
+++ b/packages/react-router/src/useRouteMatch.ts
@@ -1,7 +1,11 @@
import { matchRoutes } from "react-router-dom";
import { useRoutes } from "./useRoutes.ts";
-export function useRouteMatch(locationArg: Partial) {
+export interface UseIsRouteMatchOptions {
+ throwWhenThereIsNoMatch?: boolean;
+}
+
+export function useRouteMatch(locationArg: Partial, { throwWhenThereIsNoMatch = true }: UseIsRouteMatchOptions = {}) {
const routes = useRoutes();
const matchingRoutes = matchRoutes(routes, locationArg) ?? [];
@@ -10,6 +14,10 @@ export function useRouteMatch(locationArg: Partial) {
// When a route is nested, it also returns all the parts that constituate the whole route (for example the layouts and the boundaries).
// We only want to know the visiblity of the actual route that has been requested, which is always the last entry.
return matchingRoutes[matchingRoutes.length - 1]!.route;
+ } else {
+ if (throwWhenThereIsNoMatch) {
+ throw new Error(`[squide] There's no matching route for the location: "${locationArg.pathname}". Did you add routes to React Router without using the runtime.registerRoute() function?`);
+ }
}
return undefined;
diff --git a/samples/endpoints/local-module/src/register.tsx b/samples/endpoints/local-module/src/register.tsx
index a5c426ae1..c333fb349 100644
--- a/samples/endpoints/local-module/src/register.tsx
+++ b/samples/endpoints/local-module/src/register.tsx
@@ -71,6 +71,10 @@ function registerRoutes(runtime: FireflyRuntime, i18nextInstance: i18n): Deferre
});
return ({ featureFlags } = {}) => {
+ if (!runtime.getSession()) {
+ throw new Error("The deferred registratons are broken again as they are executed before the protected data has been loaded.");
+ }
+
if (featureFlags?.featureA) {
runtime.registerRoute({
path: "/feature-a",
diff --git a/samples/endpoints/remote-module/src/register.tsx b/samples/endpoints/remote-module/src/register.tsx
index cc5045502..eafb7c0b4 100644
--- a/samples/endpoints/remote-module/src/register.tsx
+++ b/samples/endpoints/remote-module/src/register.tsx
@@ -109,6 +109,10 @@ function registerRoutes(runtime: FireflyRuntime, i18nextInstance: i18n): Deferre
});
return ({ featureFlags } = {}) => {
+ if (!runtime.getSession()) {
+ throw new Error("The deferred registratons are broken again as they are executed before the protected data has been loaded.");
+ }
+
if (featureFlags?.featureB) {
runtime.registerRoute({
path: "/feature-b",
diff --git a/samples/endpoints/shell/src/AppRouter.tsx b/samples/endpoints/shell/src/AppRouter.tsx
index 1161578af..8b7568567 100644
--- a/samples/endpoints/shell/src/AppRouter.tsx
+++ b/samples/endpoints/shell/src/AppRouter.tsx
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { AppRouterErrorBoundary } from "./AppRouterErrorBoundary.tsx";
import { i18NextInstanceKey } from "./i18next.ts";
+import { useRefState } from "./useRefState.tsx";
export interface DeferredRegistrationData {
featureFlags?: FeatureFlags;
@@ -55,6 +56,7 @@ async function fetchSubscription(signal: AbortSignal) {
function fetchProtectedData(
setSession: (session: Session) => void,
setSubscription: (subscription: Subscription) => void,
+ setIsProtectedDataLoaded: (isProtectedDataLoaded: boolean) => void,
signal: AbortSignal,
logger: Logger
) {
@@ -70,6 +72,7 @@ function fetchProtectedData(
logger.debug("[shell] %cSubscription is ready%c:", "color: white; background-color: green;", "", subscription);
setSubscription(subscription);
+ setIsProtectedDataLoaded(true);
})
.catch((error: unknown) => {
if (isApiError(error) && error.status === 401) {
@@ -92,7 +95,9 @@ function Loading() {
export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppRouterProps) {
const [featureFlags, setFeatureFlags] = useState();
- const [subscription, setSubscription] = useState();
+
+ const [subscriptionRef, setSubscription] = useRefState();
+ const [isProtectedDataLoaded, setIsProtectedDataLoaded] = useState(false);
const logger = useLogger();
const runtime = useRuntime();
@@ -112,7 +117,7 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR
changeLanguage(session.user.preferredLanguage);
};
- return fetchProtectedData(setSession, setSubscription, signal, logger);
+ return fetchProtectedData(setSession, setSubscription, setIsProtectedDataLoaded, signal, logger);
}, [logger, sessionManager, changeLanguage]);
const handleCompleteRegistrations = useCallback(() => {
@@ -124,7 +129,7 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR
return (
-
+
}
@@ -133,7 +138,7 @@ export function AppRouter({ waitForMsw, sessionManager, telemetryService }: AppR
onLoadPublicData={handleLoadPublicData}
onLoadProtectedData={handleLoadProtectedData}
isPublicDataLoaded={!!featureFlags}
- isProtectedDataLoaded={!!sessionManager.getSession() && !!subscription}
+ isProtectedDataLoaded={isProtectedDataLoaded}
onCompleteRegistrations={handleCompleteRegistrations}
>
{(routes, providerProps) => (
diff --git a/samples/endpoints/shell/src/useRefState.tsx b/samples/endpoints/shell/src/useRefState.tsx
new file mode 100644
index 000000000..18a02e368
--- /dev/null
+++ b/samples/endpoints/shell/src/useRefState.tsx
@@ -0,0 +1,13 @@
+import { RefObject, useCallback, useRef } from "react";
+
+export function useRefState(initialValue?: T): [RefObject, (newValue: T) => void] {
+ const valueRef = useRef(initialValue);
+
+ const setValue = useCallback((newValue: T) => {
+ if (valueRef.current !== newValue) {
+ valueRef.current = newValue;
+ }
+ }, [valueRef]);
+
+ return [valueRef, setValue];
+}