diff --git a/frontend/packages/console-app/src/hooks/usePluginFlagsSettled.ts b/frontend/packages/console-app/src/hooks/usePluginFlagsSettled.ts new file mode 100644 index 00000000000..96e4ed9e379 --- /dev/null +++ b/frontend/packages/console-app/src/hooks/usePluginFlagsSettled.ts @@ -0,0 +1,61 @@ +import { useState, useEffect, useMemo } from 'react'; +import type { PluginInfoEntry } from '@openshift/dynamic-plugin-sdk'; +import { flagPending } from '@console/internal/reducers/features'; +import { useConsoleSelector } from '@console/shared/src/hooks/useConsoleSelector'; + +const FLAG_SETTLING_TIMEOUT_MS = 3000; + +/** + * Checks whether all loaded plugin feature flags have settled. + * + * When a dynamic plugin finishes loading, its `console.flag/hookProvider` + * extensions run asynchronously. Until a hookProvider calls `setFeatureFlag`, + * its flag value is `undefined` (pending) in Redux. Routes gated on those + * flags are invisible to the router during this window. + * + * This hook inspects each loaded plugin's `manifest.extensions` for required + * flags that are still pending, and includes a timeout fallback so that + * broken hookProviders do not permanently block the 404 catch-all. + * + * @param pluginInfoEntries - current plugin information from `usePluginInfo()` + * @returns `true` when all plugin flags have resolved or the timeout has elapsed + */ +export const usePluginFlagsSettled = (pluginInfoEntries: PluginInfoEntry[]): boolean => { + const reduxFlags = useConsoleSelector((state) => state.FLAGS); + + const hasPendingPluginFlags = useMemo( + () => + pluginInfoEntries.some((entry) => { + if (entry.status === 'pending' || entry.status === 'failed') return false; + const extensions = entry.manifest?.extensions; + if (!Array.isArray(extensions)) return false; + return extensions.some((ext) => { + const required = ext.flags?.required; + if (!Array.isArray(required) || required.length === 0) return false; + return required.some((flagName: string) => flagPending(reduxFlags[flagName])); + }); + }), + [pluginInfoEntries, reduxFlags], + ); + + const [flagSettlingTimedOut, setFlagSettlingTimedOut] = useState(false); + + const [prevHasPending, setPrevHasPending] = useState(false); + if (prevHasPending && !hasPendingPluginFlags) { + setFlagSettlingTimedOut(false); + } + if (prevHasPending !== hasPendingPluginFlags) { + setPrevHasPending(hasPendingPluginFlags); + } + + useEffect(() => { + if (!hasPendingPluginFlags || flagSettlingTimedOut) { + return; + } + + const timer = setTimeout(() => setFlagSettlingTimedOut(true), FLAG_SETTLING_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [hasPendingPluginFlags, flagSettlingTimedOut]); + + return !hasPendingPluginFlags || flagSettlingTimedOut; +}; diff --git a/frontend/public/components/app-contents.tsx b/frontend/public/components/app-contents.tsx index 6fdef8672d4..48c7e1798af 100644 --- a/frontend/public/components/app-contents.tsx +++ b/frontend/public/components/app-contents.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useMemo, Suspense } from 'react'; import { PageSection } from '@patternfly/react-core'; import * as _ from 'lodash'; import { Route, Routes, Navigate, useParams, useLocation, matchRoutes } from 'react-router'; +import { usePluginFlagsSettled } from '@console/app/src/hooks/usePluginFlagsSettled'; import { usePluginRoutes } from '@console/app/src/hooks/usePluginRoutes'; import type { Perspective } from '@console/dynamic-plugin-sdk'; import { useActivePerspective } from '@console/dynamic-plugin-sdk'; @@ -158,6 +159,9 @@ const AppContents: FC = () => { [pluginInfoEntries], ); + const pluginFlagsSettled = usePluginFlagsSettled(pluginInfoEntries); + const showNotFound = allPluginsProcessed && pluginFlagsSettled; + const contentRouter = ( {pluginPageRoutes} @@ -738,7 +742,7 @@ const AppContents: FC = () => { {inactivePluginPageRoutes} } /> - {allPluginsProcessed ? ( + {showNotFound ? (