chore: enforce loader format for component refs

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-08-04 16:23:35 +02:00
parent 976e2f5999
commit aab7bc21cd
7 changed files with 49 additions and 137 deletions
@@ -20,15 +20,14 @@ describe('ComponentImplementationBlueprint', () => {
it('should allow defining a component override for sync component ref', () => {
const componentRef = createComponentRef({
id: 'test.component',
mode: 'sync',
defaultComponent: (props: { hello: string }) => <div>{props.hello}</div>,
loader: () => (props: { hello: string }) => <div>{props.hello}</div>,
});
const extension = ComponentImplementationBlueprint.make({
params: define =>
define({
ref: componentRef,
component: props => {
loader: () => props => {
// @ts-expect-error
const t: number = props.hello;
@@ -22,10 +22,9 @@ import {
export const componentDataRef = createExtensionDataRef<{
ref: ComponentRef;
component:
| ((props: {}) => JSX.Element | null)
loader:
| (() => (props: {}) => JSX.Element | null)
| (() => Promise<(props: {}) => JSX.Element | null>);
type: 'sync' | 'async';
}>().with({ id: 'core.component.component' });
export const ComponentImplementationBlueprint = createExtensionBlueprint({
@@ -37,16 +36,10 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({
},
defineParams<Ref extends ComponentRef<any>>(params: {
ref: Ref;
component: Ref extends ComponentRef<
infer IInnerComponentProps,
any,
infer IMode
>
? IMode extends 'sync'
? (props: IInnerComponentProps) => JSX.Element
: IMode extends 'async'
? () => Promise<(props: IInnerComponentProps) => JSX.Element>
: never
loader: Ref extends ComponentRef<infer IInnerComponentProps, any>
?
| (() => (props: IInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: IInnerComponentProps) => JSX.Element | null>)
: never;
}) {
return createExtensionBlueprintParams(params);
@@ -54,8 +47,7 @@ export const ComponentImplementationBlueprint = createExtensionBlueprint({
*factory(params) {
yield componentDataRef({
ref: params.ref,
component: params.component,
type: params.ref.mode,
loader: params.loader,
});
},
});
@@ -18,7 +18,7 @@ import { createComponentRef } from './createComponentRef';
describe('createComponentRef', () => {
it('can be created and read', () => {
const ref = createComponentRef({ id: 'foo', mode: 'sync' });
const ref = createComponentRef({ id: 'foo' });
expect(ref.id).toBe('foo');
expect(String(ref)).toBe('ComponentRef{id=foo}');
});
@@ -28,14 +28,15 @@ describe('createComponentRef', () => {
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
defaultComponent: ({ foo }) => <Test key={foo} />,
loader:
() =>
({ foo }) =>
<Test key={foo} />,
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'async',
defaultComponent:
loader:
async () =>
({ foo }) =>
<Test key={foo} />,
@@ -43,21 +44,6 @@ describe('createComponentRef', () => {
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
// @ts-expect-error - this should be an error as mode is sync
defaultComponent: async ({ foo }) => <Test key={foo} />,
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'async',
// @ts-expect-error - this should be an error as mode is async
defaultComponent: ({ bar }) => <Test key={bar} />,
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
});
expect(Test).toBeDefined();
@@ -66,23 +52,15 @@ describe('createComponentRef', () => {
it('should allow transformings props', () => {
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
transformProps: props => ({ foo: props.bar }),
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
// @ts-expect-error - this should be an error as foo is not a string
transformProps: props => ({ foo: 1 }),
});
createComponentRef<{ foo: string }, { bar: string }>({
id: 'foo',
mode: 'sync',
transformProps: props => ({ foo: props.bar }),
});
expect(true).toBe(true);
});
});
@@ -20,12 +20,10 @@ import { OpaqueComponentRef } from '@internal/frontend';
export type ComponentRef<
TInnerComponentProps extends {} = {},
TExternalComponentProps extends {} = TInnerComponentProps,
TMode extends 'sync' | 'async' = 'sync' | 'async',
> = {
id: string;
TProps: TInnerComponentProps;
TExternalProps: TExternalComponentProps;
TMode: TMode;
$$type: '@backstage/ComponentRef';
};
@@ -34,66 +32,27 @@ export type ComponentRefOptions<
TExternalComponentProps extends {} = TInnerComponentProps,
> = {
id: string;
componentAsync: TMode extends 'async'
? () => Promise<(props: TInnerComponentProps) => JSX.Element | null>
: TMode extends 'sync'
? (props: TInnerComponentProps) => JSX.Element | null
: never;
loader?:
| (() => (props: TInnerComponentProps) => JSX.Element | null)
| (() => Promise<(props: TInnerComponentProps) => JSX.Element | null>);
transformProps?: (props: TExternalComponentProps) => TInnerComponentProps;
};
/**
* Creates a new component ref that is synchronous.
* @public
*/
export function createComponentRef<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
options: ComponentRefOptions<
TInnerComponentProps,
TExternalComponentProps,
'sync'
>,
): ComponentRef<TInnerComponentProps, TExternalComponentProps, 'sync'>;
/**
* Creates a new component ref that is asynchronous.
* @public
*/
export function createComponentRef<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
>(
options: ComponentRefOptions<
TInnerComponentProps,
TExternalComponentProps,
'async'
>,
): ComponentRef<TInnerComponentProps, TExternalComponentProps, 'async'>;
export function createComponentRef<
TInnerComponentProps extends {},
TExternalComponentProps extends {} = TInnerComponentProps,
TMode extends 'sync' | 'async' = 'sync' | 'async',
>(
options: ComponentRefOptions<
TInnerComponentProps,
TExternalComponentProps,
TMode
>,
options: ComponentRefOptions<TInnerComponentProps, TExternalComponentProps>,
): ComponentRef<TInnerComponentProps, TExternalComponentProps> {
return OpaqueComponentRef.createInstance('v1', {
id: options.id,
TProps: null as unknown as TInnerComponentProps,
TExternalProps: null as unknown as TExternalComponentProps,
TMode: null as unknown as TMode,
toString() {
return `ComponentRef{id=${options.id}}`;
},
options: {
mode: options.mode,
defaultComponent: options.defaultComponent,
loader: options.loader,
transformProps: options.transformProps,
} as (typeof OpaqueComponentRef.TInternal)['options'],
});
@@ -22,15 +22,16 @@ describe('makeComponentFromRef', () => {
it('should create a component from a ref for sync component', () => {
const ref = createComponentRef({
id: 'random',
mode: 'sync',
defaultComponent: (props: { name: string }) => {
loader: () => (props: { name: string }) => {
return <div data-testid="test">{props.name}</div>;
},
transformProps: (props: { id: string }) => ({
name: props.id,
}),
});
const Component = makeComponentFromRef({ ref });
render(<Component name="test" />);
render(<Component id="test" />);
expect(screen.getByTestId('test')).toHaveTextContent('test');
});
@@ -38,7 +39,6 @@ describe('makeComponentFromRef', () => {
it('should render a fallback when theres no default implementation provided', () => {
const ref = createComponentRef({
id: 'random',
mode: 'sync',
});
const Component = makeComponentFromRef({ ref });
@@ -51,11 +51,10 @@ describe('makeComponentFromRef', () => {
it('should map props from external to internal', () => {
const ref = createComponentRef({
id: 'random',
mode: 'sync',
transformProps: (props: { name: string }) => ({
uppercase: props.name.toUpperCase(),
}),
defaultComponent: props => {
loader: () => props => {
// @ts-expect-error as uppercase is types as a string
const test: number = props.uppercase;
@@ -75,8 +74,7 @@ describe('makeComponentFromRef', () => {
it('should create a component from a ref for async component', async () => {
const ref = createComponentRef({
id: 'random',
mode: 'async',
defaultComponent: async () => (props: { name: string }) => {
loader: async () => (props: { name: string }) => {
return <div data-testid="test">{props.name}</div>;
},
});
@@ -91,7 +89,6 @@ describe('makeComponentFromRef', () => {
it('should render a fallback when theres no default implementation provided', async () => {
const ref = createComponentRef({
id: 'random',
mode: 'async',
});
const Component = makeComponentFromRef({ ref });
@@ -104,11 +101,10 @@ describe('makeComponentFromRef', () => {
it('should map props from external to internal', async () => {
const ref = createComponentRef({
id: 'random',
mode: 'async',
transformProps: (props: { name: string }) => ({
uppercase: props.name.toUpperCase(),
}),
defaultComponent: async () => props => {
loader: async () => props => {
// @ts-expect-error as uppercase is types as a string
const test: number = props.uppercase;
@@ -33,29 +33,24 @@ export function makeComponentFromRef<
const ComponentRefImpl = (props: ExternalComponentProps) => {
const innerProps = options.transformProps?.(props) ?? props;
if (options.mode === 'sync') {
const DefaultImplementation =
options.defaultComponent ?? FallbackComponent;
const ComponentOrPromise = options.loader?.() ?? FallbackComponent;
return <DefaultImplementation {...innerProps} />;
if ('then' in ComponentOrPromise) {
const DefaultImplementation = lazy(() =>
ComponentOrPromise.then(c => {
return { default: c };
}),
);
return (
// todo: is this necessary? can we remove this?
<Suspense>
<DefaultImplementation {...innerProps} />
</Suspense>
);
}
const DefaultImplementation = lazy(
() =>
options.defaultComponent?.().then(c => {
return { default: c };
}) ??
Promise.resolve({
default: FallbackComponent,
}),
);
return (
// todo: is this necessary? can we remove this?
<Suspense>
<DefaultImplementation {...innerProps} />
</Suspense>
);
return <ComponentOrPromise {...innerProps} />;
};
return ComponentRefImpl;