chore: some more refactor to get ready for shipping

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-08-07 15:57:34 +02:00
parent 75ea6f14c5
commit 20f1d88971
13 changed files with 94 additions and 125 deletions
+6 -12
View File
@@ -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<CoreErrorDisplayProps, CoreErrorDisplayProps>;
};
// @public (undocumented)
@@ -1899,15 +1896,12 @@ export type SwappableComponentRef<
// @public
export interface SwappableComponentsApi {
// (undocumented)
getComponentLoader<
getComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
):
| (() => (props: TInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>)
| undefined;
): (props: TInnerComponentProps) => JSX.Element | null;
}
// @public
@@ -23,15 +23,12 @@ import { createApiRef } from '@backstage/core-plugin-api';
* @public
*/
export interface SwappableComponentsApi {
getComponentLoader<
getComponent<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>,
):
| (() => (props: TInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>)
| undefined;
): (props: TInnerComponentProps) => JSX.Element | null;
}
/**
@@ -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 }) => <div>{props.hello}</div>,
});
const extension = SwappableComponentBlueprint.make({
params: define =>
define({
component: TestComponent,
loader: () => props => <div>Override {props.hello}</div>,
}),
loader: async () => (props: { hello: string }) =>
<div>{props.hello}</div>,
transformProps: ({ hello }) => ({ hello: `tr ${hello}` }),
});
renderInTestApp(<div />, {
extensions: [
extension,
PageBlueprint.make({
params: define =>
define({
// todo(blam): there's a bug that this path cannot be `/`?
path: '/test',
loader: async () => <TestComponent hello="test!" />,
}),
@@ -141,7 +135,7 @@ describe('SwappableComponentBlueprint', () => {
});
await waitFor(() =>
expect(screen.getByText('Override test!')).toBeInTheDocument(),
expect(screen.getByText('tr test!')).toBeInTheDocument(),
);
});
});
@@ -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,
@@ -41,4 +41,6 @@ export const NotFoundErrorPage =
*/
export const ErrorDisplay = createSwappableComponent<CoreErrorDisplayProps>({
id: 'core.components.errorDisplay',
loader: () => props =>
<div data-testid="core.components.errorDisplay">{props.error.message}</div>,
});
@@ -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';
@@ -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(<Component id="test" />);
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(<Component />);
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(<Component name="test" />);
expect(screen.getByTestId('test')).toHaveTextContent('TEST');
await expect(screen.findByTestId('test')).resolves.toHaveTextContent(
'TEST',
);
});
});
@@ -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<InternalComponentProps, ExternalComponentProps>;
}): (props: ExternalComponentProps) => JSX.Element {
const internalRef = OpaqueSwappableComponentRef.toInternal(ref);
const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
<div data-testid={ref.id} {...p} />
);
const ComponentRefImpl = (props: ExternalComponentProps) => {
const api = useComponentRefApi();
const ComponentOrPromise =
api?.getComponentLoader<any>(ref)?.() ??
internalRef.loader?.() ??
FallbackComponent;
const innerProps = internalRef.transformProps?.(props) ?? props;
if ('then' in ComponentOrPromise) {
const DefaultImplementation = lazy(() =>
ComponentOrPromise.then(c => {
return { default: c };
}),
);
return (
<Suspense>
<DefaultImplementation {...innerProps} />
</Suspense>
);
}
return <ComponentOrPromise {...innerProps} />;
};
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<TInnerComponentProps, TExternalComponentProps>;
} {
const FallbackComponent = (p: JSX.IntrinsicAttributes) => (
<div data-testid={options.id} {...p} />
);
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 <Component {...innerProps} />;
}
const Component = api.getComponent<any>(ref);
return <Component {...props} />;
};
Object.assign(ComponentRefImpl, { ref });
return ComponentRefImpl as {
ref: SwappableComponentRef<TInnerComponentProps, TExternalComponentProps>;
} & ((props: object) => JSX.Element | null);
}