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
@@ -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;
+6 -1
View File
@@ -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;