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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user