diff --git a/.changeset/promote-plugin-wrapper-api.md b/.changeset/promote-plugin-wrapper-api.md new file mode 100644 index 0000000000..b9c6af8589 --- /dev/null +++ b/.changeset/promote-plugin-wrapper-api.md @@ -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. diff --git a/.changeset/promote-plugin-wrapper-app.md b/.changeset/promote-plugin-wrapper-app.md new file mode 100644 index 0000000000..e76d1247b0 --- /dev/null +++ b/.changeset/promote-plugin-wrapper-app.md @@ -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. diff --git a/packages/frontend-plugin-api/report-alpha.api.md b/packages/frontend-plugin-api/report-alpha.api.md index 77a377bb8e..76678107ef 100644 --- a/packages/frontend-plugin-api/report-alpha.api.md +++ b/packages/frontend-plugin-api/report-alpha.api.md @@ -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; -// @alpha +// @public export const PluginWrapperBlueprint: ExtensionBlueprint<{ kind: 'plugin-wrapper'; - params: (params: { - loader: () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>; + params: (params: { + loader: () => Promise>; }) => ExtensionBlueprintParams<{ - loader: () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>; + loader: () => Promise; }>; output: ExtensionDataRef< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, + () => Promise, 'core.plugin-wrapper.loader', {} >; @@ -53,16 +44,21 @@ export const PluginWrapperBlueprint: ExtensionBlueprint<{ configInput: {}; dataRefs: { wrapper: ConfigurableExtensionDataRef< - () => Promise<{ - component: ComponentType<{ - children: ReactNode; - }>; - }>, + () => Promise, 'core.plugin-wrapper.loader', {} >; }; }>; +// @public +export type PluginWrapperDefinition = { + useWrapperValue?: () => TValue; + component: ComponentType<{ + children: ReactNode; + value: TValue; + }>; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 4624e68dda..902f6520dc 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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; + +// @public +export const PluginWrapperBlueprint: ExtensionBlueprint_2<{ + kind: 'plugin-wrapper'; + params: (params: { + loader: () => Promise>; + }) => ExtensionBlueprintParams_2<{ + loader: () => Promise; + }>; + output: ExtensionDataRef_2< + () => Promise, + 'core.plugin-wrapper.loader', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + wrapper: ConfigurableExtensionDataRef_2< + () => Promise, + 'core.plugin-wrapper.loader', + {} + >; + }; +}>; + +// @public +export type PluginWrapperDefinition = { + useWrapperValue?: () => TValue; + component: ComponentType<{ + children: ReactNode; + value: TValue; + }>; +}; + // @public (undocumented) export type PortableSchema = { parse: (input: TInput) => TOutput; diff --git a/packages/frontend-plugin-api/src/alpha.ts b/packages/frontend-plugin-api/src/alpha.ts index 2251bfafb7..dfd7f1e2bf 100644 --- a/packages/frontend-plugin-api/src/alpha.ts +++ b/packages/frontend-plugin-api/src/alpha.ts @@ -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, diff --git a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts index a4b66d6128..8d9b224b1f 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/PluginWrapperApi.ts @@ -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({ - id: 'core.plugin-wrapper.alpha', + id: 'core.plugin-wrapper', }); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 06d96a50a3..582eacf345 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -50,3 +50,4 @@ export * from './StorageApi'; export * from './AnalyticsApi'; export * from './TranslationApi'; export * from './PluginHeaderActionsApi'; +export * from './PluginWrapperApi'; diff --git a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx index f6f673bd93..5119ae1617 100644 --- a/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/PluginWrapperBlueprint.tsx @@ -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 = { + /** + * 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 >().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(params: { + loader: () => Promise>; }) { - return createExtensionBlueprintParams(params); + return createExtensionBlueprintParams( + params as { loader: () => Promise }, + ); }, *factory(params) { yield wrapperDataRef(params.loader); diff --git a/packages/frontend-plugin-api/src/blueprints/index.ts b/packages/frontend-plugin-api/src/blueprints/index.ts index d413776419..a571f6ef73 100644 --- a/packages/frontend-plugin-api/src/blueprints/index.ts +++ b/packages/frontend-plugin-api/src/blueprints/index.ts @@ -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'; diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index 1215c7576f..d05e7c366f 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -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; diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index b1d851dab9..0262c4c1ef 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -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, 'core.plugin-wrapper.loader', {} >, diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx index c723222f2f..bbf9ce6294 100644 --- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.test.tsx @@ -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 ( +
+ Wrapper{label}#{value.count} {children} + +
+ ); + }; +} 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( - <> +
1
@@ -47,7 +77,7 @@ describe('DefaultPluginWrapperApi', () => {
3
- , +
, ); 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( - <> +
1
2
- , +
, ); 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> => ({ + 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( + +
+ 1 +
+
+ 2 +
+
, + ); + + 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> => ({ + 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( + + X + Y + , + ); + + 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> => ({ + component: makeTestIncWrapper('A', () => { + renderCountA += 1; + }), + useWrapperValue: useTestInc, + }), + pluginId: 'plugin-a', + }, + { + loader: async (): Promise> => ({ + 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( + + X + Y + , + ); + + 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); + }); }); diff --git a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx index 7e602ee23f..1c6037a6f3 100644 --- a/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx +++ b/plugins/app/src/apis/PluginWrapperApi/DefaultPluginWrapperApi.tsx @@ -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( + undefined, +); type WrapperInput = { - loader: () => Promise<{ component: ComponentType<{ children: ReactNode }> }>; + loader: () => Promise>; 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): DefaultPluginWrapperApi { const loadersByPlugin = new Map< string, - Array< - () => Promise<{ component: ComponentType<{ children: ReactNode }> }> - > + Array<() => Promise>> >(); for (const wrapper of wrappers) { @@ -68,6 +96,45 @@ export class DefaultPluginWrapperApi implements PluginWrapperApi { continue; } + const WrapperWithState = ({ + loader, + component: WrapperComponent, + useWrapperValue, + children, + }: { + loader: () => Promise; + 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 ( + + {children} + + ); + }; + const ComposedWrapper = (props: { children: ReactNode }) => { const [loadedWrappers, setLoadedWrappers] = useState< Array> | 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 }) => ( + + {children} + + ); + }, + ); + + 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 = {current}; - } + for (const Wrapper of loadedWrappers) { + content = {content}; + } - 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(); + const renderUpdateListeners = new Set<() => void>(); + + let renderElements = new Array(); + + 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, + , + ]; + + 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} + + {props.children} + + + ); + }; + + return RootWrapper; } } diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index e43e4b46e4..2e49127a61 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -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 = {content}; + } + return [ coreExtensionData.reactElement( diff --git a/plugins/app/src/extensions/PluginWrapperApi.ts b/plugins/app/src/extensions/PluginWrapperApi.ts index e402f094f1..836f563fee 100644 --- a/plugins/app/src/extensions/PluginWrapperApi.ts +++ b/plugins/app/src/extensions/PluginWrapperApi.ts @@ -17,8 +17,6 @@ import { PluginWrapperBlueprint, pluginWrapperApiRef, -} from '@backstage/frontend-plugin-api/alpha'; -import { createExtensionInput, ApiBlueprint, } from '@backstage/frontend-plugin-api';