Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
9.5 KiB
Swappable Component Architecture Refactor
Problem
The initial implementation had @backstage/frontend-plugin-api directly importing Header from @backstage/ui, which violated the package dependency architecture:
@backstage/frontend-plugin-apiis a low-level API package@backstage/uiis a higher-level UI component library- API packages should not depend on UI libraries
Solution
Implemented a swappable component pattern that:
- Provides a default implementation using plain HTML elements in
@backstage/frontend-plugin-api - Ships the
@backstage/uiHeader implementation via the@backstage/plugin-appplugin - Allows apps to override the component with custom implementations
This follows the same pattern used for other swappable components like Progress, NotFoundErrorPage, and ErrorDisplay.
Architecture
1. Created PageWrapper Swappable Component
Location: packages/frontend-plugin-api/src/components/PageWrapper.tsx
export interface PageWrapperProps {
title?: string;
tabs?: PageTab[];
children?: ReactNode;
}
// Default implementation using plain HTML
function DefaultPageWrapper(props: PageWrapperProps): JSX.Element {
// Uses plain <div>, <header>, <nav>, <a> elements
// No dependency on @backstage/ui
}
// Swappable component that can be overridden
export const PageWrapper = createSwappableComponent<PageWrapperProps>({
id: 'core.page-wrapper',
loader: () => DefaultPageWrapper,
});
Key Features:
- ✅ No dependencies on
@backstage/ui - ✅ Uses only plain HTML elements in default implementation
- ✅ Provides basic styling for usability
- ✅ Can be overridden by apps
2. Updated PageBlueprint
Location: packages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx
Before:
import { Header } from '@backstage/ui'; // ❌ Direct dependency
<Header title={params.title} tabs={tabs} />;
After:
import { PageWrapper } from '../components'; // ✅ Internal swappable component
<PageWrapper title={params.title} tabs={tabs}>
{children}
</PageWrapper>;
3. Provided @backstage/ui Implementation
Location: plugins/app/src/extensions/components.tsx
export const PageWrapper = SwappableComponentBlueprint.make({
name: 'core-page-wrapper',
params: define =>
define({
component: SwappablePageWrapper,
loader: () => (props: PageWrapperProps) => {
const { title, tabs, children } = props;
return (
<>
<Header title={title} tabs={tabs} />
{children}
</>
);
},
}),
});
Registered in: plugins/app/src/plugin.ts
The app plugin now provides the @backstage/ui Header implementation, which will be used instead of the default plain HTML version.
Component Hierarchy
┌─────────────────────────────────────────────────────────────┐
│ @backstage/frontend-plugin-api │
│ │
│ PageWrapper (swappable component) │
│ ├─ Default: Plain HTML implementation │
│ └─ Interface: PageWrapperProps │
└─────────────────────────────────────────────────────────────┘
▲
│ overrides
│
┌─────────────────────────────────────────────────────────────┐
│ @backstage/plugin-app │
│ │
│ PageWrapper extension │
│ └─ Implementation: @backstage/ui Header │
└─────────────────────────────────────────────────────────────┘
Default Implementation Details
The default PageWrapper implementation provides:
Header Section
- Title display with basic typography
- Tab navigation with links
- Simple border and spacing
Styling
- Uses inline styles (no external CSS dependencies)
- Minimal but functional design
- Ensures the component is usable without any UI library
Example Output (Default)
<div data-component="page-wrapper">
<header style="...">
<div style="...">{title}</div>
<nav style="...">
<a href="/tab1">Tab 1</a>
<a href="/tab2">Tab 2</a>
</nav>
</header>
{children}
</div>
Benefits of This Approach
1. Clean Dependencies
- ✅
@backstage/frontend-plugin-apiremains dependency-free - ✅ No circular dependencies
- ✅ Clear separation of concerns
2. Flexibility
- Apps can use the default HTML implementation
- Apps can use the
@backstage/uiimplementation (via plugin-app) - Apps can provide completely custom implementations
3. Testability
- Default implementation can be tested without UI library dependencies
- Easy to test in isolation
- No need to mock UI components
4. Progressive Enhancement
- Works out of the box with plain HTML
- Enhanced with better UI when
@backstage/plugin-appis installed - Graceful degradation if UI library is not available
How to Override PageWrapper
Apps can provide their own implementation:
import { SwappableComponentBlueprint } from '@backstage/plugin-app-react';
import { PageWrapper as SwappablePageWrapper } from '@backstage/frontend-plugin-api';
import { MyCustomHeader } from './components';
const CustomPageWrapper = SwappableComponentBlueprint.make({
name: 'custom-page-wrapper',
params: define =>
define({
component: SwappablePageWrapper,
loader: () => props =>
<MyCustomHeader {...props}>{props.children}</MyCustomHeader>,
}),
});
// Add to app plugin extensions
Files Modified
Created
packages/frontend-plugin-api/src/components/PageWrapper.tsx- Swappable component
Modified
packages/frontend-plugin-api/src/components/index.ts- Export PageWrapperpackages/frontend-plugin-api/src/blueprints/PageBlueprint.tsx- Use PageWrapper instead of Headerplugins/app/src/extensions/components.tsx- Provide @backstage/ui implementationplugins/app/src/extensions/index.ts- Export PageWrapper extensionplugins/app/src/plugin.ts- Register PageWrapper extension
API Surface
PageTab Interface
export interface PageTab {
id: string;
label: string;
href: string;
matchStrategy?: 'prefix' | 'exact';
}
PageWrapperProps Interface
export interface PageWrapperProps {
title?: string;
tabs?: PageTab[];
children?: ReactNode;
}
PageWrapper Component
export const PageWrapper: {
(props: PageWrapperProps): JSX.Element | null;
ref: SwappableComponentRef<PageWrapperProps>;
};
Testing Strategy
Unit Tests
- Test default PageWrapper implementation
- Test PageWrapper with various prop combinations
- Test tab rendering
- Test children rendering
Integration Tests
- Test PageBlueprint using PageWrapper
- Test swapping PageWrapper implementation
- Test @backstage/ui Header integration via plugin-app
Visual Tests
- Compare default vs @backstage/ui implementations
- Verify styling consistency
- Test responsive behavior
Migration Impact
For Core Backstage
- ✅ No breaking changes
- ✅ Existing apps automatically get @backstage/ui Header via plugin-app
- ✅ New architecture is more maintainable
For Plugin Authors
- ✅ No changes required
- ✅ PageBlueprint API remains the same
- ✅ SubPageBlueprint API remains the same
For App Developers
- ✅ Can continue using default setup
- ✅ Can customize PageWrapper if desired
- ✅ Can disable @backstage/ui implementation if needed
Future Enhancements
Potential Additions
- Breadcrumbs Support: Add breadcrumbs to PageWrapperProps
- Actions Support: Add header actions to PageWrapperProps
- Themes: Support for theme-aware default styling
- Layouts: Different layout modes (full-width, centered, etc.)
API Evolution
The swappable component pattern makes it easy to evolve the API:
- Add new optional props without breaking changes
- Provide richer default implementations over time
- Support multiple variants (e.g., PageWrapperCompact, PageWrapperExpanded)
Comparison to Previous Implementation
Before
PageBlueprint
└─> Directly imports @backstage/ui Header ❌
└─> Creates dependency violation
After
PageBlueprint
└─> Uses PageWrapper (swappable) ✅
├─> Default: Plain HTML
└─> App Plugin: @backstage/ui Header
Conclusion
This refactor successfully:
- ✅ Removes the invalid dependency from
@backstage/frontend-plugin-apito@backstage/ui - ✅ Provides a functional default implementation using plain HTML
- ✅ Maintains the ability to use the rich
@backstage/uiHeader via plugin-app - ✅ Follows established Backstage patterns for swappable components
- ✅ Enables future customization and extensibility
- ✅ Passes all type checks
The architecture is now cleaner, more flexible, and follows Backstage best practices.