diff --git a/workspaces/global-header/.changeset/components-entry-lazy-appbar.md b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md
new file mode 100644
index 00000000000..822442a2f02
--- /dev/null
+++ b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md
@@ -0,0 +1,7 @@
+---
+'@red-hat-developer-hub/backstage-plugin-global-header': minor
+---
+
+**BREAKING**: Import building blocks (`GlobalHeaderIconButton`, `GlobalHeaderMenuItem`, `GlobalHeaderDropdown`) only from `@red-hat-developer-hub/backstage-plugin-global-header/components` — they are no longer re-exported from the root entry or deprecated `/alpha`. Prefer dynamic `import()` inside blueprint loaders.
+
+Building-block UI lives solely on the `/components` Module Federation expose (`src/componentsExport.ts`) so MUI stays off the root NFS sync chunk. MUI `ClassNameGenerator` setup is also moved off the root sync path: it runs from `configureMuiClassName.ts` when the lazy AppBar / `/components` / `/legacy` UI loads, not from the root entry.
diff --git a/workspaces/global-header/docs/index.md b/workspaces/global-header/docs/index.md
index 77ef1974133..0914aa2fdb0 100644
--- a/workspaces/global-header/docs/index.md
+++ b/workspaces/global-header/docs/index.md
@@ -6,10 +6,17 @@ By default it includes a Search input field, Create, Starred[^1], Support[^2] an
The plugin supports two integration modes:
-- **New Frontend System** -- Extension blueprints (`GlobalHeaderComponentBlueprint`, `GlobalHeaderMenuItemBlueprint`) allow any plugin to contribute toolbar items and dropdown menu items. See the [New Frontend System Guide](new-frontend-system.md) for full details, including architecture, code examples, and API reference.
-- **Legacy Mount Points** -- Dynamic plugin mount points for traditional Backstage apps. See [Configuration](configuration.md).
+- **New Frontend System (stable)** -- Import from the package root
+ (`@red-hat-developer-hub/backstage-plugin-global-header`). Extension blueprints
+ (`GlobalHeaderComponentBlueprint`, `GlobalHeaderMenuItemBlueprint`) allow any
+ plugin to contribute toolbar items and dropdown menu items. Building-block UI
+ belongs on `/components` so it stays off the main NFS sync chunk. See the
+ [New Frontend System Guide](new-frontend-system.md) for full details,
+ including architecture, code examples, and API reference.
+- **Legacy Mount Points** -- Import from `/legacy` for traditional Backstage
+ apps using dynamic plugin mount points. See [Configuration](configuration.md).
-Deployers can also add menu items directly via `app-config.yaml` without writing any plugin code. See [Config-Driven Menu Items](new-frontend-system.md#config-driven-menu-items).
+Deployers can also add menu items directly via `app-config.yaml` without writing any plugin code. See [Config-Driven Menu Items](new-frontend-system.md#add-items-from-app-configyaml).
[^1]: Only when an entity is starred.
[^2]: Only when the Support URL is configured in the `app-config.yaml`.
diff --git a/workspaces/global-header/docs/new-frontend-system.md b/workspaces/global-header/docs/new-frontend-system.md
index 4d3af29960b..a6a59299bba 100644
--- a/workspaces/global-header/docs/new-frontend-system.md
+++ b/workspaces/global-header/docs/new-frontend-system.md
@@ -2,6 +2,13 @@
The global header is the top-level navigation bar in Red Hat Developer Hub. It ships with sensible defaults -- a company logo, search, notifications, and user profile -- but is designed to be extended by other plugins and configured by deployers.
+The NFS surface is **stable** on the package root
+(`@red-hat-developer-hub/backstage-plugin-global-header`). Building-block UI
+components live on a separate `/components` entry so they stay off the main
+Module Federation sync chunk. Prefer `/legacy` only for Old Frontend System
+(mount-point) apps. `/alpha` is a deprecated translations-only shim — do not
+use it for the plugin, module, or blueprints.
+
This guide explains how to:
- [Set up the header in your app](#setup)
@@ -39,7 +46,7 @@ yarn --cwd packages/app add @red-hat-developer-hub/backstage-plugin-global-heade
import { createApp } from '@backstage/frontend-defaults';
import globalHeaderPlugin, {
globalHeaderModule,
-} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
+} from '@red-hat-developer-hub/backstage-plugin-global-header';
export default createApp({
features: [
@@ -54,14 +61,15 @@ Both are required. The **module** provides the wrapper; the **plugin** provides
## Add a toolbar component
-Import `GlobalHeaderComponentBlueprint` and call `.make()`. There are three ways to define what renders.
+Import `GlobalHeaderComponentBlueprint` from the root and call `.make()`.
+There are three ways to define what renders.
### Option A: Provide data, let the framework render
Supply `icon`, `title`, and `link` (or `onClick`). The framework renders a styled icon button for you.
```typescript
-import { GlobalHeaderComponentBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
+import { GlobalHeaderComponentBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header';
export const myButton = GlobalHeaderComponentBlueprint.make({
name: 'my-button',
@@ -76,26 +84,31 @@ export const myButton = GlobalHeaderComponentBlueprint.make({
### Option B: Use building-block components
-For dropdowns or more control, provide a `component` that uses the exported building blocks (`GlobalHeaderIconButton`, `GlobalHeaderDropdown`).
+For dropdowns or more control, provide a `loader` (preferred) that dynamically
+imports building blocks from `/components` (kept off the root NFS sync
+chunk):
```typescript
-import {
- GlobalHeaderComponentBlueprint,
- GlobalHeaderDropdown,
-} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
-
-const MyDropdown = () => (
- }
- />
-);
+import { GlobalHeaderComponentBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header';
export const myDropdown = GlobalHeaderComponentBlueprint.make({
name: 'my-dropdown',
- params: { component: MyDropdown, priority: 75 },
+ params: {
+ priority: 75,
+ loader: async () => {
+ const { GlobalHeaderDropdown } = await import(
+ '@red-hat-developer-hub/backstage-plugin-global-header/components'
+ );
+ return () => (
+ }
+ />
+ );
+ },
+ },
});
```
@@ -116,17 +129,18 @@ export const myWidget = GlobalHeaderComponentBlueprint.make({
### Parameters reference
-| Param | Type | Description |
-| ----------- | ------------------------- | ----------------------------------------------- |
-| `icon` | `string` | Icon name, inline SVG, or URL |
-| `title` | `string` | Display title (also tooltip and aria-label) |
-| `titleKey` | `string` | i18n translation key for the title |
-| `tooltip` | `string` | Explicit tooltip (overrides `title`) |
-| `link` | `string` | Navigation URL |
-| `onClick` | `() => void` | Click handler (mutually exclusive with `link`) |
-| `component` | `ComponentType` | Custom React component (options B/C) |
-| `priority` | `number` | Sort order -- higher values appear further left |
-| `layout` | `Record` | MUI `sx` overrides on the wrapper |
+| Param | Type | Description |
+| ----------- | ------------------------------ | ----------------------------------------------------- |
+| `icon` | `string` | Icon name, inline SVG, or URL |
+| `title` | `string` | Display title (also tooltip and aria-label) |
+| `titleKey` | `string` | i18n translation key for the title |
+| `tooltip` | `string` | Explicit tooltip (overrides `title`) |
+| `link` | `string` | Navigation URL |
+| `onClick` | `() => void` | Click handler (mutually exclusive with `link`) |
+| `component` | `ComponentType` | Custom React component (options B/C; prefer `loader`) |
+| `loader` | `() => Promise` | Async component factory (keeps UI off sync) |
+| `priority` | `number` | Sort order -- higher values appear further left |
+| `layout` | `Record` | MUI `sx` overrides on the wrapper |
## Add a menu item
@@ -143,7 +157,7 @@ Import `GlobalHeaderMenuItemBlueprint`. The `target` field routes the item to th
Provide `title`, `link`, and optionally `icon` / `sectionLabel`. Items that share a `sectionLabel` are grouped under that heading.
```typescript
-import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
+import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header';
export const docsItem = GlobalHeaderMenuItemBlueprint.make({
name: 'my-docs',
@@ -160,26 +174,38 @@ export const docsItem = GlobalHeaderMenuItemBlueprint.make({
### Custom component item using building blocks
-Use the exported `GlobalHeaderMenuItem` to build a complete, clickable menu item with consistent styling. The component receives `handleClose` and `hideDivider` as props from the dropdown.
+Use `GlobalHeaderMenuItem` from `/components` inside a blueprint `loader` so the
+UI stays off the root NFS sync chunk. The component receives
+`handleClose` and `hideDivider` as props from the dropdown.
```typescript
-import {
- GlobalHeaderMenuItemBlueprint,
- GlobalHeaderMenuItem,
-} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
-
-const MyDocsLink = ({ handleClose }: { handleClose?: () => void }) => (
-
-);
+import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header';
export const myDocsItem = GlobalHeaderMenuItemBlueprint.make({
name: 'my-docs-link',
- params: { target: 'help', component: MyDocsLink, priority: 50 },
+ params: {
+ target: 'help',
+ priority: 50,
+ loader: async () => {
+ const { GlobalHeaderMenuItem } = await import(
+ '@red-hat-developer-hub/backstage-plugin-global-header/components'
+ );
+ return function MyDocsLink({
+ handleClose,
+ }: {
+ handleClose?: () => void;
+ }) {
+ return (
+
+ );
+ };
+ },
+ },
});
```
@@ -224,7 +250,7 @@ import { createFrontendPlugin } from '@backstage/frontend-plugin-api';
import {
GlobalHeaderComponentBlueprint,
GlobalHeaderMenuItemBlueprint,
-} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
+} from '@red-hat-developer-hub/backstage-plugin-global-header';
export default createFrontendPlugin({
pluginId: 'my-plugin',
@@ -346,7 +372,8 @@ Extension ID pattern: `gh-menu-item:global-header/`
For plugin authors building custom toolbar components or dropdowns, the plugin exports lower-level building blocks and React hooks:
-**Building-block components** (consistent styling without starting from scratch):
+**Building-block components** (import from `/components` — not the root
+entry — so MUI stays off the main NFS sync chunk):
| Component | Key props | Purpose |
| ------------------------ | -------------------------------- | ---------------------------------------------------------------------------- |
@@ -354,6 +381,16 @@ For plugin authors building custom toolbar components or dropdowns, the plugin e
| `GlobalHeaderMenuItem` | `to`, `title`, `icon`, `onClick` | Complete clickable menu item with link navigation and consistent styling |
| `GlobalHeaderDropdown` | `target`, `buttonContent` | Dropdown that auto-collects `gh-menu-item` extensions for the given `target` |
+```typescript
+import {
+ GlobalHeaderIconButton,
+ GlobalHeaderMenuItem,
+ GlobalHeaderDropdown,
+} from '@red-hat-developer-hub/backstage-plugin-global-header/components';
+```
+
+Prefer dynamic `import()` of these from inside blueprint `loader`s.
+
**Context hooks** (direct access to collected extension data):
| Hook | Returns |
@@ -363,4 +400,9 @@ For plugin authors building custom toolbar components or dropdowns, the plugin e
**Translations:** Use `titleKey` / `subTitleKey` for i18n. Keys containing dots (e.g. `'applicationLauncher.sections.documentation'`) are auto-resolved. The plugin exports `globalHeaderTranslationRef` and `globalHeaderTranslations` for overrides.
-All exports are available from `@red-hat-developer-hub/backstage-plugin-global-header/alpha`.
+| Entry | Use for |
+| ------------------ | ----------------------------------------------------------------- |
+| Package root (`.`) | NFS plugin, module, blueprints, hooks, translations |
+| `/components` | Building-block UI only (`GlobalHeaderMenuItem`, dropdowns, icons) |
+| `/legacy` | Deprecated OFS / mount-point API |
+| `/alpha` | Deprecated translations re-export only (not an NFS entry) |
diff --git a/workspaces/global-header/packages/app/src/App.tsx b/workspaces/global-header/packages/app/src/App.tsx
index 3be9e4b28d9..f713e5aca30 100644
--- a/workspaces/global-header/packages/app/src/App.tsx
+++ b/workspaces/global-header/packages/app/src/App.tsx
@@ -27,7 +27,6 @@ import {
globalHeaderModule,
globalHeaderTranslationsModule,
GlobalHeaderMenuItemBlueprint,
- GlobalHeaderMenuItem,
} from '@red-hat-developer-hub/backstage-plugin-global-header';
import { navModule } from './modules/nav';
@@ -57,15 +56,6 @@ const signInModule = createFrontendModule({
],
});
-const CustomHelpMenuItem = ({ handleClose }: { handleClose?: () => void }) => (
-
-);
-
const headerExamplesPlugin = createFrontendPlugin({
pluginId: 'header-examples',
extensions: [
@@ -73,8 +63,28 @@ const headerExamplesPlugin = createFrontendPlugin({
name: 'custom-help-docs',
params: {
target: 'help',
- component: CustomHelpMenuItem,
priority: 50,
+ // Import building blocks from `/components` inside the loader so they
+ // stay off the main `/alpha` NFS sync chunk.
+ loader: async () => {
+ const { GlobalHeaderMenuItem } = await import(
+ '@red-hat-developer-hub/backstage-plugin-global-header/components'
+ );
+ return function CustomHelpMenuItem({
+ handleClose,
+ }: {
+ handleClose?: () => void;
+ }) {
+ return (
+
+ );
+ };
+ },
},
}),
],
diff --git a/workspaces/global-header/plugins/global-header/README.md b/workspaces/global-header/plugins/global-header/README.md
index 8c294145479..3c72d612a7a 100644
--- a/workspaces/global-header/plugins/global-header/README.md
+++ b/workspaces/global-header/plugins/global-header/README.md
@@ -21,13 +21,14 @@ yarn --cwd packages/app add @red-hat-developer-hub/backstage-plugin-global-heade
### New Frontend System
-Import the plugin and module in your NFS app:
+The NFS plugin is available from the package root (`.`). Import the
+plugin and module in your NFS app:
```typescript
import { createApp } from '@backstage/frontend-defaults';
import globalHeaderPlugin, {
globalHeaderModule,
-} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha';
+} from '@red-hat-developer-hub/backstage-plugin-global-header';
export default createApp({
features: [
@@ -38,11 +39,21 @@ export default createApp({
});
```
-Other plugins can contribute toolbar items and dropdown menu items using `GlobalHeaderComponentBlueprint` and `GlobalHeaderMenuItemBlueprint`. See the [New Frontend System documentation](../../docs/new-frontend-system.md) for detailed examples and API reference.
+> `/alpha` is deprecated and translations-only — use the root import for NFS.
+> Legacy (OFS) mounts live under `/legacy`.
+
+Other plugins can contribute toolbar items and dropdown menu items using
+`GlobalHeaderComponentBlueprint` and `GlobalHeaderMenuItemBlueprint` from the
+root. Building-block UI (`GlobalHeaderMenuItem`, `GlobalHeaderDropdown`,
+…) must be imported from `/components` (ideally inside a blueprint `loader`) so
+MUI-heavy UI stays off the main NFS Module Federation sync chunk. See the
+[New Frontend System documentation](../../docs/new-frontend-system.md) for
+detailed examples and API reference.
### Legacy (Mount Points)
-For legacy Backstage apps using dynamic plugin mount points, see the [Configuration documentation](../../docs/configuration.md).
+For legacy Backstage apps using dynamic plugin mount points, import from
+`/legacy` and see the [Configuration documentation](../../docs/configuration.md).
## Configuration
diff --git a/workspaces/global-header/plugins/global-header/package.json b/workspaces/global-header/plugins/global-header/package.json
index 7c8b80cd05a..98a82e8218f 100644
--- a/workspaces/global-header/plugins/global-header/package.json
+++ b/workspaces/global-header/plugins/global-header/package.json
@@ -9,6 +9,7 @@
"./legacy": "./src/legacy.ts",
"./global-header-module": "./src/globalHeaderModuleExport.ts",
"./global-header-translations-module": "./src/globalHeaderTranslationsModuleExport.ts",
+ "./components": "./src/componentsExport.ts",
"./package.json": "./package.json"
},
"typesVersions": {
@@ -25,6 +26,9 @@
"global-header-translations-module": [
"src/globalHeaderTranslationsModuleExport.ts"
],
+ "components": [
+ "src/componentsExport.ts"
+ ],
"package.json": [
"package.json"
]
@@ -123,6 +127,7 @@
"PluginRoot": "./src/index.ts",
"Alpha": "./src/alpha/index.ts",
"Legacy": "./src/legacy.ts",
+ "Components": "./src/componentsExport.ts",
"GlobalHeaderModule": "./src/globalHeaderModuleExport.ts",
"GlobalHeaderTranslationsModule": "./src/globalHeaderTranslationsModuleExport.ts"
}
diff --git a/workspaces/global-header/plugins/global-header/report-components.api.md b/workspaces/global-header/plugins/global-header/report-components.api.md
new file mode 100644
index 00000000000..6d40acdd61c
--- /dev/null
+++ b/workspaces/global-header/plugins/global-header/report-components.api.md
@@ -0,0 +1,75 @@
+## API Report File for "@red-hat-developer-hub/backstage-plugin-global-header"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+import type Button from '@mui/material/Button';
+import type { ComponentProps } from 'react';
+import { FrontendModule } from '@backstage/frontend-plugin-api';
+import { JSX as JSX_2 } from 'react/jsx-runtime';
+import type { ReactNode } from 'react';
+
+// @public
+const _default: FrontendModule;
+export default _default;
+
+// @public
+export const GlobalHeaderDropdown: (
+ input: GlobalHeaderDropdownProps,
+) => JSX_2.Element | null;
+
+// @public
+export interface GlobalHeaderDropdownProps {
+ buttonContent: ReactNode;
+ buttonProps?: ComponentProps;
+ emptyState?: ReactNode;
+ isIconButton?: boolean;
+ target: string;
+ tooltip?: string;
+ trackValidity?: boolean;
+}
+
+// @public (undocumented)
+export const GlobalHeaderIconButton: (
+ input: HeaderIconButtonProps,
+) => JSX_2.Element;
+
+// @public
+export const GlobalHeaderMenuItem: (
+ input: GlobalHeaderMenuItemProps,
+) => JSX_2.Element;
+
+// @public
+export interface GlobalHeaderMenuItemProps {
+ icon?: string;
+ onClick?: () => void;
+ subTitle?: string;
+ subTitleKey?: string;
+ title?: string;
+ titleKey?: string;
+ to?: string;
+ tooltip?: string;
+}
+
+// @public (undocumented)
+export interface HeaderIconButtonProps {
+ // (undocumented)
+ ariaLabel?: string;
+ // (undocumented)
+ color?: 'inherit' | 'primary' | 'secondary' | 'default';
+ // (undocumented)
+ icon: string;
+ // (undocumented)
+ size?: 'small' | 'medium' | 'large';
+ // (undocumented)
+ title: string;
+ // (undocumented)
+ titleKey?: string;
+ // (undocumented)
+ to: string;
+ // (undocumented)
+ tooltip?: string;
+}
+
+// (No @packageDocumentation comment for this package)
+```
diff --git a/workspaces/global-header/plugins/global-header/report-legacy.api.md b/workspaces/global-header/plugins/global-header/report-legacy.api.md
index 33f80a84ce5..7721e0a640e 100644
--- a/workspaces/global-header/plugins/global-header/report-legacy.api.md
+++ b/workspaces/global-header/plugins/global-header/report-legacy.api.md
@@ -386,6 +386,4 @@ export interface SupportButtonProps {
// (undocumented)
tooltip?: string;
}
-
-// (No @packageDocumentation comment for this package)
```
diff --git a/workspaces/global-header/plugins/global-header/report.api.md b/workspaces/global-header/plugins/global-header/report.api.md
index 34d78f709bc..b88fb6ebdd7 100644
--- a/workspaces/global-header/plugins/global-header/report.api.md
+++ b/workspaces/global-header/plugins/global-header/report.api.md
@@ -3,17 +3,13 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
-import type Button from '@mui/material/Button';
-import type { ComponentProps } from 'react';
import type { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { FrontendModule } from '@backstage/frontend-plugin-api';
-import { JSX as JSX_2 } from 'react/jsx-runtime';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
-import type { ReactNode } from 'react';
import { TranslationRef } from '@backstage/frontend-plugin-api';
import { TranslationResource } from '@backstage/frontend-plugin-api';
@@ -545,32 +541,6 @@ export const globalHeaderComponentDataRef: ConfigurableExtensionDataRef<
{}
>;
-// @public
-export const GlobalHeaderDropdown: (
- input: GlobalHeaderDropdownProps,
-) => JSX_2.Element | null;
-
-// @public
-export interface GlobalHeaderDropdownProps {
- buttonContent: ReactNode;
- buttonProps?: ComponentProps;
- emptyState?: ReactNode;
- isIconButton?: boolean;
- target: string;
- tooltip?: string;
- trackValidity?: boolean;
-}
-
-// @public (undocumented)
-export const GlobalHeaderIconButton: (
- input: HeaderIconButtonProps,
-) => JSX_2.Element;
-
-// @public
-export const GlobalHeaderMenuItem: (
- input: GlobalHeaderMenuItemProps,
-) => JSX_2.Element;
-
// @public
export const GlobalHeaderMenuItemBlueprint: ExtensionBlueprint<{
kind: 'gh-menu-item';
@@ -637,18 +607,6 @@ export const globalHeaderMenuItemDataRef: ConfigurableExtensionDataRef<
{}
>;
-// @public
-export interface GlobalHeaderMenuItemProps {
- icon?: string;
- onClick?: () => void;
- subTitle?: string;
- subTitleKey?: string;
- title?: string;
- titleKey?: string;
- to?: string;
- tooltip?: string;
-}
-
// @public
export const globalHeaderModule: FrontendModule;
@@ -1064,26 +1022,6 @@ export const globalHeaderTranslations: TranslationResource<'plugin.global-header
// @public
export const globalHeaderTranslationsModule: FrontendModule;
-// @public (undocumented)
-export interface HeaderIconButtonProps {
- // (undocumented)
- ariaLabel?: string;
- // (undocumented)
- color?: 'inherit' | 'primary' | 'secondary' | 'default';
- // (undocumented)
- icon: string;
- // (undocumented)
- size?: 'small' | 'medium' | 'large';
- // (undocumented)
- title: string;
- // (undocumented)
- titleKey?: string;
- // (undocumented)
- to: string;
- // (undocumented)
- tooltip?: string;
-}
-
// @public (undocumented)
export const helpDropdownExtension: OverridableExtensionDefinition<{
kind: 'gh-component';
@@ -1138,12 +1076,12 @@ export const logoutMenuItemExtension: OverridableExtensionDefinition<{
// @public
export interface MenuItemParams {
- // (undocumented)
component?: ComponentType;
// (undocumented)
icon?: string;
// (undocumented)
link?: string;
+ loader?: () => Promise>;
// (undocumented)
onClick?: () => void;
// (undocumented)
@@ -1378,13 +1316,13 @@ export const supportButtonMenuItemExtension: OverridableExtensionDefinition<{
// @public
export interface ToolbarComponentParams {
- // (undocumented)
component?: ComponentType;
// (undocumented)
icon?: string;
layout?: Record;
// (undocumented)
link?: string;
+ loader?: () => Promise>;
// (undocumented)
onClick?: () => void;
// (undocumented)
diff --git a/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx
index 6461234af52..aeea038e319 100644
--- a/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx
@@ -14,11 +14,11 @@
* limitations under the License.
*/
-import AppsIcon from '@mui/icons-material/Apps';
-import AppRegistrationIcon from '@mui/icons-material/AppRegistration';
+import Box from '@mui/material/Box';
+import { HeaderIcon } from '../components/HeaderIcon/HeaderIcon';
import { useTranslation } from '../hooks/useTranslation';
-import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState';
+import { DropdownEmptyState } from '../components/HeaderDropdownComponent/DropdownEmptyState';
import { GlobalHeaderDropdown } from './GlobalHeaderDropdown';
/**
@@ -34,15 +34,15 @@ export const ApplicationLauncherDropdown = () => {
target="app-launcher"
isIconButton
tooltip={t('applicationLauncher.tooltip')}
- buttonContent={}
+ buttonContent={}
emptyState={
+
+
+
}
/>
}
diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx
index 6f7a063a608..5f6f6493470 100644
--- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx
@@ -49,10 +49,9 @@ describe('GlobalHeader', () => {
,
);
- expect(screen.getByRole('navigation')).toBeInTheDocument();
- expect(
- screen.getByRole('navigation').querySelector('.MuiToolbar-root'),
- ).toBeEmptyDOMElement();
+ const nav = screen.getByRole('navigation');
+ expect(nav).toBeInTheDocument();
+ expect(nav.firstElementChild).toBeEmptyDOMElement();
});
it('renders the nav element with id="global-header"', () => {
diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx
index 258926b9ec2..1cce44b4cf7 100644
--- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx
@@ -20,6 +20,7 @@ import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
+import '../configureMuiClassName';
import { useGlobalHeaderComponents } from '../extensions/GlobalHeaderContext';
/**
diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx
new file mode 100644
index 00000000000..ede62cce266
--- /dev/null
+++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx
@@ -0,0 +1,354 @@
+/*
+ * Copyright Red Hat, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ useEffect,
+ useState,
+ type ComponentProps,
+ type ComponentType,
+} from 'react';
+import { act, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import MenuItem from '@mui/material/MenuItem';
+import { renderInTestApp } from '@backstage/test-utils';
+
+import { GlobalHeaderProvider } from '../extensions/GlobalHeaderContext';
+import type { GlobalHeaderMenuItemData } from '../types';
+import { GlobalHeaderDropdown } from './GlobalHeaderDropdown';
+
+jest.mock('../hooks/useTranslation', () => {
+ const { mockUseTranslation } = require('../test-utils/mockTranslations');
+ return { useTranslation: mockUseTranslation };
+});
+
+jest.mock('../components/Trans', () => {
+ const { MockTrans } = require('../test-utils/mockTranslations');
+ return { Trans: MockTrans };
+});
+
+const emptyState = No items
;
+
+const renderDropdown = (
+ menuItems: GlobalHeaderMenuItemData[],
+ props: Partial> = {},
+) =>
+ renderInTestApp(
+
+ Help}
+ emptyState={emptyState}
+ {...props}
+ />
+ ,
+ );
+
+const openMenu = async () => {
+ await userEvent.click(screen.getByRole('button', { name: /help/i }));
+};
+
+describe('GlobalHeaderDropdown', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.clearAllMocks();
+ });
+
+ it('returns null when there are no menu items and no emptyState', async () => {
+ const { container } = await renderInTestApp(
+
+ Help} />
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders the trigger when emptyState is provided with no menu items', async () => {
+ await renderDropdown([]);
+
+ expect(screen.getByRole('button', { name: /help/i })).toBeInTheDocument();
+ });
+
+ it('shows emptyState immediately when there are no contributions', async () => {
+ await renderDropdown([]);
+ await openMenu();
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument();
+ });
+
+ it('renders data-driven menu items', async () => {
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'data',
+ title: 'Documentation',
+ link: '/docs',
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems);
+ await openMenu();
+
+ expect(
+ await screen.findByRole('menuitem', { name: /documentation/i }),
+ ).toBeInTheDocument();
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ });
+
+ it('renders component-type menu items', async () => {
+ const SupportItem: ComponentType<{ handleClose?: () => void }> = () => (
+
+ );
+
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'component',
+ component: SupportItem,
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems);
+ await openMenu();
+
+ expect(
+ await screen.findByRole('menuitem', { name: /support/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('ignores menu items for other targets', async () => {
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'create',
+ type: 'data',
+ title: 'Create template',
+ link: '/create',
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems);
+ await openMenu();
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument();
+ expect(
+ screen.queryByRole('menuitem', { name: /create template/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ describe('trackValidity', () => {
+ it('shows emptyState after settle timeout when contributed items render nothing', async () => {
+ jest.useFakeTimers();
+ const user = userEvent.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ });
+
+ const NullItem: ComponentType = () => null;
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'component',
+ component: NullItem,
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems, { trackValidity: true });
+ await user.click(screen.getByRole('button', { name: /help/i }));
+
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument();
+ });
+
+ it('keeps showing menu content when a menuitem is present synchronously', async () => {
+ jest.useFakeTimers();
+ const user = userEvent.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ });
+
+ const SupportItem: ComponentType = () => ;
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'component',
+ component: SupportItem,
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems, { trackValidity: true });
+ await user.click(screen.getByRole('button', { name: /help/i }));
+
+ expect(
+ await screen.findByRole('menuitem', { name: /support/i }),
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ jest.advanceTimersByTime(1500);
+ });
+
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('menuitem', { name: /support/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('does not show emptyState when a lazy item appears before settle timeout', async () => {
+ jest.useFakeTimers();
+ const user = userEvent.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ });
+
+ const LazySupport: ComponentType = () => {
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ const id = window.setTimeout(() => setReady(true), 100);
+ return () => window.clearTimeout(id);
+ }, []);
+ if (!ready) return null;
+ return ;
+ };
+
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'component',
+ component: LazySupport,
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems, { trackValidity: true });
+ await user.click(screen.getByRole('button', { name: /help/i }));
+
+ await act(async () => {
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(
+ await screen.findByRole('menuitem', { name: /support/i }),
+ ).toBeInTheDocument();
+
+ await act(async () => {
+ jest.advanceTimersByTime(1500);
+ });
+
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ });
+
+ it('recovers from emptyState when a lazy item appears after settle timeout', async () => {
+ jest.useFakeTimers();
+ const user = userEvent.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ });
+
+ const VeryLazySupport: ComponentType = () => {
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ const id = window.setTimeout(() => setReady(true), 800);
+ return () => window.clearTimeout(id);
+ }, []);
+ if (!ready) return null;
+ return ;
+ };
+
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'component',
+ component: VeryLazySupport,
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems, { trackValidity: true });
+ await user.click(screen.getByRole('button', { name: /help/i }));
+
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument();
+
+ await act(async () => {
+ jest.advanceTimersByTime(300);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+ });
+ expect(
+ screen.getByRole('menuitem', { name: /support/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('hides contributed content while emptyState is shown, without unmounting it', async () => {
+ jest.useFakeTimers();
+ const user = userEvent.setup({
+ advanceTimers: jest.advanceTimersByTime,
+ });
+
+ let unmounted = false;
+ const PersistentNullItem: ComponentType = () => {
+ useEffect(() => {
+ return () => {
+ unmounted = true;
+ };
+ }, []);
+ return ;
+ };
+
+ const menuItems: GlobalHeaderMenuItemData[] = [
+ {
+ target: 'help',
+ type: 'component',
+ component: PersistentNullItem,
+ priority: 10,
+ },
+ ];
+
+ await renderDropdown(menuItems, { trackValidity: true });
+ await user.click(screen.getByRole('button', { name: /help/i }));
+
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument();
+ expect(screen.getByTestId('persistent-null-host')).not.toBeVisible();
+ expect(unmounted).toBe(false);
+ });
+ });
+
+ it('forwards tooltip and icon button props to the trigger', async () => {
+ await renderDropdown([], {
+ isIconButton: true,
+ tooltip: 'Help menu',
+ buttonContent: icon,
+ });
+
+ expect(
+ screen.getByRole('button', { name: 'Help menu' }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx
index f52137d65b4..bbdaa4a5288 100644
--- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import { useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentProps, ReactNode } from 'react';
import type Button from '@mui/material/Button';
@@ -24,6 +24,14 @@ import { useDropdownManager } from '../hooks';
import { HeaderDropdownComponent } from './HeaderDropdownComponent/HeaderDropdownComponent';
import { GlobalHeaderDropdownContent } from './GlobalHeaderDropdownContent';
+/**
+ * Settle delays for lazy menu items.
+ * Matches the legacy OFS HelpDropdown validity tracking behaviour.
+ */
+const VALIDITY_CHECK_MS = [500, 1500] as const;
+
+type MenuValidity = 'pending' | 'valid' | 'empty';
+
/**
* Props for {@link GlobalHeaderDropdown}.
*
@@ -43,9 +51,8 @@ export interface GlobalHeaderDropdownProps {
/** Rendered when no menu items are contributed (or all render empty when `trackValidity` is on). */
emptyState?: ReactNode;
/**
- * When `true`, the dropdown checks the rendered MenuList for visible
- * `[role="menuitem"]` elements after each render. If none are found,
- * the `emptyState` is shown instead.
+ * When enabled, waits for lazy menu items to settle before deciding whether
+ * to display the empty state.
*/
trackValidity?: boolean;
}
@@ -73,21 +80,66 @@ export const GlobalHeaderDropdown = ({
const entries = useMemo(() => buildDropdownEntries(menuItems), [menuItems]);
const menuListRef = useRef(null);
- const [hasVisibleItems, setHasVisibleItems] = useState(true);
+ const [menuValidity, setMenuValidity] = useState('pending');
+
const isOpen = Boolean(anchorEl);
- useLayoutEffect(() => {
- if (!trackValidity || !isOpen || !menuListRef.current) return;
- const found =
- menuListRef.current.querySelector('[role="menuitem"]') !== null;
- if (found !== hasVisibleItems) {
- setHasVisibleItems(found);
+ useEffect(() => {
+ if (!trackValidity) {
+ return;
+ }
+
+ if (!isOpen) {
+ setMenuValidity('pending');
+ return;
+ }
+
+ const list = menuListRef.current;
+ if (!list) {
+ return;
}
- }, [trackValidity, hasVisibleItems, isOpen]);
- if (menuItems.length === 0 && !emptyState) return null;
+ const syncFromDom = () => {
+ if (list.querySelector('[role="menuitem"]')) {
+ setMenuValidity('valid');
+ }
+ };
- const showEmpty = entries.length === 0 || (trackValidity && !hasVisibleItems);
+ // Handle items that rendered synchronously.
+ syncFromDom();
+
+ const observer = new MutationObserver(syncFromDom);
+
+ observer.observe(list, {
+ childList: true,
+ subtree: true,
+ });
+
+ const concludeEmpty = () => {
+ if (!list.querySelector('[role="menuitem"]')) {
+ setMenuValidity(prev => (prev === 'valid' ? prev : 'empty'));
+ }
+ };
+
+ const timers = VALIDITY_CHECK_MS.map(ms =>
+ window.setTimeout(concludeEmpty, ms),
+ );
+
+ // eslint-disable-next-line consistent-return
+ return () => {
+ observer.disconnect();
+ timers.forEach(id => window.clearTimeout(id));
+ };
+ }, [trackValidity, isOpen]);
+
+ if (menuItems.length === 0 && !emptyState) {
+ return null;
+ }
+
+ const hasNoContributions = entries.length === 0;
+
+ const showEmptyState =
+ hasNoContributions || (trackValidity && menuValidity === 'empty');
return (
- {showEmpty ? (
+ {hasNoContributions ? (
emptyState
) : (
-
+ <>
+ {/*
+ * Keep the menu content mounted while showing the empty state so
+ * lazy ExtensionBoundary items can still render and recover.
+ * This prevents permanently latching into the empty state if a
+ * menu item appears after the initial validity check.
+ */}
+
+
+
+
+ {trackValidity && showEmptyState ? emptyState : null}
+ >
)}
);
diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx
index 3dc81c31a7b..c2d4ee3e8e5 100644
--- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx
@@ -80,7 +80,15 @@ export const GlobalHeaderMenuItem = ({
disableRipple
disableTouchRipple
onClick={onClick}
- sx={{ py: 0.5, color: 'inherit', textDecoration: 'none' }}
+ sx={{
+ py: 0.5,
+ px: 0,
+ width: '100%',
+ boxSizing: 'border-box',
+ display: 'flex',
+ color: 'inherit',
+ textDecoration: 'none',
+ }}
{...(to ? { component: Link, to } : {})}
>
= ({
onClick?.();
handleClose();
}}
- sx={{ py: 0.5, color: 'inherit', textDecoration: 'none' }}
+ sx={{
+ py: 0.5,
+ px: 0,
+ width: '100%',
+ boxSizing: 'border-box',
+ display: 'flex',
+ color: 'inherit',
+ textDecoration: 'none',
+ }}
+ // Fragment when there is no link so nested full MenuItems
+ // (e.g. SupportButton) are not wrapped in a second menuitem.
component={link ? Link : Fragment}
to={link}
>
diff --git a/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx
index 9c9f9e42e00..18a8c1fec6e 100644
--- a/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx
@@ -14,9 +14,9 @@
* limitations under the License.
*/
-import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
-import SupportAgentIcon from '@mui/icons-material/SupportAgent';
+import Box from '@mui/material/Box';
+import { HeaderIcon } from './HeaderIcon/HeaderIcon';
import { useTranslation } from '../hooks/useTranslation';
import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState';
import { GlobalHeaderDropdown } from './GlobalHeaderDropdown';
@@ -34,14 +34,16 @@ export const HelpDropdown = () => {
trackValidity
isIconButton
tooltip={t('help.tooltip')}
- buttonContent={}
+ buttonContent={}
buttonProps={{ color: 'inherit' }}
emptyState={
+
+
+
}
/>
}
diff --git a/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx b/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx
index 0749792a121..bf84af237ca 100644
--- a/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx
@@ -46,7 +46,11 @@ export const LogoutButton = ({
onClick={handleLogout}
sx={{
cursor: 'pointer',
+ py: 0.5,
+ px: 0,
width: '100%',
+ boxSizing: 'border-box',
+ display: 'flex',
color: 'inherit',
}}
>
diff --git a/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx b/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx
index a6b513d3023..a599e84ee5b 100644
--- a/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx
@@ -40,7 +40,11 @@ export const MenuItemLinkContent: FC = ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
- margin: '8px 0',
+ my: 1,
+ // Pad content (not MenuItem) — RHDH/PatternFly zeros padding on
+ // menu anchors and list items across help, profile, etc.
+ px: 2,
+ boxSizing: 'border-box',
color: 'inherit',
width: '100%',
}}
diff --git a/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx b/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx
index caa8bbdbf3b..e314029cb67 100644
--- a/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx
@@ -71,7 +71,15 @@ export const MyProfileMenuItem = ({
onClick={handleClose}
disableRipple
disableTouchRipple
- sx={{ py: 0.5, color: 'inherit', textDecoration: 'none' }}
+ sx={{
+ py: 0.5,
+ px: 0,
+ width: '100%',
+ boxSizing: 'border-box',
+ display: 'flex',
+ color: 'inherit',
+ textDecoration: 'none',
+ }}
>
{
alt={t('profile.picture')}
/>
) : (
-
+
+
+
)}
{
>
)}
-
+
+
+
);
};
diff --git a/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx b/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx
index 0fa42684ccf..f7ea8c67c9b 100644
--- a/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx
+++ b/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx
@@ -61,7 +61,15 @@ export const SupportButton = ({