diff --git a/e2e/test-plugins/grafana-extensionstest-app/README.md b/e2e/test-plugins/grafana-extensionstest-app/README.md index e92e2b3f064..b952f16df66 100644 --- a/e2e/test-plugins/grafana-extensionstest-app/README.md +++ b/e2e/test-plugins/grafana-extensionstest-app/README.md @@ -32,4 +32,4 @@ Note that this plugin extends the `@grafana/plugin-configs` configs which is why ## Run Playwright tests -- `yarn playwright --project extensions-test-app` +- `yarn playwright test --project extensions-test-app` diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index a9de5a23ea7..570d2d97756 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -22,6 +22,7 @@ import { RouteDescriptor } from './core/navigation/types'; import { ThemeProvider } from './core/utils/ConfigProvider'; import { LiveConnectionWarning } from './features/live/LiveConnectionWarning'; import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext'; +import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper'; interface AppWrapperProps { @@ -104,7 +105,7 @@ export class AppWrapper extends Component { - +
{config.featureToggles.appSidecar ? ( diff --git a/public/app/app.ts b/public/app/app.ts index dad577603d8..556952c0775 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -85,12 +85,12 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { DatasourceSrv } from './features/plugins/datasource_srv'; import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions'; -import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; -import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types'; +import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; import { usePluginLinks } from './features/plugins/extensions/usePluginLinks'; +import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { preloadPlugins } from './features/plugins/pluginPreloader'; import { QueryRunner } from './features/query/state/QueryRunner'; @@ -127,7 +127,6 @@ if (process.env.NODE_ENV === 'development') { export class GrafanaApp { context!: GrafanaContextType; - pluginExtensionsRegistries!: PluginExtensionRegistries; async init() { try { @@ -217,22 +216,16 @@ export class GrafanaApp { setDataSourceSrv(dataSourceSrv); initWindowRuntime(); - // Initialize plugin extensions - this.pluginExtensionsRegistries = setupPluginExtensionRegistries(); - if (contextSrv.user.orgRole !== '') { - // The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init. - // TODO: remove the following exception once the issue mentioned above is fixed. - const awaitedAppPluginIds = ['cloud-home-app']; - const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id)); - const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id)); - - preloadPlugins(appPlugins, this.pluginExtensionsRegistries); - await preloadPlugins(awaitedAppPlugins, this.pluginExtensionsRegistries, 'frontend_awaited_plugins_preload'); + const appPluginsToAwait = getAppPluginsToAwait(); + const appPluginsToPreload = getAppPluginsToPreload(); + + preloadPlugins(appPluginsToPreload); + await preloadPlugins(appPluginsToAwait); } - setPluginExtensionGetter(createPluginExtensionsGetter(this.pluginExtensionsRegistries)); - setPluginExtensionsHook(createUsePluginExtensions(this.pluginExtensionsRegistries)); + setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries)); + setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries)); setPluginLinksHook(usePluginLinks); setPluginComponentHook(usePluginComponent); setPluginComponentsHook(usePluginComponents); diff --git a/public/app/features/plugins/components/AppRootPage.test.tsx b/public/app/features/plugins/components/AppRootPage.test.tsx index 805a780be3c..d9b3bd9d77d 100644 --- a/public/app/features/plugins/components/AppRootPage.test.tsx +++ b/public/app/features/plugins/components/AppRootPage.test.tsx @@ -11,7 +11,9 @@ import { contextSrv } from 'app/core/services/context_srv'; import { Echo } from 'app/core/services/echo/Echo'; import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext'; -import { setupPluginExtensionRegistries } from '../extensions/registry/setup'; +import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry'; import { getPluginSettings } from '../pluginSettings'; import { importAppPlugin } from '../plugin_loader'; @@ -86,7 +88,11 @@ function renderUnderRouter(page = '') { appPluginNavItem.parentItem = appsSection; - const registries = setupPluginExtensionRegistries(); + const registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + }; const pagePath = page ? `/${page}` : ''; const route = { path: `/a/:pluginId/*`, diff --git a/public/app/features/plugins/extensions/registry/setup.ts b/public/app/features/plugins/extensions/registry/setup.ts index 05fe0f21b7a..6c2fd1e6a5f 100644 --- a/public/app/features/plugins/extensions/registry/setup.ts +++ b/public/app/features/plugins/extensions/registry/setup.ts @@ -5,17 +5,17 @@ import { AddedLinksRegistry } from './AddedLinksRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './types'; -export function setupPluginExtensionRegistries(): PluginExtensionRegistries { - const pluginExtensionsRegistries = { - addedComponentsRegistry: new AddedComponentsRegistry(), - exposedComponentsRegistry: new ExposedComponentsRegistry(), - addedLinksRegistry: new AddedLinksRegistry(), - }; +export const addedComponentsRegistry = new AddedComponentsRegistry(); +export const exposedComponentsRegistry = new ExposedComponentsRegistry(); +export const addedLinksRegistry = new AddedLinksRegistry(); +export const pluginExtensionRegistries: PluginExtensionRegistries = { + addedComponentsRegistry, + exposedComponentsRegistry, + addedLinksRegistry, +}; - pluginExtensionsRegistries.addedLinksRegistry.register({ - pluginId: 'grafana', - configs: getCoreExtensionConfigurations(), - }); - - return pluginExtensionsRegistries; -} +// Registering core extensions +addedLinksRegistry.register({ + pluginId: 'grafana', + configs: getCoreExtensionConfigurations(), +}); diff --git a/public/app/features/plugins/extensions/useLoadAppPlugins.tsx b/public/app/features/plugins/extensions/useLoadAppPlugins.tsx new file mode 100644 index 00000000000..ce013713906 --- /dev/null +++ b/public/app/features/plugins/extensions/useLoadAppPlugins.tsx @@ -0,0 +1,19 @@ +import { useAsync } from 'react-use'; + +import { preloadPlugins } from '../pluginPreloader'; + +import { getAppPluginConfigs } from './utils'; + +export function useLoadAppPlugins(pluginIds: string[] = []): { isLoading: boolean } { + const { loading: isLoading } = useAsync(async () => { + const appConfigs = getAppPluginConfigs(pluginIds); + + if (!appConfigs.length) { + return; + } + + await preloadPlugins(appConfigs); + }); + + return { isLoading }; +} diff --git a/public/app/features/plugins/extensions/usePluginComponent.test.tsx b/public/app/features/plugins/extensions/usePluginComponent.test.tsx index 007414ab4dc..9491700e217 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.test.tsx @@ -7,11 +7,15 @@ import { config } from '@grafana/runtime'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; -import { setupPluginExtensionRegistries } from './registry/setup'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; import { usePluginComponent } from './usePluginComponent'; import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; +jest.mock('./useLoadAppPlugins'); jest.mock('app/features/plugins/pluginSettings', () => ({ getPluginSettings: jest.fn().mockResolvedValue({ id: 'my-app-plugin', @@ -83,7 +87,12 @@ describe('usePluginComponent()', () => { }; beforeEach(() => { - registries = setupPluginExtensionRegistries(); + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + }; + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); jest.mocked(isGrafanaDevMode).mockReturnValue(false); resetLogMock(log); diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index ba057721f26..5e079beea93 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -7,7 +7,8 @@ import { UsePluginComponentResult } from '@grafana/runtime'; import { useExposedComponentsRegistry } from './ExtensionRegistriesContext'; import * as errors from './errors'; import { log } from './logs/log'; -import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils'; import { isExposedComponentDependencyMissing } from './validators'; // Returns a component exposed by a plugin. @@ -16,11 +17,19 @@ export function usePluginComponent(id: string): UsePl const registry = useExposedComponentsRegistry(); const registryState = useObservable(registry.asObservable()); const pluginContext = usePluginContext(); + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id)); return useMemo(() => { // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. const enableRestrictions = isGrafanaDevMode() && pluginContext; + if (isLoadingAppPlugins) { + return { + isLoading: true, + component: null, + }; + } + if (!registryState?.[id]) { return { isLoading: false, @@ -47,5 +56,5 @@ export function usePluginComponent(id: string): UsePl isLoading: false, component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog), }; - }, [id, pluginContext, registryState]); + }, [id, pluginContext, registryState, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.test.tsx b/public/app/features/plugins/extensions/usePluginComponents.test.tsx index e873e6e81a7..5528525bf47 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.test.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.test.tsx @@ -6,11 +6,15 @@ import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; -import { setupPluginExtensionRegistries } from './registry/setup'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; import { usePluginComponents } from './usePluginComponents'; import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; +jest.mock('./useLoadAppPlugins'); jest.mock('app/features/plugins/pluginSettings', () => ({ getPluginSettings: jest.fn().mockResolvedValue({ id: 'my-app-plugin', @@ -50,8 +54,14 @@ describe('usePluginComponents()', () => { beforeEach(() => { jest.mocked(isGrafanaDevMode).mockReturnValue(false); + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); + resetLogMock(log); - registries = setupPluginExtensionRegistries(); + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + }; jest.mocked(wrapWithPluginContext).mockClear(); diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index 3147a8441d9..e583a69141d 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -10,7 +10,8 @@ import { import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; import * as errors from './errors'; import { log } from './logs/log'; -import { isGrafanaDevMode } from './utils'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; // Returns an array of component extensions for the given extension point @@ -21,6 +22,7 @@ export function usePluginComponents({ const registry = useAddedComponentsRegistry(); const registryState = useObservable(registry.asObservable()); const pluginContext = usePluginContext(); + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); return useMemo(() => { // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. @@ -45,6 +47,13 @@ export function usePluginComponents({ }; } + if (isLoadingAppPlugins) { + return { + isLoading: true, + components: [], + }; + } + for (const registryItem of registryState?.[extensionPointId] ?? []) { const { pluginId } = registryItem; @@ -65,5 +74,5 @@ export function usePluginComponents({ isLoading: false, components, }; - }, [extensionPointId, limitPerPlugin, pluginContext, registryState]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx index 71254272588..203b55391a0 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.test.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.test.tsx @@ -5,8 +5,11 @@ import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; import { createUsePluginExtensions } from './usePluginExtensions'; +jest.mock('./useLoadAppPlugins'); + describe('usePluginExtensions()', () => { let registries: PluginExtensionRegistries; const pluginId = 'myorg-extensions-app'; @@ -18,6 +21,7 @@ describe('usePluginExtensions()', () => { addedLinksRegistry: new AddedLinksRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(), }; + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); }); it('should return an empty array if there are no extensions registered for the extension point', () => { diff --git a/public/app/features/plugins/extensions/usePluginExtensions.tsx b/public/app/features/plugins/extensions/usePluginExtensions.tsx index 5a7050062e2..2dfc57e289e 100644 --- a/public/app/features/plugins/extensions/usePluginExtensions.tsx +++ b/public/app/features/plugins/extensions/usePluginExtensions.tsx @@ -8,7 +8,8 @@ import * as errors from './errors'; import { getPluginExtensions } from './getPluginExtensions'; import { log } from './logs/log'; import { PluginExtensionRegistries } from './registry/types'; -import { isGrafanaDevMode } from './utils'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; +import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils'; import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; export function createUsePluginExtensions(registries: PluginExtensionRegistries) { @@ -20,8 +21,9 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); const addedLinksRegistry = useObservable(observableAddedLinksRegistry); const { extensionPointId, context, limitPerPlugin } = options; + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); - const { extensions } = useMemo(() => { + return useMemo(() => { // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. const enableRestrictions = isGrafanaDevMode() && pluginContext !== null; const pluginId = pluginContext?.meta.id ?? ''; @@ -50,19 +52,35 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries) }; } - return getPluginExtensions({ + if (isLoadingAppPlugins) { + return { + isLoading: true, + extensions: [], + }; + } + + const { extensions } = getPluginExtensions({ extensionPointId, context, limitPerPlugin, addedComponentsRegistry, addedLinksRegistry, }); + + return { extensions, isLoading: false }; + // Doing the deps like this instead of just `option` because users probably aren't going to memoize the // options object so we are checking it's simple value attributes. // The context though still has to be memoized though and not mutated. // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood - }, [addedLinksRegistry, addedComponentsRegistry, extensionPointId, context, limitPerPlugin, pluginContext]); - - return { extensions, isLoading: false }; + }, [ + addedLinksRegistry, + addedComponentsRegistry, + extensionPointId, + context, + limitPerPlugin, + pluginContext, + isLoadingAppPlugins, + ]); }; } diff --git a/public/app/features/plugins/extensions/usePluginLinks.test.tsx b/public/app/features/plugins/extensions/usePluginLinks.test.tsx index c9d6ca2af39..dc9729560d9 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.test.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.test.tsx @@ -6,11 +6,15 @@ import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { log } from './logs/log'; import { resetLogMock } from './logs/testUtils'; -import { setupPluginExtensionRegistries } from './registry/setup'; +import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry'; +import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { PluginExtensionRegistries } from './registry/types'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; import { usePluginLinks } from './usePluginLinks'; import { isGrafanaDevMode } from './utils'; +jest.mock('./useLoadAppPlugins'); jest.mock('app/features/plugins/pluginSettings', () => ({ getPluginSettings: jest.fn().mockResolvedValue({ id: 'my-app-plugin', @@ -48,8 +52,13 @@ describe('usePluginLinks()', () => { const extensionPointId = `${pluginId}/extension-point/v1`; beforeEach(() => { + jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false }); jest.mocked(isGrafanaDevMode).mockReturnValue(false); - registries = setupPluginExtensionRegistries(); + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + }; resetLogMock(log); pluginMeta = { diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index 1a7205e347a..58efcc4e3e6 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -11,8 +11,10 @@ import { import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; import * as errors from './errors'; import { log } from './logs/log'; +import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, + getExtensionPointPluginDependencies, getLinkExtensionOnClick, getLinkExtensionOverrides, getLinkExtensionPathWithTracking, @@ -30,6 +32,7 @@ export function usePluginLinks({ const registry = useAddedLinksRegistry(); const pluginContext = usePluginContext(); const registryState = useObservable(registry.asObservable()); + const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); return useMemo(() => { // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. @@ -56,6 +59,13 @@ export function usePluginLinks({ }; } + if (isLoadingAppPlugins) { + return { + isLoading: true, + links: [], + }; + } + if (!registryState || !registryState[extensionPointId]) { return { isLoading: false, @@ -117,5 +127,5 @@ export function usePluginLinks({ isLoading: false, links: extensions, }; - }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]); + }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/utils.test.tsx b/public/app/features/plugins/extensions/utils.test.tsx index b78afe339ba..606661af2a3 100644 --- a/public/app/features/plugins/extensions/utils.test.tsx +++ b/public/app/features/plugins/extensions/utils.test.tsx @@ -1,7 +1,8 @@ import { render, screen } from '@testing-library/react'; import { type Unsubscribable } from 'rxjs'; -import { dateTime, usePluginContext } from '@grafana/data'; +import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data'; +import { config } from '@grafana/runtime'; import appEvents from 'app/core/app_events'; import { ShowModalReactEvent } from 'app/types/events'; @@ -12,6 +13,10 @@ import { getReadOnlyProxy, createOpenModalFunction, wrapWithPluginContext, + getExtensionPointPluginDependencies, + getExposedComponentPluginDependencies, + getAppPluginConfigs, + getAppPluginIdFromExposedComponentId, } from './utils'; jest.mock('app/features/plugins/pluginSettings', () => ({ @@ -447,4 +452,431 @@ describe('Plugin Extensions / Utils', () => { expect(screen.getByText('Version: 1.0.0')).toBeVisible(); }); }); + + describe('getAppPluginConfigs()', () => { + const originalApps = config.apps; + const genereicAppPluginConfig = { + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + afterEach(() => { + config.apps = originalApps; + }); + + test('should return the app plugin configs based on the provided plugin ids', () => { + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + }; + + expect(getAppPluginConfigs(['myorg-first-app', 'myorg-third-app'])).toEqual([ + config.apps['myorg-first-app'], + config.apps['myorg-third-app'], + ]); + }); + + test('should simply ignore the app plugin ids that do not belong to a config', () => { + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + }; + + expect(getAppPluginConfigs(['myorg-first-app', 'unknown-app-id'])).toEqual([config.apps['myorg-first-app']]); + }); + }); + + describe('getAppPluginIdFromExposedComponentId()', () => { + test('should return the app plugin id from an extension point id', () => { + expect(getAppPluginIdFromExposedComponentId('myorg-extensions-app/component/v1')).toBe('myorg-extensions-app'); + }); + }); + + describe('getExtensionPointPluginDependencies()', () => { + const originalApps = config.apps; + const genereicAppPluginConfig = { + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + afterEach(() => { + config.apps = originalApps; + }); + + test('should return the app plugin ids that register extensions to a link extension point', () => { + const extensionPointId = 'myorg-first-app/link/v1'; + + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + // This plugin is registering a link extension to the extension point + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + extensions: { + addedLinks: [ + { + targets: [extensionPointId], + title: 'Link title', + }, + ], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + }; + + const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); + + expect(appPluginIds).toEqual(['myorg-second-app']); + }); + + test('should return the app plugin ids that register extensions to a component extension point', () => { + const extensionPointId = 'myorg-first-app/component/v1'; + + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + }, + // This plugin is registering a component extension to the extension point + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + extensions: { + addedLinks: [], + addedComponents: [ + { + targets: [extensionPointId], + title: 'Component title', + }, + ], + exposedComponents: [], + extensionPoints: [], + }, + }, + }; + + const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); + + expect(appPluginIds).toEqual(['myorg-third-app']); + }); + + test('should return an empty array if there are no apps that that extend the extension point', () => { + const extensionPointId = 'myorg-first-app/component/v1'; + + // None of the apps are extending the extension point + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + }; + + const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); + + expect(appPluginIds).toEqual([]); + }); + + test('should also return (recursively) the app plugin ids that the apps which extend the extension-point depend on', () => { + const extensionPointId = 'myorg-first-app/component/v1'; + + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + // This plugin is registering a component extension to the extension point. + // It is also depending on the 'myorg-fourth-app' plugin. + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + extensions: { + addedLinks: [], + addedComponents: [ + { + targets: [extensionPointId], + title: 'Component title', + }, + ], + exposedComponents: [], + extensionPoints: [], + }, + dependencies: { + ...genereicAppPluginConfig.dependencies, + extensions: { + exposedComponents: ['myorg-fourth-app/component/v1'], + }, + }, + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + // This plugin exposes a component, but is also depending on the 'myorg-fifth-app'. + 'myorg-fourth-app': { + ...genereicAppPluginConfig, + id: 'myorg-fourth-app', + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [ + { + id: 'myorg-fourth-app/component/v1', + title: 'Exposed component', + }, + ], + extensionPoints: [], + }, + dependencies: { + ...genereicAppPluginConfig.dependencies, + extensions: { + exposedComponents: ['myorg-fifth-app/component/v1'], + }, + }, + }, + 'myorg-fifth-app': { + ...genereicAppPluginConfig, + id: 'myorg-fifth-app', + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [ + { + id: 'myorg-fifth-app/component/v1', + title: 'Exposed component', + }, + ], + extensionPoints: [], + }, + }, + 'myorg-sixth-app': { + ...genereicAppPluginConfig, + id: 'myorg-sixth-app', + }, + }; + + const appPluginIds = getExtensionPointPluginDependencies(extensionPointId); + + expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']); + }); + }); + + describe('getExposedComponentPluginDependencies()', () => { + const originalApps = config.apps; + const genereicAppPluginConfig = { + path: '', + version: '', + preload: false, + angular: { + detected: false, + hideDeprecation: false, + }, + loadingStrategy: PluginLoadingStrategy.fetch, + dependencies: { + grafanaVersion: '8.0.0', + plugins: [], + extensions: { + exposedComponents: [], + }, + }, + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [], + extensionPoints: [], + }, + }; + + afterEach(() => { + config.apps = originalApps; + }); + + test('should only return the app plugin id that exposes the component, if that component does not depend on anything', () => { + const exposedComponentId = 'myorg-second-app/component/v1'; + + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [ + { + id: exposedComponentId, + title: 'Component title', + }, + ], + extensionPoints: [], + }, + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + }; + + const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId); + + expect(appPluginIds).toEqual(['myorg-second-app']); + }); + + test('should also return the list of app plugin ids that the plugin - which exposes the component - is depending on', () => { + const exposedComponentId = 'myorg-second-app/component/v1'; + + config.apps = { + 'myorg-first-app': { + ...genereicAppPluginConfig, + id: 'myorg-first-app', + }, + 'myorg-second-app': { + ...genereicAppPluginConfig, + id: 'myorg-second-app', + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [ + { + id: exposedComponentId, + title: 'Component title', + }, + ], + extensionPoints: [], + }, + dependencies: { + ...genereicAppPluginConfig.dependencies, + extensions: { + exposedComponents: ['myorg-fourth-app/component/v1'], + }, + }, + }, + 'myorg-third-app': { + ...genereicAppPluginConfig, + id: 'myorg-third-app', + }, + 'myorg-fourth-app': { + ...genereicAppPluginConfig, + id: 'myorg-fourth-app', + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [ + { + id: 'myorg-fourth-app/component/v1', + title: 'Component title', + }, + ], + extensionPoints: [], + }, + dependencies: { + ...genereicAppPluginConfig.dependencies, + extensions: { + exposedComponents: ['myorg-fifth-app/component/v1'], + }, + }, + }, + 'myorg-fifth-app': { + ...genereicAppPluginConfig, + id: 'myorg-fifth-app', + extensions: { + addedLinks: [], + addedComponents: [], + exposedComponents: [ + { + id: 'myorg-fifth-app/component/v1', + title: 'Component title', + }, + ], + extensionPoints: [], + }, + }, + }; + + const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId); + + expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']); + }); + }); }); diff --git a/public/app/features/plugins/extensions/utils.tsx b/public/app/features/plugins/extensions/utils.tsx index b4819a3f1b8..bb339ffcf38 100644 --- a/public/app/features/plugins/extensions/utils.tsx +++ b/public/app/features/plugins/extensions/utils.tsx @@ -16,8 +16,9 @@ import { PanelMenuItem, PluginExtensionAddedLinkConfig, urlUtil, + PluginExtensionPoints, } from '@grafana/data'; -import { reportInteraction, config } from '@grafana/runtime'; +import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime'; import { Modal } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; @@ -421,3 +422,75 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string, // Comes from the `app_mode` setting in the Grafana config (defaults to "development") // Can be set with the `GF_DEFAULT_APP_MODE` environment variable export const isGrafanaDevMode = () => config.buildInfo.env === 'development'; + +export const getAppPluginConfigs = (pluginIds: string[] = []) => + Object.values(config.apps).filter((app) => pluginIds.includes(app.id)); + +export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => { + return exposedComponentId.split('/')[0]; +}; + +// Returns a list of app plugin ids that are registering extensions to this extension point. +// (These plugins are necessary to be loaded to use the extension point.) +// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.) +export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => { + return Object.values(config.apps) + .filter( + (app) => + app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) || + app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId)) + ) + .map((app) => app.id) + .reduce((acc: string[], id: string) => { + return [...acc, id, ...getAppPluginDependencies(id)]; + }, []); +}; + +// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component. +// (It is first the plugin that exposes the component, and then the ones that it depends on.) +export const getExposedComponentPluginDependencies = (exposedComponentId: string) => { + const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId); + + return [pluginId].reduce((acc: string[], pluginId: string) => { + return [...acc, pluginId, ...getAppPluginDependencies(pluginId)]; + }, []); +}; + +// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions` +// metadata field. (For example the plugins that expose components that the app depends on.) +// Heads up! This is a recursive function. +export const getAppPluginDependencies = (pluginId: string): string[] => { + if (!config.apps[pluginId]) { + return []; + } + + const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map( + getAppPluginIdFromExposedComponentId + ); + + return pluginIdDependencies.reduce((acc, pluginId) => { + return [...acc, ...getAppPluginDependencies(pluginId)]; + }, pluginIdDependencies); +}; + +// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization. +export const getAppPluginsToAwait = () => { + const pluginIds = [ + // The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init. + 'cloud-home-app', + ]; + + return Object.values(config.apps).filter((app) => pluginIds.includes(app.id)); +}; + +// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization. +export const getAppPluginsToPreload = () => { + // The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading. + const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu); + const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id); + const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id); + + return Object.values(config.apps).filter((app) => { + return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id)); + }); +}; diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index b4d7489f80f..8b7a85759be 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -1,11 +1,9 @@ import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data'; import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; import type { AppPluginConfig } from '@grafana/runtime'; -import { startMeasure, stopMeasure } from 'app/core/utils/metrics'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; -import { PluginExtensionRegistries } from './extensions/registry/types'; -import { importPluginModule } from './plugin_loader'; +import { importAppPlugin } from './plugin_loader'; export type PluginPreloadResult = { pluginId: string; @@ -15,67 +13,28 @@ export type PluginPreloadResult = { addedLinkConfigs?: PluginExtensionAddedLinkConfig[]; }; -export async function preloadPlugins( - apps: AppPluginConfig[] = [], - registries: PluginExtensionRegistries, - eventName = 'frontend_plugins_preload' -) { - startMeasure(eventName); - const promises = apps.filter((config) => config.preload).map((config) => preload(config)); - const preloadedPlugins = await Promise.all(promises); +const preloadedAppPlugins = new Set(); +const isNotYetPreloaded = ({ id }: AppPluginConfig) => !preloadedAppPlugins.has(id); +const markAsPreloaded = (apps: AppPluginConfig[]) => apps.forEach(({ id }) => preloadedAppPlugins.add(id)); - for (const preloadedPlugin of preloadedPlugins) { - if (preloadedPlugin.error) { - console.error(`[Plugins] Skip loading extensions for "${preloadedPlugin.pluginId}" due to an error.`); - continue; - } +export async function preloadPlugins(apps: AppPluginConfig[] = []) { + const appPluginsToPreload = apps.filter(isNotYetPreloaded); - registries.exposedComponentsRegistry.register({ - pluginId: preloadedPlugin.pluginId, - configs: preloadedPlugin.exposedComponentConfigs, - }); - registries.addedComponentsRegistry.register({ - pluginId: preloadedPlugin.pluginId, - configs: preloadedPlugin.addedComponentConfigs || [], - }); - registries.addedLinksRegistry.register({ - pluginId: preloadedPlugin.pluginId, - configs: preloadedPlugin.addedLinkConfigs || [], - }); + if (appPluginsToPreload.length === 0) { + return; } - stopMeasure(eventName); + markAsPreloaded(apps); + + await Promise.all(appPluginsToPreload.map(preload)); } -async function preload(config: AppPluginConfig): Promise { - const { path, version, id: pluginId, loadingStrategy } = config; +async function preload(config: AppPluginConfig) { try { - startMeasure(`frontend_plugin_preload_${pluginId}`); - const { plugin } = await importPluginModule({ - path, - version, - isAngular: config.angular.detected, - pluginId, - loadingStrategy, - moduleHash: config.moduleHash, - }); - const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin; - - // Fetching meta-information for the preloaded app plugin and caching it for later. - // (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.) - getPluginSettings(pluginId); + const meta = await getPluginSettings(config.id); - return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs }; + await importAppPlugin(meta); } catch (error) { - console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); - return { - pluginId, - error, - exposedComponentConfigs: [], - addedComponentConfigs: [], - addedLinkConfigs: [], - }; - } finally { - stopMeasure(`frontend_plugin_preload_${pluginId}`); + console.error(`[Plugins] Failed to preload plugin: ${config.path} (version: ${config.version})`, error); } } diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index cf65adc4811..aa20ae5881f 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -13,6 +13,7 @@ import { DataQuery } from '@grafana/schema'; import { GenericDataSourcePlugin } from '../datasources/types'; import builtInPlugins from './built_in_plugins'; +import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup'; import { getPluginFromCache, registerPluginInCache } from './loader/cache'; // SystemJS has to be imported before the sharedDependenciesMap import { SystemJS } from './loader/systemjs'; @@ -69,6 +70,15 @@ systemJSPrototype.resolve = decorateSystemJSResolve.bind(systemJSPrototype, syst // Any css files loaded via SystemJS have their styles applied onload. systemJSPrototype.onload = decorateSystemJsOnload; +type PluginImportInfo = { + path: string; + pluginId: string; + loadingStrategy: PluginLoadingStrategy; + version?: string; + isAngular?: boolean; + moduleHash?: string; +}; + export async function importPluginModule({ path, pluginId, @@ -76,14 +86,7 @@ export async function importPluginModule({ version, isAngular, moduleHash, -}: { - path: string; - pluginId: string; - loadingStrategy: PluginLoadingStrategy; - version?: string; - isAngular?: boolean; - moduleHash?: string; -}): Promise { +}: PluginImportInfo): Promise { if (version) { registerPluginInCache({ path, version, loadingStrategy }); } @@ -166,21 +169,44 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { - const isAngular = meta.angular?.detected ?? meta.angularDetected; - const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; - return importPluginModule({ +// Only successfully loaded plugins are cached +const importedAppPlugins: Record = {}; + +export async function importAppPlugin(meta: PluginMeta): Promise { + const pluginId = meta.id; + + if (importedAppPlugins[pluginId]) { + return importedAppPlugins[pluginId]; + } + + const pluginExports = await importPluginModule({ path: meta.module, version: meta.info?.version, - isAngular, - loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, + isAngular: meta.angular?.detected ?? meta.angularDetected, + loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch, moduleHash: meta.moduleHash, - }).then((pluginExports) => { - const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin(); - plugin.init(meta); - plugin.meta = meta; - plugin.setComponentsFromLegacyExports(pluginExports); - return plugin; }); + + const { plugin = new AppPlugin() } = pluginExports; + plugin.init(meta); + plugin.meta = meta; + plugin.setComponentsFromLegacyExports(pluginExports); + + exposedComponentsRegistry.register({ + pluginId, + configs: plugin.exposedComponentConfigs || [], + }); + addedComponentsRegistry.register({ + pluginId, + configs: plugin.addedComponentConfigs || [], + }); + addedLinksRegistry.register({ + pluginId, + configs: plugin.addedLinkConfigs || [], + }); + + importedAppPlugins[pluginId] = plugin; + + return plugin; } diff --git a/public/app/features/plugins/tests/plugin_loader.test.ts b/public/app/features/plugins/tests/plugin_loader.test.ts index e81e833966a..d41caa1dbe1 100644 --- a/public/app/features/plugins/tests/plugin_loader.test.ts +++ b/public/app/features/plugins/tests/plugin_loader.test.ts @@ -12,27 +12,25 @@ jest.mock('app/core/core', () => { import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data'; // Loaded after the `unmock` above +import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from '../extensions/registry/setup'; import { SystemJS } from '../loader/systemjs'; import { importAppPlugin } from '../plugin_loader'; -class MyCustomApp extends AppPlugin { - initWasCalled = false; - calledTwice = false; - - init(meta: AppPluginMeta) { - this.initWasCalled = true; - this.calledTwice = this.meta === meta; - } -} +jest.mock('../extensions/registry/setup'); describe('Load App', () => { - const app = new MyCustomApp(); + const app = new AppPlugin(); const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js'; // Hook resolver for tests const originalResolve = SystemJS.constructor.prototype.resolve; SystemJS.constructor.prototype.resolve = (x: unknown) => x; beforeAll(() => { + app.init = jest.fn(); + addedComponentsRegistry.register = jest.fn(); + addedLinksRegistry.register = jest.fn(); + exposedComponentsRegistry.register = jest.fn(); + SystemJS.set(modulePath, { plugin: app }); }); @@ -55,14 +53,17 @@ describe('Load App', () => { const m = await SystemJS.import(modulePath); expect(m.plugin).toBe(app); - const loaded = await importAppPlugin(meta); - expect(loaded).toBe(app); + // Importing the app should initialise the meta + const importedApp = await importAppPlugin(meta); + expect(importedApp).toBe(app); expect(app.meta).toBe(meta); - expect(app.initWasCalled).toBeTruthy(); - expect(app.calledTwice).toBeFalsy(); - const again = await importAppPlugin(meta); - expect(again).toBe(app); - expect(app.calledTwice).toBeTruthy(); + // Importing the same app again doesn't initialise it twice + const importedAppAgain = await importAppPlugin(meta); + expect(importedAppAgain).toBe(app); + expect(app.init).toHaveBeenCalledTimes(1); + expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1); + expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1); + expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1); }); });