Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions frontend/packages/console-app/src/hooks/usePluginFlagsSettled.ts
Comment thread
TheRealJon marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 5 additions & 1 deletion frontend/public/components/app-contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -158,6 +159,9 @@ const AppContents: FC = () => {
[pluginInfoEntries],
);

const pluginFlagsSettled = usePluginFlagsSettled(pluginInfoEntries);
const showNotFound = allPluginsProcessed && pluginFlagsSettled;

const contentRouter = (
<Routes>
{pluginPageRoutes}
Expand Down Expand Up @@ -738,7 +742,7 @@ const AppContents: FC = () => {
{inactivePluginPageRoutes}
<Route path="/" element={<DefaultPage />} />

{allPluginsProcessed ? (
{showNotFound ? (
<Route
path="*"
element={
Expand Down