diff --git a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx index a3a686a8d2..a5b7d31141 100644 --- a/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx +++ b/packages/core-compat-api/src/compatWrapper/ForwardsCompatProvider.tsx @@ -69,26 +69,19 @@ class CompatComponentsApi implements SwappableComponentsApi { this.#ErrorBoundaryFallback = ErrorBoundaryFallback; } - getComponentLoader< + getComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( ref: SwappableComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined { + ): (props: TInnerComponentProps) => JSX.Element | null { switch (ref.id) { case Progress.ref.id: - return (() => this.#Progress) as () => ( - props: object, - ) => JSX.Element | null; + return this.#Progress as (props: object) => JSX.Element | null; case NotFoundErrorPage.ref.id: - return (() => this.#NotFoundErrorPage) as () => ( - props: object, - ) => JSX.Element | null; + return this.#NotFoundErrorPage as (props: object) => JSX.Element | null; case ErrorDisplay.ref.id: - return (() => this.#ErrorBoundaryFallback) as () => ( + return this.#ErrorBoundaryFallback as ( props: object, ) => JSX.Element | null; default: diff --git a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx index 8304150303..fe9dab9910 100644 --- a/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx +++ b/packages/core-compat-api/src/compatWrapper/compatWrapper.test.tsx @@ -114,7 +114,7 @@ describe('ForwardsCompatProvider', () => { {Object.entries(defaultComponentRefs) .map( ([name, ref]) => - `${name}=${Boolean(components.getComponentLoader(ref))}`, + `${name}=${Boolean(components.getComponent(ref))}`, ) .join(', ')} {'\n'} diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx index 6afe3c2e19..2a91525601 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.test.tsx @@ -31,9 +31,7 @@ describe('DefaultComponentsApi', () => { }, ]); - const ComponentA = api.getComponentLoader( - testRefA, - )?.() as () => JSX.Element; + const ComponentA = api.getComponent(testRefA); render(); @@ -49,13 +47,9 @@ describe('DefaultComponentsApi', () => { }, ]); - const ComponentB2 = api.getComponentLoader( - testRefB2, - )?.() as () => JSX.Element; + const ComponentB2 = api.getComponent(testRefB2); - const ComponentB1 = api.getComponentLoader( - testRefB1, - )?.() as () => JSX.Element; + const ComponentB1 = api.getComponent(testRefB1); expect(ComponentB1).toBe(ComponentB2); diff --git a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx similarity index 55% rename from packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts rename to packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx index 885b7bdddb..dbead31f35 100644 --- a/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.ts +++ b/packages/frontend-app-api/src/apis/implementations/SwappableComponentsApi/DefaultSwappableComponentsApi.tsx @@ -19,6 +19,9 @@ import { SwappableComponentsApi, SwappableComponentBlueprint, } from '@backstage/frontend-plugin-api'; +import { OpaqueSwappableComponentRef } from '@internal/frontend'; + +import { lazy } from 'react'; /** * Implementation for the {@linkComponentApi} @@ -26,18 +29,24 @@ import { * @internal */ export class DefaultSwappableComponentsApi implements SwappableComponentsApi { - #components: Map< - string, - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>) - | undefined - >; + #components: Map JSX.Element | null) | undefined>; static fromComponents( components: Array, ) { return new DefaultSwappableComponentsApi( - new Map(components.map(entry => [entry.ref.id, entry.loader])), + new Map( + components.map(entry => { + return [ + entry.ref.id, + entry.loader + ? lazy(async () => ({ + default: await entry.loader!(), + })) + : undefined, + ]; + }), + ), ); } @@ -45,13 +54,21 @@ export class DefaultSwappableComponentsApi implements SwappableComponentsApi { this.#components = components; } - getComponentLoader( + getComponent( ref: SwappableComponentRef, - ): - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>) - | undefined { - const impl = this.#components.get(ref.id); - return impl; + ): (props: object) => JSX.Element | null { + const OverrideComponent = this.#components.get(ref.id); + const { defaultComponent: DefaultComponent, transformProps } = + OpaqueSwappableComponentRef.toInternal(ref); + + return (props: object) => { + const innerProps = transformProps?.(props) ?? props; + + if (OverrideComponent) { + return ; + } + + return ; + }; } } diff --git a/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts index 7fe27920a4..2a5da3fa73 100644 --- a/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts +++ b/packages/frontend-internal/src/wiring/InternalSwappableComponentRef.ts @@ -22,9 +22,7 @@ export const OpaqueSwappableComponentRef = OpaqueType.create<{ versions: { readonly version: 'v1'; readonly transformProps?: (props: object) => object; - readonly loader?: - | (() => (props: object) => JSX.Element | null) - | (() => Promise<(props: object) => JSX.Element | null>); + readonly defaultComponent: (props: object) => JSX.Element | null; }; }>({ versions: ['v1'], diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index e4815fb98b..d60ce91cb7 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -375,7 +375,7 @@ export interface ConfigurableExtensionDataRef< } // @public (undocumented) -export type CoreErrorBoundaryFallbackProps = { +export type CoreErrorDisplayProps = { plugin?: FrontendPlugin; error: Error; resetError: () => void; @@ -871,13 +871,10 @@ export { ErrorApiErrorContext }; export { errorApiRef }; // @public (undocumented) -export const ErrorBoundary: (( - props: CoreErrorBoundaryFallbackProps, +export const ErrorDisplay: (( + props: CoreErrorDisplayProps, ) => JSX.Element | null) & { - ref: SwappableComponentRef< - CoreErrorBoundaryFallbackProps, - CoreErrorBoundaryFallbackProps - >; + ref: SwappableComponentRef; }; // @public (undocumented) @@ -1899,15 +1896,12 @@ export type SwappableComponentRef< // @public export interface SwappableComponentsApi { // (undocumented) - getComponentLoader< + getComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( ref: SwappableComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined; + ): (props: TInnerComponentProps) => JSX.Element | null; } // @public diff --git a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts index 29a9584a91..ed2b20ce80 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/SwappableComponentsApi.ts @@ -23,15 +23,12 @@ import { createApiRef } from '@backstage/core-plugin-api'; * @public */ export interface SwappableComponentsApi { - getComponentLoader< + getComponent< TInnerComponentProps extends {}, TExternalComponentProps extends {} = TInnerComponentProps, >( ref: SwappableComponentRef, - ): - | (() => (props: TInnerComponentProps) => JSX.Element | null) - | (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>) - | undefined; + ): (props: TInnerComponentProps) => JSX.Element | null; } /** diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx index 8fae754799..fae4bf7ebf 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.test.tsx @@ -112,26 +112,20 @@ describe('SwappableComponentBlueprint', () => { await waitFor(() => expect(screen.getByText('test!')).toBeInTheDocument()); }); - it('should allow overriding a component ref with the blueprint', async () => { + it('should render a component ref with an async loader implementation and prop transform', async () => { const TestComponent = createSwappableComponent({ id: 'test.component', - loader: () => (props: { hello: string }) =>
{props.hello}
, - }); - - const extension = SwappableComponentBlueprint.make({ - params: define => - define({ - component: TestComponent, - loader: () => props =>
Override {props.hello}
, - }), + loader: async () => (props: { hello: string }) => +
{props.hello}
, + transformProps: ({ hello }) => ({ hello: `tr ${hello}` }), }); renderInTestApp(
, { extensions: [ - extension, PageBlueprint.make({ params: define => define({ + // todo(blam): there's a bug that this path cannot be `/`? path: '/test', loader: async () => , }), @@ -141,7 +135,7 @@ describe('SwappableComponentBlueprint', () => { }); await waitFor(() => - expect(screen.getByText('Override test!')).toBeInTheDocument(), + expect(screen.getByText('tr test!')).toBeInTheDocument(), ); }); }); diff --git a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts index 60807f96c5..931d15f3ab 100644 --- a/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts +++ b/packages/frontend-plugin-api/src/blueprints/SwappableComponentBlueprint.ts @@ -34,7 +34,7 @@ export const componentDataRef = createExtensionDataRef<{ */ export const SwappableComponentBlueprint = createExtensionBlueprint({ kind: 'component', - attachTo: { id: 'api:app/components', input: 'components' }, + attachTo: { id: 'api:app/swappable-components', input: 'components' }, output: [componentDataRef], dataRefs: { component: componentDataRef, diff --git a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx similarity index 91% rename from packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts rename to packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx index f6cc3345cf..6684dafce6 100644 --- a/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.ts +++ b/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx @@ -41,4 +41,6 @@ export const NotFoundErrorPage = */ export const ErrorDisplay = createSwappableComponent({ id: 'core.components.errorDisplay', + loader: () => props => +
{props.error.message}
, }); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 823d07240b..270168b88b 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -26,7 +26,7 @@ import { ErrorBoundary } from './ErrorBoundary'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; import { AppNode } from '../apis'; -import { Progress } from '@backstage/core-components'; +import { Progress } from '@backstage/frontend-plugin-api'; import { coreExtensionData } from '../wiring'; import { AppNodeProvider } from './AppNodeProvider'; diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx index b7871eac3c..2a73a4da42 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.test.tsx @@ -66,7 +66,7 @@ describe('createSwappableComponent', () => { }); describe('sync', () => { - it('should create a component from a ref for sync component', () => { + it('should create a component from a ref for sync component', async () => { const Component = createSwappableComponent({ id: 'random', loader: () => (props: { name: string }) => { @@ -79,20 +79,21 @@ describe('createSwappableComponent', () => { render(); - expect(screen.getByTestId('test')).toHaveTextContent('test'); + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'test', + ); }); - it('should render a fallback when theres no default implementation provided', () => { + it('should render a fallback when theres no default implementation provided', async () => { const Component = createSwappableComponent({ id: 'random', }); render(); - - expect(screen.getByTestId('random')).toBeInTheDocument(); + await expect(screen.findByTestId('random')).resolves.toBeInTheDocument(); }); - it('should map props from external to internal', () => { + it('should map props from external to internal', async () => { const Component = createSwappableComponent({ id: 'random', transformProps: (props: { name: string }) => ({ @@ -108,7 +109,9 @@ describe('createSwappableComponent', () => { render(); - expect(screen.getByTestId('test')).toHaveTextContent('TEST'); + await expect(screen.findByTestId('test')).resolves.toHaveTextContent( + 'TEST', + ); }); }); diff --git a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx index 1218dbc8d2..7df8c2e0c3 100644 --- a/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx +++ b/packages/frontend-plugin-api/src/components/createSwappableComponent.tsx @@ -16,7 +16,7 @@ import { OpaqueSwappableComponentRef } from '@internal/frontend'; import { swappableComponentsApiRef, useApi } from '../apis'; -import { lazy, Suspense } from 'react'; +import { lazy } from 'react'; /** @public */ export type SwappableComponentRef< @@ -53,48 +53,6 @@ const useComponentRefApi = () => { } }; -function makeComponentFromRef< - InternalComponentProps extends {}, - ExternalComponentProps extends {}, ->({ - ref, -}: { - ref: SwappableComponentRef; -}): (props: ExternalComponentProps) => JSX.Element { - const internalRef = OpaqueSwappableComponentRef.toInternal(ref); - const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( -
- ); - - const ComponentRefImpl = (props: ExternalComponentProps) => { - const api = useComponentRefApi(); - const ComponentOrPromise = - api?.getComponentLoader(ref)?.() ?? - internalRef.loader?.() ?? - FallbackComponent; - - const innerProps = internalRef.transformProps?.(props) ?? props; - - if ('then' in ComponentOrPromise) { - const DefaultImplementation = lazy(() => - ComponentOrPromise.then(c => { - return { default: c }; - }), - ); - - return ( - - - - ); - } - - return ; - }; - - return ComponentRefImpl; -} - /** * Creates a SwappableComponent that can be used to render the component, optionally overriden by the app. * @@ -111,6 +69,10 @@ export function createSwappableComponent< ): ((props: TExternalComponentProps) => JSX.Element | null) & { ref: SwappableComponentRef; } { + const FallbackComponent = (p: JSX.IntrinsicAttributes) => ( +
+ ); + const ref = OpaqueSwappableComponentRef.createInstance('v1', { id: options.id, TProps: null as unknown as TInnerComponentProps, @@ -118,16 +80,31 @@ export function createSwappableComponent< toString() { return `SwappableComponentRef{id=${options.id}}`; }, - loader: - options.loader as (typeof OpaqueSwappableComponentRef.TInternal)['loader'], + defaultComponent: lazy(async () => { + const Component = (await options.loader?.()) ?? FallbackComponent; + return { default: Component }; + }) as (typeof OpaqueSwappableComponentRef.TInternal)['defaultComponent'], transformProps: options.transformProps as (typeof OpaqueSwappableComponentRef.TInternal)['transformProps'], }); - const component = makeComponentFromRef({ ref }); - Object.assign(component, { ref }); + const ComponentRefImpl = (props: TExternalComponentProps) => { + const api = useComponentRefApi(); - return component as { + if (!api) { + const internalRef = OpaqueSwappableComponentRef.toInternal(ref); + const Component = internalRef.defaultComponent; + const innerProps = internalRef.transformProps?.(props) ?? props; + return ; + } + + const Component = api.getComponent(ref); + return ; + }; + + Object.assign(ComponentRefImpl, { ref }); + + return ComponentRefImpl as { ref: SwappableComponentRef; } & ((props: object) => JSX.Element | null); }