diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx index 3b6c8cddaa085b..5c753777eae9b7 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.test.tsx @@ -212,6 +212,43 @@ describe('react embeddable renderer', () => { ) ); }); + + it('catches error when thrown in deserialize', async () => { + const buildEmbeddable = jest.fn(); + const errorInInitializeFactory: ReactEmbeddableFactory<{ name: string; bork: string }> = { + ...testEmbeddableFactory, + type: 'errorInDeserialize', + buildEmbeddable, + deserializeState: (state) => { + throw new Error('error in deserialize'); + }, + }; + registerReactEmbeddableFactory('errorInDeserialize', () => + Promise.resolve(errorInInitializeFactory) + ); + setupPresentationPanelServices(); + + const onApiAvailable = jest.fn(); + const embeddable = render( + ({ + getSerializedStateForChild: () => ({ + rawState: {}, + }), + })} + /> + ); + + await waitFor(() => expect(embeddable.getByTestId('errorMessageMarkdown')).toBeInTheDocument()); + expect(onApiAvailable).not.toBeCalled(); + expect(buildEmbeddable).not.toBeCalled(); + expect(embeddable.getByTestId('errorMessageMarkdown')).toHaveTextContent( + 'error in deserialize' + ); + }); }); describe('reactEmbeddable phase events', () => { diff --git a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx index a10420a33f67fe..8d8a632caec1fa 100644 --- a/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx +++ b/src/plugins/embeddable/public/react_embeddable_system/react_embeddable_renderer.tsx @@ -6,7 +6,11 @@ * Side Public License, v 1. */ -import { HasSerializedChildState, SerializedPanelState } from '@kbn/presentation-containers'; +import { + apiIsPresentationContainer, + HasSerializedChildState, + SerializedPanelState, +} from '@kbn/presentation-containers'; import { PresentationPanel, PresentationPanelProps } from '@kbn/presentation-panel-plugin/public'; import { apiPublishesDataLoading, @@ -20,9 +24,9 @@ import { v4 as generateId } from 'uuid'; import { getReactEmbeddableFactory } from './react_embeddable_registry'; import { initializeReactEmbeddableState } from './react_embeddable_state'; import { + BuildReactEmbeddableApiRegistration, DefaultEmbeddableApi, SetReactEmbeddableApiRegistration, - BuildReactEmbeddableApiRegistration, } from './types'; const ON_STATE_CHANGE_DEBOUNCE = 100; @@ -94,12 +98,6 @@ export const ReactEmbeddableRenderer = < const factory = await getReactEmbeddableFactory(type); const subscriptions = new Subscription(); - const { initialState, startStateDiffing } = await initializeReactEmbeddableState< - SerializedState, - Api, - RuntimeState - >(uuid, factory, parentApi); - const setApi = ( apiRegistration: SetReactEmbeddableApiRegistration ) => { @@ -114,75 +112,105 @@ export const ReactEmbeddableRenderer = < return fullApi; }; - const buildApi = ( - apiRegistration: BuildReactEmbeddableApiRegistration, - comparators: StateComparators - ) => { - if (onAnyStateChange) { - /** - * To avoid unnecessary re-renders, only subscribe to the comparator publishing subjects if - * an `onAnyStateChange` callback is provided - */ - const comparatorDefinitions: Array< - ComparatorDefinition - > = Object.values(comparators); - subscriptions.add( - combineLatest(comparatorDefinitions.map((comparator) => comparator[0])) - .pipe( - skip(1), - debounceTime(ON_STATE_CHANGE_DEBOUNCE), - switchMap(() => { - const isAsync = - apiRegistration.serializeState.prototype?.name === 'AsyncFunction'; - return isAsync - ? (apiRegistration.serializeState() as Promise< - SerializedPanelState - >) - : Promise.resolve(apiRegistration.serializeState()); + const buildEmbeddable = async () => { + const { initialState, startStateDiffing } = await initializeReactEmbeddableState< + SerializedState, + Api, + RuntimeState + >(uuid, factory, parentApi); + + const buildApi = ( + apiRegistration: BuildReactEmbeddableApiRegistration, + comparators: StateComparators + ) => { + if (onAnyStateChange) { + /** + * To avoid unnecessary re-renders, only subscribe to the comparator publishing subjects if + * an `onAnyStateChange` callback is provided + */ + const comparatorDefinitions: Array< + ComparatorDefinition + > = Object.values(comparators); + subscriptions.add( + combineLatest(comparatorDefinitions.map((comparator) => comparator[0])) + .pipe( + skip(1), + debounceTime(ON_STATE_CHANGE_DEBOUNCE), + switchMap(() => { + const isAsync = + apiRegistration.serializeState.prototype?.name === 'AsyncFunction'; + return isAsync + ? (apiRegistration.serializeState() as Promise< + SerializedPanelState + >) + : Promise.resolve(apiRegistration.serializeState()); + }) + ) + .subscribe((serializedState) => { + onAnyStateChange(serializedState); }) - ) - .subscribe((serializedState) => { - onAnyStateChange(serializedState); - }) + ); + } + + const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } = + startStateDiffing(comparators); + + const fullApi = setApi({ + ...apiRegistration, + unsavedChanges, + resetUnsavedChanges, + snapshotRuntimeState, + } as unknown as SetReactEmbeddableApiRegistration); + + cleanupFunction.current = () => cleanup(); + return fullApi; + }; + + const { api, Component } = await factory.buildEmbeddable( + initialState, + buildApi, + uuid, + parentApi, + setApi + ); + + if (apiPublishesDataLoading(api)) { + subscriptions.add( + api.dataLoading.subscribe((loading) => reportPhaseChange(Boolean(loading))) ); + } else { + reportPhaseChange(false); } - const { unsavedChanges, resetUnsavedChanges, cleanup, snapshotRuntimeState } = - startStateDiffing(comparators); - - const fullApi = setApi({ - ...apiRegistration, - unsavedChanges, - resetUnsavedChanges, - snapshotRuntimeState, - } as unknown as SetReactEmbeddableApiRegistration); - - cleanupFunction.current = () => cleanup(); - return fullApi; + return { api, Component }; }; - const { api, Component } = await factory.buildEmbeddable( - initialState, - buildApi, - uuid, - parentApi, - setApi - ); - - if (apiPublishesDataLoading(api)) { - subscriptions.add( - api.dataLoading.subscribe((loading) => reportPhaseChange(Boolean(loading))) - ); - } else { - reportPhaseChange(false); + try { + const { api, Component } = await buildEmbeddable(); + return React.forwardRef((_, ref) => { + // expose the api into the imperative handle + useImperativeHandle(ref, () => api, []); + + return ; + }); + } catch (e) { + /** + * critical error encountered when trying to build the api / embeddable; + * since no API is available, create a dummy API that allows the panel to be deleted + * */ + const errorApi = { + uuid, + blockingError: new BehaviorSubject(e), + } as unknown as Api; + if (apiIsPresentationContainer(parentApi)) { + errorApi.parentApi = parentApi; + } + return React.forwardRef((_, ref) => { + // expose the dummy error api into the imperative handle + useImperativeHandle(ref, () => errorApi, []); + return null; + }); } - - return React.forwardRef((_, ref) => { - // expose the api into the imperative handle - useImperativeHandle(ref, () => api, []); - - return ; - }); })(); }, /**