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
+9
View File
@@ -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.
+5
View File
@@ -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;
+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;
+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';