frontend-plugin-api: promote PluginWrapper API to stable with useWrapperValue hook

Promote PluginWrapperApi, pluginWrapperApiRef, PluginWrapperBlueprint, and
the new PluginWrapperDefinition type from @alpha to @public. Add getRootWrapper()
method to PluginWrapperApi and integrate the useWrapperValue hook pattern from
the rugvip/plugin-wrapper branch, allowing plugin wrappers to share stateful
values via a hook that runs once in the root wrapper and is distributed to all
wrapper instances via useSyncExternalStore.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-16 11:19:57 +01:00
parent 722ce6f54c
commit 9508514116
15 changed files with 533 additions and 73 deletions
+2 -5
View File
@@ -19,6 +19,7 @@ import { JSX as JSX_2 } from 'react';
import { NavContentComponent } from '@backstage/plugin-app-react';
import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SignInPageProps } from '@backstage/plugin-app-react';
@@ -620,11 +621,7 @@ const appPlugin: OverridableFrontendPlugin<
inputs: {
wrappers: ExtensionInput<
ConfigurableExtensionDataRef<
() => Promise<{
component: ComponentType<{
children: ReactNode;
}>;
}>,
() => Promise<PluginWrapperDefinition>,
'core.plugin-wrapper.loader',
{}
>,
@@ -16,6 +16,34 @@
import { render, screen } from '@testing-library/react';
import { DefaultPluginWrapperApi } from './DefaultPluginWrapperApi';
import { PluginWrapperDefinition } from '@backstage/frontend-plugin-api';
import { ReactNode, useState } from 'react';
import userEvent from '@testing-library/user-event';
type TestInc = { count: number; increment: () => void };
function useTestInc(): TestInc {
const [value, setValue] = useState(0);
return {
count: value,
increment: () => setValue(val => val + 1),
};
}
function makeTestIncWrapper(
label: string = '',
renderSpy?: () => void,
): (props: { children: ReactNode; value: TestInc }) => JSX.Element {
return ({ children, value }: { children: ReactNode; value: TestInc }) => {
renderSpy?.();
return (
<div>
Wrapper{label}#{value.count} {children}
<button onClick={value.increment}>Increment{label}</button>
</div>
);
};
}
describe('DefaultPluginWrapperApi', () => {
it('should wrap multiple components with a single wrapper', async () => {
@@ -36,8 +64,10 @@ describe('DefaultPluginWrapperApi', () => {
expect(Wrapper2).toBeDefined();
expect(Wrapper3).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<>
<RootWrapper>
<div>
<Wrapper1>1</Wrapper1>
</div>
@@ -47,7 +77,7 @@ describe('DefaultPluginWrapperApi', () => {
<div>
<Wrapper3>3</Wrapper3>
</div>
</>,
</RootWrapper>,
);
await expect(screen.findByText('Wrapper(1)')).resolves.toBeInTheDocument();
@@ -77,15 +107,17 @@ describe('DefaultPluginWrapperApi', () => {
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<>
<RootWrapper>
<div>
<Wrapper1>1</Wrapper1>
</div>
<div>
<Wrapper2>2</Wrapper2>
</div>
</>,
</RootWrapper>,
);
await expect(
@@ -95,4 +127,153 @@ describe('DefaultPluginWrapperApi', () => {
screen.findByText('WrapperB(WrapperA(2))'),
).resolves.toBeInTheDocument();
});
it('should share a single value across multiple wrappers', async () => {
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async (): Promise<PluginWrapperDefinition<string>> => ({
component: ({ children, value }) => (
<>
Wrapper({children}:{value})
</>
),
useWrapperValue: () => 'foo',
}),
pluginId: 'plugin-1',
},
]);
const Wrapper1 = api.getPluginWrapper('plugin-1')!;
const Wrapper2 = api.getPluginWrapper('plugin-1')!;
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<RootWrapper>
<div>
<Wrapper1>1</Wrapper1>
</div>
<div>
<Wrapper2>2</Wrapper2>
</div>
</RootWrapper>,
);
await expect(
screen.findByText('Wrapper(1:foo)'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Wrapper(2:foo)'),
).resolves.toBeInTheDocument();
});
it('should share a single stateful value across multiple wrappers', async () => {
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async (): Promise<PluginWrapperDefinition<TestInc>> => ({
component: makeTestIncWrapper(),
useWrapperValue: useTestInc,
}),
pluginId: 'plugin-1',
},
]);
const Wrapper1 = api.getPluginWrapper('plugin-1')!;
const Wrapper2 = api.getPluginWrapper('plugin-1')!;
expect(Wrapper1).toBeDefined();
expect(Wrapper2).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<RootWrapper>
<Wrapper1>X</Wrapper1>
<Wrapper2>Y</Wrapper2>
</RootWrapper>,
);
await expect(screen.findByText('Wrapper#0 X')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper#0 Y')).resolves.toBeInTheDocument();
await userEvent.click(screen.getAllByText('Increment')[0]);
await expect(screen.findByText('Wrapper#1 X')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper#1 Y')).resolves.toBeInTheDocument();
await userEvent.click(screen.getAllByText('Increment')[1]);
await expect(screen.findByText('Wrapper#2 X')).resolves.toBeInTheDocument();
await expect(screen.findByText('Wrapper#2 Y')).resolves.toBeInTheDocument();
});
it('should not rerender adjacent hooks on update', async () => {
let renderCountA = 0;
let renderCountB = 0;
const api = DefaultPluginWrapperApi.fromWrappers([
{
loader: async (): Promise<PluginWrapperDefinition<TestInc>> => ({
component: makeTestIncWrapper('A', () => {
renderCountA += 1;
}),
useWrapperValue: useTestInc,
}),
pluginId: 'plugin-a',
},
{
loader: async (): Promise<PluginWrapperDefinition<TestInc>> => ({
component: makeTestIncWrapper('B', () => {
renderCountB += 1;
}),
useWrapperValue: useTestInc,
}),
pluginId: 'plugin-b',
},
]);
const WrapperA = api.getPluginWrapper('plugin-a')!;
const WrapperB = api.getPluginWrapper('plugin-b')!;
expect(WrapperA).toBeDefined();
expect(WrapperB).toBeDefined();
const RootWrapper = api.getRootWrapper();
render(
<RootWrapper>
<WrapperA>X</WrapperA>
<WrapperB>Y</WrapperB>
</RootWrapper>,
);
await expect(
screen.findByText('WrapperA#0 X'),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('WrapperB#0 Y'),
).resolves.toBeInTheDocument();
expect(renderCountA).toBe(1);
expect(renderCountB).toBe(1);
await userEvent.click(screen.getByText('IncrementA'));
expect(screen.getByText('WrapperA#1 X')).toBeInTheDocument();
expect(screen.getByText('WrapperB#0 Y')).toBeInTheDocument();
expect(renderCountA).toBe(2);
expect(renderCountB).toBe(1);
await userEvent.click(screen.getByText('IncrementB'));
expect(screen.getByText('WrapperA#1 X')).toBeInTheDocument();
expect(screen.getByText('WrapperB#1 Y')).toBeInTheDocument();
expect(renderCountA).toBe(2);
expect(renderCountB).toBe(2);
});
});
@@ -14,11 +14,36 @@
* limitations under the License.
*/
import { PluginWrapperApi } from '@backstage/frontend-plugin-api/alpha';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import {
PluginWrapperApi,
PluginWrapperDefinition,
} from '@backstage/frontend-plugin-api';
import {
ComponentType,
ReactNode,
createContext,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react';
interface HookStore {
getSnapshot: () => { value: unknown } | undefined;
subscribe: (listener: () => void) => () => void;
}
interface HookRegistryContextValue {
registerHook: (key: any, hook: () => unknown) => HookStore;
}
const HookRegistryContext = createContext<HookRegistryContextValue | undefined>(
undefined,
);
type WrapperInput = {
loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>;
loader: () => Promise<PluginWrapperDefinition<any>>;
pluginId: string;
};
@@ -29,12 +54,17 @@ type WrapperInput = {
*/
export class DefaultPluginWrapperApi implements PluginWrapperApi {
constructor(
private readonly rootWrapper: ComponentType<{ children: ReactNode }>,
private readonly pluginWrappers: Map<
string,
ComponentType<{ children: ReactNode }>
>,
) {}
getRootWrapper(): ComponentType<{ children: ReactNode }> {
return this.rootWrapper;
}
getPluginWrapper(
pluginId: string,
): ComponentType<{ children: ReactNode }> | undefined {
@@ -44,9 +74,7 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
static fromWrappers(wrappers: Array<WrapperInput>): DefaultPluginWrapperApi {
const loadersByPlugin = new Map<
string,
Array<
() => Promise<{ component: ComponentType<{ children: ReactNode }> }>
>
Array<() => Promise<PluginWrapperDefinition<any>>>
>();
for (const wrapper of wrappers) {
@@ -68,6 +96,45 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
continue;
}
const WrapperWithState = ({
loader,
component: WrapperComponent,
useWrapperValue,
children,
}: {
loader: () => Promise<PluginWrapperDefinition>;
component: ComponentType<{
children: ReactNode;
value: unknown;
}>;
useWrapperValue: () => unknown;
children: ReactNode;
}) => {
const hookContext = useContext(HookRegistryContext);
if (!hookContext) {
throw new Error(
'Attempted to render a wrapped plugin component without a root wrapper context',
);
}
const store = useMemo(() => {
return hookContext.registerHook(loader, useWrapperValue);
}, [hookContext, loader, useWrapperValue]);
const container = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
);
if (!container) {
return null;
}
return (
<WrapperComponent value={container.value}>
{children}
</WrapperComponent>
);
};
const ComposedWrapper = (props: { children: ReactNode }) => {
const [loadedWrappers, setLoadedWrappers] = useState<
Array<ComponentType<{ children: ReactNode }>> | undefined
@@ -77,7 +144,27 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
useEffect(() => {
Promise.all(loaders.map(loader => loader()))
.then(results => {
setLoadedWrappers(results.map(r => r.component));
const normalizedResults = results.map(
({ component, useWrapperValue }, index) => {
const loader = loaders[index];
if (!useWrapperValue) {
return component as ComponentType<{ children: ReactNode }>;
}
return ({ children }: { children: ReactNode }) => (
<WrapperWithState
loader={loader}
component={component}
useWrapperValue={useWrapperValue}
>
{children}
</WrapperWithState>
);
},
);
setLoadedWrappers(normalizedResults);
})
.catch(setError);
}, []);
@@ -86,24 +173,107 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi {
throw error;
}
return useMemo(() => {
if (!loadedWrappers) {
return null;
}
if (!loadedWrappers) {
return null;
}
let current = props.children;
let content = props.children;
for (const Wrapper of loadedWrappers) {
current = <Wrapper>{current}</Wrapper>;
}
for (const Wrapper of loadedWrappers) {
content = <Wrapper>{content}</Wrapper>;
}
return current;
}, [loadedWrappers, props.children]);
return <>{content}</>;
};
composedWrappers.set(pluginId, ComposedWrapper);
}
return new DefaultPluginWrapperApi(composedWrappers);
return new DefaultPluginWrapperApi(
DefaultPluginWrapperApi.createRootWrapper(),
composedWrappers,
);
}
/**
* Creates the root wrapper component that is responsible for rendering and
* forwarding the values of the common `useWrapperValue` hooks.
*/
static createRootWrapper() {
const renderers = new Map<any, HookStore>();
const renderUpdateListeners = new Set<() => void>();
let renderElements = new Array<JSX.Element>();
const createHookRenderer = (hook: () => unknown): HookStore => {
const listeners = new Set<() => void>();
let container: { value: unknown } | undefined = undefined;
const HookRenderer = () => {
container = { value: hook() };
useEffect(() => {
for (const listener of listeners) {
listener();
}
});
return null;
};
renderElements = [
...renderElements,
<HookRenderer key={`hook-renderer-${renderElements.length + 1}`} />,
];
return {
getSnapshot: () => container,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
};
const registerHook = (key: any, hook: () => unknown) => {
let renderer = renderers.get(key);
if (!renderer) {
renderer = createHookRenderer(hook);
renderers.set(key, renderer);
queueMicrotask(() => {
for (const listener of renderUpdateListeners) {
listener();
}
});
}
return renderer;
};
const subscribeToRenderUpdates = (listener: () => void) => {
renderUpdateListeners.add(listener);
return () => renderUpdateListeners.delete(listener);
};
const getRenderElements = () => renderElements;
const RootWrapper = (props: { children: ReactNode }) => {
const elements = useSyncExternalStore(
subscribeToRenderUpdates,
getRenderElements,
);
return (
<>
<>{elements}</>
<HookRegistryContext.Provider
value={{
registerHook,
}}
>
{props.children}
</HookRegistryContext.Provider>
</>
);
};
return RootWrapper;
}
}
+7
View File
@@ -29,6 +29,7 @@ import {
createExtension,
createExtensionInput,
routeResolutionApiRef,
pluginWrapperApiRef,
useAnalytics,
} from '@backstage/frontend-plugin-api';
import {
@@ -115,6 +116,12 @@ export const AppRoot = createExtension({
}
}
const pluginWrapperApi = apis.get(pluginWrapperApiRef);
const RootWrapper = pluginWrapperApi?.getRootWrapper();
if (RootWrapper) {
content = <RootWrapper>{content}</RootWrapper>;
}
return [
coreExtensionData.reactElement(
<BUIProvider useAnalytics={useAnalytics}>
@@ -17,8 +17,6 @@
import {
PluginWrapperBlueprint,
pluginWrapperApiRef,
} from '@backstage/frontend-plugin-api/alpha';
import {
createExtensionInput,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';