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:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Promoted `PluginWrapperApi`, `pluginWrapperApiRef`, `PluginWrapperBlueprint`, and the new `PluginWrapperDefinition` type from `@alpha` to `@public`. These are now available from the main package entry point rather than only through `/alpha`.
|
||||
|
||||
The `PluginWrapperApi` type now has a required `getRootWrapper()` method that returns a root wrapper component. The `pluginWrapperApiRef` ID changed from `core.plugin-wrapper.alpha` to `core.plugin-wrapper`.
|
||||
|
||||
The `PluginWrapperBlueprint` now accepts `PluginWrapperDefinition` as the loader return type, which supports an optional `useWrapperValue` hook that allows sharing state between wrapper instances.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-app': patch
|
||||
---
|
||||
|
||||
Updated the default `PluginWrapperApi` implementation to support the new `useWrapperValue` hook and root wrapper. The root wrapper is now rendered in the app root to manage shared hook state across plugin wrapper instances.
|
||||
@@ -11,8 +11,11 @@ import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// @alpha
|
||||
// @public
|
||||
export type PluginWrapperApi = {
|
||||
getRootWrapper(): ComponentType<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
getPluginWrapper(pluginId: string):
|
||||
| ComponentType<{
|
||||
children: ReactNode;
|
||||
@@ -20,31 +23,19 @@ export type PluginWrapperApi = {
|
||||
| undefined;
|
||||
};
|
||||
|
||||
// @alpha
|
||||
// @public
|
||||
export const pluginWrapperApiRef: ApiRef<PluginWrapperApi>;
|
||||
|
||||
// @alpha
|
||||
// @public
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint<{
|
||||
kind: 'plugin-wrapper';
|
||||
params: (params: {
|
||||
loader: () => Promise<{
|
||||
component: ComponentType<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
}>;
|
||||
params: <TValue = never>(params: {
|
||||
loader: () => Promise<PluginWrapperDefinition<TValue>>;
|
||||
}) => ExtensionBlueprintParams<{
|
||||
loader: () => Promise<{
|
||||
component: ComponentType<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
}>;
|
||||
loader: () => Promise<PluginWrapperDefinition>;
|
||||
}>;
|
||||
output: ExtensionDataRef<
|
||||
() => Promise<{
|
||||
component: ComponentType<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
}>,
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
>;
|
||||
@@ -53,16 +44,21 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{
|
||||
configInput: {};
|
||||
dataRefs: {
|
||||
wrapper: ConfigurableExtensionDataRef<
|
||||
() => Promise<{
|
||||
component: ComponentType<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
}>,
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
useWrapperValue?: () => TValue;
|
||||
component: ComponentType<{
|
||||
children: ReactNode;
|
||||
value: TValue;
|
||||
}>;
|
||||
};
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
```
|
||||
|
||||
@@ -1880,6 +1880,55 @@ export interface PluginOptions<
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type PluginWrapperApi = {
|
||||
getRootWrapper(): ComponentType<{
|
||||
children: ReactNode;
|
||||
}>;
|
||||
getPluginWrapper(pluginId: string):
|
||||
| ComponentType<{
|
||||
children: ReactNode;
|
||||
}>
|
||||
| undefined;
|
||||
};
|
||||
|
||||
// @public
|
||||
export const pluginWrapperApiRef: ApiRef_2<PluginWrapperApi>;
|
||||
|
||||
// @public
|
||||
export const PluginWrapperBlueprint: ExtensionBlueprint_2<{
|
||||
kind: 'plugin-wrapper';
|
||||
params: <TValue = never>(params: {
|
||||
loader: () => Promise<PluginWrapperDefinition<TValue>>;
|
||||
}) => ExtensionBlueprintParams_2<{
|
||||
loader: () => Promise<PluginWrapperDefinition>;
|
||||
}>;
|
||||
output: ExtensionDataRef_2<
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
>;
|
||||
inputs: {};
|
||||
config: {};
|
||||
configInput: {};
|
||||
dataRefs: {
|
||||
wrapper: ConfigurableExtensionDataRef_2<
|
||||
() => Promise<PluginWrapperDefinition>,
|
||||
'core.plugin-wrapper.loader',
|
||||
{}
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
|
||||
// @public
|
||||
export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
useWrapperValue?: () => TValue;
|
||||
component: ComponentType<{
|
||||
children: ReactNode;
|
||||
value: TValue;
|
||||
}>;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type PortableSchema<TOutput, TInput = TOutput> = {
|
||||
parse: (input: TInput) => TOutput;
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { PluginWrapperBlueprint } from './blueprints/PluginWrapperBlueprint';
|
||||
// These exports are now available from the main entry point and are
|
||||
// re-exported here only for backwards compatibility.
|
||||
export {
|
||||
PluginWrapperBlueprint,
|
||||
type PluginWrapperDefinition,
|
||||
} from './blueprints/PluginWrapperBlueprint';
|
||||
export {
|
||||
type PluginWrapperApi,
|
||||
pluginWrapperApiRef,
|
||||
|
||||
@@ -15,21 +15,23 @@
|
||||
*/
|
||||
|
||||
import { ComponentType, ReactNode } from 'react';
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { createApiRef } from '../system';
|
||||
|
||||
/**
|
||||
* The Plugin Wrapper API is used to wrap plugin extensions with providers,
|
||||
* plugins should generally use `ExtensionBoundary` instead.
|
||||
* The Plugin Wrapper API allows plugins to wrap their extensions with
|
||||
* providers. This API is only intended for internal use by the Backstage
|
||||
* frontend system. To provide contexts to plugin components, use
|
||||
* `ExtensionBoundary` instead.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This API is primarily intended for internal use by the Backstage frontend
|
||||
* system, but can be used for advanced use-cases. If you do override it, be
|
||||
* sure to include the default implementation as well.
|
||||
*
|
||||
* @alpha
|
||||
* @public
|
||||
*/
|
||||
export type PluginWrapperApi = {
|
||||
/**
|
||||
* Returns the root wrapper that manages the global plugin state across
|
||||
* plugin wrapper instances.
|
||||
*/
|
||||
getRootWrapper(): ComponentType<{ children: ReactNode }>;
|
||||
|
||||
/**
|
||||
* Returns a wrapper component for a specific plugin, or undefined if no
|
||||
* wrappers exist. Do not use this API directly, instead use
|
||||
@@ -43,8 +45,8 @@ export type PluginWrapperApi = {
|
||||
/**
|
||||
* The API reference of {@link PluginWrapperApi}.
|
||||
*
|
||||
* @alpha
|
||||
* @public
|
||||
*/
|
||||
export const pluginWrapperApiRef = createApiRef<PluginWrapperApi>({
|
||||
id: 'core.plugin-wrapper.alpha',
|
||||
id: 'core.plugin-wrapper',
|
||||
});
|
||||
|
||||
@@ -50,3 +50,4 @@ export * from './StorageApi';
|
||||
export * from './AnalyticsApi';
|
||||
export * from './TranslationApi';
|
||||
export * from './PluginHeaderActionsApi';
|
||||
export * from './PluginWrapperApi';
|
||||
|
||||
@@ -21,14 +21,42 @@ import {
|
||||
createExtensionDataRef,
|
||||
} from '../wiring';
|
||||
|
||||
/**
|
||||
* Defines the structure of a plugin wrapper, optionally including a shared
|
||||
* hook value.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* When `useWrapperValue` is provided, the hook is called in a single location
|
||||
* in the app and the resulting value is forwarded as the `value` prop to the
|
||||
* component. The hook obeys the rules of React hooks and is not called until a
|
||||
* component from the plugin is rendered.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PluginWrapperDefinition<TValue = unknown | never> = {
|
||||
/**
|
||||
* Creates a shared value that is forwarded as the `value` prop to the
|
||||
* component.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This function obeys the rules of React hooks and is only invoked in a
|
||||
* single location in the app. Note that the hook will not be called until a
|
||||
* component from the plugin is rendered.
|
||||
*/
|
||||
useWrapperValue?: () => TValue;
|
||||
component: ComponentType<{ children: ReactNode; value: TValue }>;
|
||||
};
|
||||
|
||||
const wrapperDataRef = createExtensionDataRef<
|
||||
() => Promise<{ component: ComponentType<{ children: ReactNode }> }>
|
||||
() => Promise<PluginWrapperDefinition>
|
||||
>().with({ id: 'core.plugin-wrapper.loader' });
|
||||
|
||||
/**
|
||||
* Creates extensions that wrap plugin extensions with providers.
|
||||
*
|
||||
* @alpha
|
||||
* @public
|
||||
*/
|
||||
export const PluginWrapperBlueprint = createExtensionBlueprint({
|
||||
kind: 'plugin-wrapper',
|
||||
@@ -37,12 +65,12 @@ export const PluginWrapperBlueprint = createExtensionBlueprint({
|
||||
dataRefs: {
|
||||
wrapper: wrapperDataRef,
|
||||
},
|
||||
defineParams(params: {
|
||||
loader: () => Promise<{
|
||||
component: ComponentType<{ children: ReactNode }>;
|
||||
}>;
|
||||
defineParams<TValue = never>(params: {
|
||||
loader: () => Promise<PluginWrapperDefinition<TValue>>;
|
||||
}) {
|
||||
return createExtensionBlueprintParams(params);
|
||||
return createExtensionBlueprintParams(
|
||||
params as { loader: () => Promise<PluginWrapperDefinition> },
|
||||
);
|
||||
},
|
||||
*factory(params) {
|
||||
yield wrapperDataRef(params.loader);
|
||||
|
||||
@@ -24,3 +24,7 @@ export { NavItemBlueprint } from './NavItemBlueprint';
|
||||
export { PageBlueprint } from './PageBlueprint';
|
||||
export { SubPageBlueprint } from './SubPageBlueprint';
|
||||
export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
|
||||
export {
|
||||
PluginWrapperBlueprint,
|
||||
type PluginWrapperDefinition,
|
||||
} from './PluginWrapperBlueprint';
|
||||
|
||||
@@ -144,6 +144,10 @@ describe('ExtensionBoundary', () => {
|
||||
};
|
||||
|
||||
const pluginWrapperApi: PluginWrapperApi = {
|
||||
getRootWrapper:
|
||||
() =>
|
||||
({ children }: { children: ReactNode }) =>
|
||||
<>{children}</>,
|
||||
getPluginWrapper: jest.fn((pluginId: string) => {
|
||||
if (pluginId === 'app') {
|
||||
return WrapperComponent;
|
||||
@@ -180,6 +184,10 @@ describe('ExtensionBoundary', () => {
|
||||
};
|
||||
|
||||
const pluginWrapperApi: PluginWrapperApi = {
|
||||
getRootWrapper:
|
||||
() =>
|
||||
({ children }: { children: ReactNode }) =>
|
||||
<>{children}</>,
|
||||
getPluginWrapper: jest.fn((pluginId: string) => {
|
||||
if (pluginId === 'app') {
|
||||
return ThrowingWrapper;
|
||||
|
||||
@@ -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