PluginExtensions: Prevent unneccesary re-renders (#113436)

* wip

* wip

* wip

* wip

* wip

* wip

* removed array.from.

* using concat

* added more tests.

* renamed according to feedback.

* Update the getPluginExtensions to use the slice.

* fixed expect statements.

* Reverted the test app from the allow list.

* cleanded up tests.

* added generic tests for base functionality.

* removed unused iport.
This commit is contained in:
Marcus Andersson
2025-11-18 14:12:23 +01:00
committed by GitHub
parent ec74a721ea
commit 083c7f31f7
17 changed files with 1385 additions and 74 deletions
-10
View File
@@ -3154,16 +3154,6 @@
"count": 1
}
},
"public/app/features/plugins/extensions/usePluginComponents.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/plugins/extensions/usePluginFunctions.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/plugins/sandbox/distortions.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -1,5 +1,5 @@
import { isString } from 'lodash';
import { combineLatest, filter, map, Observable } from 'rxjs';
import { combineLatest, map, Observable } from 'rxjs';
import {
PluginExtensionTypes,
@@ -37,16 +37,22 @@ import {
export const getObservablePluginExtensions = (
options: Omit<GetExtensionsOptions, 'addedComponentsRegistry' | 'addedLinksRegistry'>
): Observable<ReturnType<GetExtensions>> => {
const { extensionPointId } = options;
const { addedComponentsRegistry, addedLinksRegistry } = pluginExtensionRegistries;
return combineLatest([
pluginExtensionRegistries.addedComponentsRegistry.asObservable(),
pluginExtensionRegistries.addedLinksRegistry.asObservable(),
addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]),
addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]),
]).pipe(
filter(([components, links]) => Boolean(components) && Boolean(links)), // filter out uninitialized registries
map(([components, links]) =>
getPluginExtensions({
...options,
addedComponentsRegistry: components,
addedLinksRegistry: links,
addedComponentsRegistry: {
[extensionPointId]: components,
},
addedLinksRegistry: {
[extensionPointId]: links,
},
})
)
);
@@ -1,5 +1,5 @@
import React from 'react';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
@@ -537,4 +537,238 @@ describe('AddedComponentsRegistry', () => {
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
});
describe('asObservableSlice', () => {
it('should return the selected slice from the registry', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
description: 'Test description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Test'),
},
],
});
const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(Array.isArray(slice)).toBe(true);
expect(slice?.length).toBe(1);
expect(slice?.[0].title).toBe('Test Component');
});
});
it('should return undefined when the selected key does not exist', async () => {
const registry = new AddedComponentsRegistry();
const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeUndefined();
});
});
it('should only emit when the selected slice changes', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
const subscribeCallback = jest.fn();
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
observable.subscribe(subscribeCallback);
// Initial empty state
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]).toBeUndefined();
// Register first component
registry.register({
pluginId: 'test-plugin-1',
configs: [
{
title: 'Component 1',
description: 'Description 1',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Component 1'),
},
],
});
// Should emit because the slice changed
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1);
// Register another component to the same extension point
registry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Component 2',
description: 'Description 2',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Component 2'),
},
],
});
// Should emit because the slice changed (array reference changed)
expect(subscribeCallback).toHaveBeenCalledTimes(3);
expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2);
// Register a component to a different extension point
registry.register({
pluginId: 'test-plugin-3',
configs: [
{
title: 'Component 3',
description: 'Description 3',
targets: ['grafana/other/point'],
component: () => React.createElement('div', null, 'Component 3'),
},
],
});
// Should NOT emit because the selected slice (for extensionPointId) didn't change
expect(subscribeCallback).toHaveBeenCalledTimes(3);
});
it('should deep freeze the selected slice', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
description: 'Test description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Test'),
},
],
});
const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
// @ts-expect-error - Testing that frozen objects cannot be modified
expect(() => slice.push({})).toThrow();
expect(() => (slice[0].title = 'Modified')).toThrow();
});
});
it('should work with read-only registries', async () => {
const registry = new AddedComponentsRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/alerting/home';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
description: 'Test description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Test'),
},
],
});
const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(Array.isArray(slice)).toBe(true);
expect(slice?.length).toBe(1);
expect(slice?.[0].title).toBe('Test Component');
});
});
it('should emit immediately to new subscribers with the current slice value', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
description: 'Test description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Test'),
},
],
});
// Subscribe after registration
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Should have been called immediately with the current value
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1);
expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Component');
});
it('should not emit when Object.is returns true for the same value', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
const subscribeCallback = jest.fn();
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
observable.subscribe(subscribeCallback);
// Initial state
expect(subscribeCallback).toHaveBeenCalledTimes(1);
// Register a component
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
description: 'Test description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Test'),
},
],
});
// Should emit once more
expect(subscribeCallback).toHaveBeenCalledTimes(2);
const firstValue = subscribeCallback.mock.calls[1][0];
// Register another component to a different extension point
registry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Other Component',
description: 'Other description',
targets: ['grafana/other/point'],
component: () => React.createElement('div', null, 'Other'),
},
],
});
// Should NOT emit because the selected slice (same reference) didn't change
expect(subscribeCallback).toHaveBeenCalledTimes(2);
const secondValue = subscribeCallback.mock.calls[1][0];
expect(Object.is(firstValue, secondValue)).toBe(true);
});
});
});
@@ -74,11 +74,9 @@ export class AddedComponentsRegistry extends Registry<
pointIdLog.debug('Added component extension successfully registered');
if (!(extensionPointId in registry)) {
registry[extensionPointId] = [result];
} else {
registry[extensionPointId].push(result);
}
// Creating a new array instead of pushing to get a new reference
const slice = registry[extensionPointId] ?? [];
registry[extensionPointId] = slice.concat(result);
}
}
@@ -1,4 +1,4 @@
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
@@ -120,6 +120,61 @@ describe('addedFunctionsRegistry', () => {
],
});
});
it('should not emit when registering with empty configs', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
const observable = addedFunctionsRegistry.asObservable();
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Initial subscription should be called once with empty registry
expect(subscribeCallback).toHaveBeenCalledTimes(1);
// Register with empty configs - should not emit
addedFunctionsRegistry.register({
pluginId,
configs: [],
});
// Should still only be called once (no new emission)
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]).toEqual({});
// Verify registry state is still empty
const registry = await addedFunctionsRegistry.getState();
expect(registry).toEqual({});
});
it('should not change registry state when registering with empty configs after previous registrations', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
// First register some extensions
addedFunctionsRegistry.register({
pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn(),
},
],
});
const registryBefore = await addedFunctionsRegistry.getState();
expect(Object.keys(registryBefore)).toHaveLength(1);
// Register with empty configs - should not change state
addedFunctionsRegistry.register({
pluginId,
configs: [],
});
const registryAfter = await addedFunctionsRegistry.getState();
expect(registryAfter).toEqual(registryBefore);
});
it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
@@ -674,4 +729,191 @@ describe('addedFunctionsRegistry', () => {
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.error).not.toHaveBeenCalled();
});
describe('asObservableSlice', () => {
it('should return the selected slice from the registry', async () => {
const registry = new AddedFunctionsRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Function',
description: 'Test description',
targets: extensionPointId,
fn: jest.fn(),
},
],
});
const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(Array.isArray(slice)).toBe(true);
expect(slice?.length).toBe(1);
expect(slice?.[0].title).toBe('Test Function');
});
});
it('should return undefined when the selected key does not exist', async () => {
const registry = new AddedFunctionsRegistry();
const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeUndefined();
});
});
it('should only emit when the selected slice changes (distinctUntilChanged)', async () => {
const registry = new AddedFunctionsRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
const subscribeCallback = jest.fn();
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
observable.subscribe(subscribeCallback);
// Initial empty state
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]).toBeUndefined();
// Register first function
registry.register({
pluginId: 'test-plugin-1',
configs: [
{
title: 'Function 1',
description: 'Description 1',
targets: extensionPointId,
fn: jest.fn(),
},
],
});
// Should emit because the slice changed
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1);
// Register another function to the same extension point
registry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Function 2',
description: 'Description 2',
targets: extensionPointId,
fn: jest.fn(),
},
],
});
// Should emit because the slice changed (array reference changed)
expect(subscribeCallback).toHaveBeenCalledTimes(3);
expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2);
// Register a function to a different extension point
registry.register({
pluginId: 'test-plugin-3',
configs: [
{
title: 'Function 3',
description: 'Description 3',
targets: 'grafana/other/point',
fn: jest.fn(),
},
],
});
// Should NOT emit because the selected slice (for extensionPointId) didn't change
expect(subscribeCallback).toHaveBeenCalledTimes(3);
});
it('should deep freeze the selected slice', async () => {
const registry = new AddedFunctionsRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Function',
description: 'Test description',
targets: extensionPointId,
fn: jest.fn(),
},
],
});
const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
// @ts-expect-error - Testing that frozen objects cannot be modified
expect(() => slice.push({})).toThrow();
expect(() => (slice[0].title = 'Modified')).toThrow();
});
});
it('should work with read-only registries', async () => {
const registry = new AddedFunctionsRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Function',
description: 'Test description',
targets: extensionPointId,
fn: jest.fn(),
},
],
});
const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(Array.isArray(slice)).toBe(true);
expect(slice?.length).toBe(1);
expect(slice?.[0].title).toBe('Test Function');
});
});
it('should emit immediately to new subscribers with the current slice value', async () => {
const registry = new AddedFunctionsRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Function',
description: 'Test description',
targets: extensionPointId,
fn: jest.fn(),
},
],
});
// Subscribe after registration
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Should have been called immediately with the current value
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1);
expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Function');
});
});
});
@@ -11,10 +11,10 @@ import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
const logPrefix = 'Could not register function extension. Reason:';
export type AddedFunctionsRegistryItem = {
export type AddedFunctionsRegistryItem<Signature = unknown> = {
pluginId: string;
title: string;
fn: unknown;
fn: Signature;
description?: string;
};
@@ -67,11 +67,9 @@ export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[
pointIdLog.debug('Added function extension successfully registered');
if (!(extensionPointId in registry)) {
registry[extensionPointId] = [result];
} else {
registry[extensionPointId].push(result);
}
// Creating a new array instead of pushing to get a new reference
const slice = registry[extensionPointId] ?? [];
registry[extensionPointId] = slice.concat(result);
}
}
@@ -1,4 +1,4 @@
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
@@ -714,4 +714,198 @@ describe('AddedLinksRegistry', () => {
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.error).not.toHaveBeenCalled();
});
describe('asObservableSlice', () => {
it('should return the selected slice from the registry', async () => {
const registry = new AddedLinksRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Link',
description: 'Test description',
path: '/test-path',
targets: extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(Array.isArray(slice)).toBe(true);
expect(slice?.length).toBe(1);
expect(slice?.[0].title).toBe('Test Link');
});
});
it('should return undefined when the selected key does not exist', async () => {
const registry = new AddedLinksRegistry();
const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeUndefined();
});
});
it('should only emit when the selected slice changes (distinctUntilChanged)', async () => {
const registry = new AddedLinksRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
const subscribeCallback = jest.fn();
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
observable.subscribe(subscribeCallback);
// Initial empty state
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]).toBeUndefined();
// Register first link
registry.register({
pluginId: 'test-plugin-1',
configs: [
{
title: 'Link 1',
description: 'Description 1',
path: '/path1',
targets: extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
// Should emit because the slice changed
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1);
// Register another link to the same extension point
registry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Link 2',
description: 'Description 2',
path: '/path2',
targets: extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
// Should emit because the slice changed (array reference changed)
expect(subscribeCallback).toHaveBeenCalledTimes(3);
expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2);
// Register a link to a different extension point
registry.register({
pluginId: 'test-plugin-3',
configs: [
{
title: 'Link 3',
description: 'Description 3',
path: '/path3',
targets: 'grafana/other/point',
configure: jest.fn().mockReturnValue({}),
},
],
});
// Should NOT emit because the selected slice (for extensionPointId) didn't change
expect(subscribeCallback).toHaveBeenCalledTimes(3);
});
it('should deep freeze the selected slice', async () => {
const registry = new AddedLinksRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Link',
description: 'Test description',
path: '/test-path',
targets: extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
// @ts-expect-error - Testing that frozen objects cannot be modified
expect(() => slice.push({})).toThrow();
expect(() => (slice[0].title = 'Modified')).toThrow();
});
});
it('should work with read-only registries', async () => {
const registry = new AddedLinksRegistry();
const readOnlyRegistry = registry.readOnly();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Link',
description: 'Test description',
path: '/test-path',
targets: extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(Array.isArray(slice)).toBe(true);
expect(slice?.length).toBe(1);
expect(slice?.[0].title).toBe('Test Link');
});
});
it('should emit immediately to new subscribers with the current slice value', async () => {
const registry = new AddedLinksRegistry();
const extensionPointId = 'grafana/dashboard/panel/menu';
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Link',
description: 'Test description',
path: '/test-path',
targets: extensionPointId,
configure: jest.fn().mockReturnValue({}),
},
],
});
// Subscribe after registration
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Should have been called immediately with the current value
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1);
expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Link');
});
});
});
@@ -74,13 +74,12 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
const pointIdLog = configLog.child({ extensionPointId });
const { targets, ...registryItem } = config;
if (!(extensionPointId in registry)) {
registry[extensionPointId] = [];
}
pointIdLog.debug('Added link extension successfully registered');
registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId });
// Creating a new array instead of pushing to get a new references
const slice = registry[extensionPointId] ?? [];
const result = { ...registryItem, pluginId, extensionPointId };
registry[extensionPointId] = slice.concat(result);
}
}
@@ -1,5 +1,5 @@
import React from 'react';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, take } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
@@ -510,4 +510,106 @@ describe('ExposedComponentsRegistry', () => {
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.error).not.toHaveBeenCalled();
});
describe('asObservableSlice', () => {
it('should return the selected exposed component from the registry', async () => {
const registry = new ExposedComponentsRegistry();
const componentId = 'test-plugin/exposed-component/v1';
registry.register({
pluginId: 'test-plugin',
configs: [
{
id: componentId,
title: 'Exposed Component',
description: 'Test description',
component: () => React.createElement('div', null, 'Exposed'),
},
],
});
const observable = registry.asObservableSlice((state) => state[componentId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(slice?.title).toBe('Exposed Component');
expect(slice?.pluginId).toBe('test-plugin');
});
});
it('should deep freeze exposed components', async () => {
const registry = new ExposedComponentsRegistry();
const componentId = 'test-plugin/exposed-component/v1';
registry.register({
pluginId: 'test-plugin',
configs: [
{
id: componentId,
title: 'Exposed Component',
description: 'Test description',
component: () => React.createElement('div', null, 'Exposed'),
},
],
});
const observable = registry.asObservableSlice((state) => state[componentId]).pipe(take(1));
await expect(observable).toEmitValuesWith((received) => {
const [slice] = received;
expect(slice).toBeDefined();
expect(() => (slice.title = 'Modified')).toThrow();
});
});
it('should only emit when the selected exposed component changes', async () => {
const registry = new ExposedComponentsRegistry();
const componentId1 = 'test-plugin/component1/v1';
const componentId2 = 'test-plugin/component2/v1';
const subscribeCallback = jest.fn();
const observable = registry.asObservableSlice((state) => state[componentId1]);
observable.subscribe(subscribeCallback);
// Initial state
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]).toBeUndefined();
// Register first component
registry.register({
pluginId: 'test-plugin',
configs: [
{
id: componentId1,
title: 'Component 1',
description: 'Description 1',
component: () => React.createElement('div', null, 'Component 1'),
},
],
});
// Should emit
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(subscribeCallback.mock.calls[1][0]?.title).toBe('Component 1');
// Register second component (different id)
registry.register({
pluginId: 'test-plugin',
configs: [
{
id: componentId2,
title: 'Component 2',
description: 'Description 2',
component: () => React.createElement('div', null, 'Component 2'),
},
],
});
// Should NOT emit because the selected slice (componentId1) didn't change
expect(subscribeCallback).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,89 @@
import React from 'react';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
describe('Registry.asObservableSlice ', () => {
describe('Shared behaviour', () => {
it('should handle selecting a non-existent key that later gets added', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
const subscribeCallback = jest.fn();
const observable = registry.asObservableSlice((state) => state[extensionPointId]);
observable.subscribe(subscribeCallback);
// Initially undefined
expect(subscribeCallback).toHaveBeenCalledTimes(1);
expect(subscribeCallback.mock.calls[0][0]).toBeUndefined();
// Register component
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
description: 'Test description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Test'),
},
],
});
// Should emit the new value
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1);
});
it('should handle multiple subscribers independently', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId1 = 'grafana/alerting/home';
const extensionPointId2 = 'grafana/other/point';
const callback1 = jest.fn();
const callback2 = jest.fn();
const observable1 = registry.asObservableSlice((state) => state[extensionPointId1]);
const observable2 = registry.asObservableSlice((state) => state[extensionPointId2]);
observable1.subscribe(callback1);
observable2.subscribe(callback2);
// Both should receive initial undefined
expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
// Register component for extensionPointId1
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Component 1',
description: 'Description 1',
targets: [extensionPointId1],
component: () => React.createElement('div', null, 'Component 1'),
},
],
});
// Only callback1 should be called
expect(callback1).toHaveBeenCalledTimes(2);
expect(callback2).toHaveBeenCalledTimes(1);
// Register component for extensionPointId2
registry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Component 2',
description: 'Description 2',
targets: [extensionPointId2],
component: () => React.createElement('div', null, 'Component 2'),
},
],
});
// Only callback2 should be called
expect(callback1).toHaveBeenCalledTimes(2);
expect(callback2).toHaveBeenCalledTimes(2);
});
});
});
@@ -1,4 +1,4 @@
import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs';
import { Observable, ReplaySubject, Subject, distinctUntilChanged, firstValueFrom, map, scan, startWith } from 'rxjs';
import { ExtensionsLog, log } from '../logs/log';
import { deepFreeze } from '../utils';
@@ -13,7 +13,7 @@ export type PluginExtensionConfigs<T> = {
export type RegistryType<T> = Record<string | symbol, T>;
// This is the base-class used by the separate specific registries.
export abstract class Registry<TRegistryValue, TMapType> {
export abstract class Registry<TRegistryValue extends object | unknown[] | Record<string | symbol, unknown>, TMapType> {
// Used in cases when we would like to pass a read-only registry to plugin.
// In these cases we are passing in the `registrySubject` to the constructor.
// (If TRUE `initialState` is ignored.)
@@ -38,7 +38,6 @@ export abstract class Registry<TRegistryValue, TMapType> {
if (options.registrySubject) {
this.registrySubject = options.registrySubject;
this.isReadOnly = true;
return;
}
@@ -47,8 +46,7 @@ export abstract class Registry<TRegistryValue, TMapType> {
.pipe(
scan(this.mapToRegistry.bind(this), options.initialState ?? {}),
// Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values)
startWith(options.initialState ?? {}),
map((registry) => deepFreeze(registry))
startWith(options.initialState ?? {})
)
// Emitting the new registry to `this.registrySubject`
.subscribe(this.registrySubject);
@@ -63,12 +61,22 @@ export abstract class Registry<TRegistryValue, TMapType> {
if (this.isReadOnly) {
throw new Error(MSG_CANNOT_REGISTER_READ_ONLY);
}
if (result.configs.length === 0) {
return;
}
this.resultSubject.next(result);
}
asObservable(): Observable<RegistryType<TRegistryValue>> {
return this.registrySubject.asObservable();
return this.registrySubject.asObservable().pipe(map((state) => deepFreeze(state)));
}
asObservableSlice(selector: (state: RegistryType<TRegistryValue>) => TRegistryValue): Observable<TRegistryValue> {
return this.registrySubject.asObservable().pipe(
map((state) => selector(state)),
distinctUntilChanged((prev, curr) => Object.is(prev, curr)),
map((slice) => deepFreeze(slice))
);
}
getState(): Promise<RegistryType<TRegistryValue>> {
@@ -0,0 +1,410 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { ExtensionRegistriesProvider } from '../ExtensionRegistriesContext';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types';
import {
useAddedComponentsRegistrySlice,
useAddedFunctionsRegistrySlice,
useAddedLinksRegistrySlice,
useExposedComponentRegistrySlice,
} from './useRegistrySlice';
describe('useRegistrySlice', () => {
let registries: PluginExtensionRegistries;
let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element;
beforeEach(() => {
registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
);
});
describe('useAddedComponentsRegistrySlice', () => {
it('should return undefined when no components are registered for the extension point', () => {
const { result } = renderHook(() => useAddedComponentsRegistrySlice('test/extension-point'), { wrapper });
expect(result.current).toBeUndefined();
});
it('should return an array of components when components are registered for the extension point', () => {
const extensionPointId = 'test/extension-point';
const TestComponent = () => <div>Test Component</div>;
registries.addedComponentsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
component: TestComponent,
targets: extensionPointId,
description: 'Test description',
},
],
});
const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper });
expect(result.current).toBeDefined();
expect(Array.isArray(result.current)).toBe(true);
expect(result.current?.length).toBe(1);
expect(result.current?.[0].title).toBe('Test Component');
expect(result.current?.[0].pluginId).toBe('test-plugin');
expect(result.current?.[0].description).toBe('Test description');
});
it('should return multiple components when multiple components are registered for the same extension point', () => {
const extensionPointId = 'test/extension-point';
const TestComponent1 = () => <div>Test Component 1</div>;
const TestComponent2 = () => <div>Test Component 2</div>;
registries.addedComponentsRegistry.register({
pluginId: 'test-plugin-1',
configs: [
{
title: 'Test Component 1',
component: TestComponent1,
targets: extensionPointId,
description: 'Test description 1',
},
],
});
registries.addedComponentsRegistry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Test Component 2',
component: TestComponent2,
targets: extensionPointId,
description: 'Test description 2',
},
],
});
const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.length).toBe(2);
expect(result.current?.[0].title).toBe('Test Component 1');
expect(result.current?.[0].description).toBe('Test description 1');
expect(result.current?.[1].title).toBe('Test Component 2');
expect(result.current?.[1].description).toBe('Test description 2');
});
it('should only return components for the specified extension point', () => {
const extensionPointId1 = 'test/extension-point-1';
const extensionPointId2 = 'test/extension-point-2';
const TestComponent1 = () => <div>Test Component 1</div>;
const TestComponent2 = () => <div>Test Component 2</div>;
registries.addedComponentsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component 1',
component: TestComponent1,
targets: extensionPointId1,
description: 'Test description 1',
},
{
title: 'Test Component 2',
component: TestComponent2,
targets: extensionPointId2,
description: 'Test description 2',
},
],
});
const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId1), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.length).toBe(1);
expect(result.current?.[0].title).toBe('Test Component 1');
expect(result.current?.[0].description).toBe('Test description 1');
});
});
describe('useExposedComponentsRegistrySlice', () => {
it('should return undefined when no component is exposed for the extension point', () => {
const { result } = renderHook(() => useExposedComponentRegistrySlice('test/exposed-component'), { wrapper });
expect(result.current).toBeUndefined();
});
it('should return a single exposed component when registered', () => {
const exposedComponentId = 'test-plugin/exposed-component';
const TestComponent = () => <div>Exposed Component</div>;
registries.exposedComponentsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
id: exposedComponentId,
title: 'Exposed Component',
component: TestComponent,
},
],
});
const { result } = renderHook(() => useExposedComponentRegistrySlice(exposedComponentId), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.title).toBe('Exposed Component');
expect(result.current?.pluginId).toBe('test-plugin');
expect(Array.isArray(result.current)).toBe(false);
});
it('should return undefined for a different extension point id', () => {
const exposedComponentId = 'test-plugin/exposed-component';
const otherId = 'test-plugin/other-component';
const TestComponent = () => <div>Exposed Component</div>;
registries.exposedComponentsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
id: exposedComponentId,
title: 'Exposed Component',
component: TestComponent,
},
],
});
const { result } = renderHook(() => useExposedComponentRegistrySlice(otherId), { wrapper });
expect(result.current).toBeUndefined();
});
});
describe('useAddedLinksRegistrySlice', () => {
it('should return undefined when no links are registered for the extension point', () => {
const { result } = renderHook(() => useAddedLinksRegistrySlice('test/extension-point'), { wrapper });
expect(result.current).toBeUndefined();
});
it('should return an array of links when links are registered for the extension point', () => {
const extensionPointId = 'test/extension-point';
registries.addedLinksRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Link',
path: '/test-path',
targets: extensionPointId,
},
],
});
const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId), { wrapper });
expect(result.current).toBeDefined();
expect(Array.isArray(result.current)).toBe(true);
expect(result.current?.length).toBe(1);
expect(result.current?.[0].title).toBe('Test Link');
expect(result.current?.[0].path).toBe('/test-path');
expect(result.current?.[0].pluginId).toBe('test-plugin');
});
it('should return multiple links when multiple links are registered for the same extension point', () => {
const extensionPointId = 'test/extension-point';
registries.addedLinksRegistry.register({
pluginId: 'test-plugin-1',
configs: [
{
title: 'Test Link 1',
path: '/test-path-1',
targets: extensionPointId,
},
],
});
registries.addedLinksRegistry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Test Link 2',
path: '/test-path-2',
targets: extensionPointId,
},
],
});
const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.length).toBe(2);
expect(result.current?.[0].title).toBe('Test Link 1');
expect(result.current?.[1].title).toBe('Test Link 2');
});
it('should only return links for the specified extension point', () => {
const extensionPointId1 = 'test/extension-point-1';
const extensionPointId2 = 'test/extension-point-2';
registries.addedLinksRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Link 1',
path: '/test-path-1',
targets: extensionPointId1,
},
{
title: 'Test Link 2',
path: '/test-path-2',
targets: extensionPointId2,
},
],
});
const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId1), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.length).toBe(1);
expect(result.current?.[0].title).toBe('Test Link 1');
});
});
describe('useAddedFunctionsRegistrySlice', () => {
it('should return undefined when no functions are registered for the extension point', () => {
const { result } = renderHook(() => useAddedFunctionsRegistrySlice('test/extension-point'), { wrapper });
expect(result.current).toBeUndefined();
});
it('should return an array of functions when functions are registered for the extension point', () => {
const extensionPointId = 'test/extension-point';
const testFunction = jest.fn();
registries.addedFunctionsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Function',
fn: testFunction,
targets: extensionPointId,
},
],
});
const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId), { wrapper });
expect(result.current).toBeDefined();
expect(Array.isArray(result.current)).toBe(true);
expect(result.current?.length).toBe(1);
expect(result.current?.[0].title).toBe('Test Function');
expect(result.current?.[0].fn).toBe(testFunction);
expect(result.current?.[0].pluginId).toBe('test-plugin');
});
it('should return multiple functions when multiple functions are registered for the same extension point', () => {
const extensionPointId = 'test/extension-point';
const testFunction1 = jest.fn();
const testFunction2 = jest.fn();
registries.addedFunctionsRegistry.register({
pluginId: 'test-plugin-1',
configs: [
{
title: 'Test Function 1',
fn: testFunction1,
targets: extensionPointId,
},
],
});
registries.addedFunctionsRegistry.register({
pluginId: 'test-plugin-2',
configs: [
{
title: 'Test Function 2',
fn: testFunction2,
targets: extensionPointId,
},
],
});
const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.length).toBe(2);
expect(result.current?.[0].title).toBe('Test Function 1');
expect(result.current?.[0].fn).toBe(testFunction1);
expect(result.current?.[1].title).toBe('Test Function 2');
expect(result.current?.[1].fn).toBe(testFunction2);
});
it('should only return functions for the specified extension point', () => {
const extensionPointId1 = 'test/extension-point-1';
const extensionPointId2 = 'test/extension-point-2';
const testFunction1 = jest.fn();
const testFunction2 = jest.fn();
registries.addedFunctionsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Function 1',
fn: testFunction1,
targets: extensionPointId1,
},
{
title: 'Test Function 2',
fn: testFunction2,
targets: extensionPointId2,
},
],
});
const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId1), { wrapper });
expect(result.current).toBeDefined();
expect(result.current?.length).toBe(1);
expect(result.current?.[0].title).toBe('Test Function 1');
expect(result.current?.[0].fn).toBe(testFunction1);
});
});
describe('observable memoization', () => {
it('should memoize the observable when extensionPointId and registry do not change', () => {
const extensionPointId = 'test/extension-point';
const TestComponent = () => <div>Test Component</div>;
registries.addedComponentsRegistry.register({
pluginId: 'test-plugin',
configs: [
{
title: 'Test Component',
component: TestComponent,
targets: extensionPointId,
description: 'Test description',
},
],
});
const { result, rerender } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper });
const firstResult = result.current;
rerender();
expect(result.current).toBe(firstResult);
});
});
});
@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import {
useAddedComponentsRegistry,
useAddedFunctionsRegistry,
useAddedLinksRegistry,
useExposedComponentsRegistry,
} from '../ExtensionRegistriesContext';
import { AddedComponentRegistryItem } from './AddedComponentsRegistry';
import { AddedFunctionsRegistryItem } from './AddedFunctionsRegistry';
import { AddedLinkRegistryItem } from './AddedLinksRegistry';
import { ExposedComponentRegistryItem } from './ExposedComponentsRegistry';
import { Registry } from './Registry';
export function useAddedComponentsRegistrySlice<Props>(
extensionPointId: string
): Array<AddedComponentRegistryItem<Props>> | undefined {
const registry = useAddedComponentsRegistry();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return useRegistrySlice(extensionPointId, registry) as Array<AddedComponentRegistryItem<Props>> | undefined;
}
export function useExposedComponentRegistrySlice<Props>(id: string): ExposedComponentRegistryItem<Props> | undefined {
const registry = useExposedComponentsRegistry();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return useRegistrySlice(id, registry) as ExposedComponentRegistryItem<Props> | undefined;
}
export function useAddedLinksRegistrySlice(extensionPointId: string): AddedLinkRegistryItem[] | undefined {
const registry = useAddedLinksRegistry();
return useRegistrySlice(extensionPointId, registry);
}
export function useAddedFunctionsRegistrySlice<Signature>(
extensionPointId: string
): Array<AddedFunctionsRegistryItem<Signature>> | undefined {
const registry = useAddedFunctionsRegistry();
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return useRegistrySlice(extensionPointId, registry) as Array<AddedFunctionsRegistryItem<Signature>> | undefined;
}
function useRegistrySlice<TRegistryValue extends object | unknown[] | Record<string | symbol, unknown>, TMapType>(
extensionPointId: string,
registry: Registry<TRegistryValue, TMapType>
): TRegistryValue | undefined {
const observable = useMemo(() => {
return registry.asObservableSlice((state) => state[extensionPointId]);
}, [extensionPointId, registry]);
return useObservable(observable);
}
@@ -1,12 +1,11 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { usePluginContext } from '@grafana/data';
import { UsePluginComponentResult } from '@grafana/runtime';
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { useExposedComponentRegistrySlice } from './registry/useRegistrySlice';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils';
import { isExposedComponentDependencyMissing } from './validators';
@@ -14,8 +13,7 @@ import { isExposedComponentDependencyMissing } from './validators';
// Returns a component exposed by a plugin.
// (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.)
export function usePluginComponent<Props extends object = {}>(id: string): UsePluginComponentResult<Props> {
const registry = useExposedComponentsRegistry();
const registryState = useObservable(registry.asObservable());
const registryItem = useExposedComponentRegistrySlice<Props>(id);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
@@ -30,14 +28,13 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
};
}
if (!registryState?.[id]) {
if (!registryItem) {
return {
isLoading: false,
component: null,
};
}
const registryItem = registryState[id];
const componentLog = log.child({
title: registryItem.title,
description: registryItem.description ?? '',
@@ -61,5 +58,5 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
log: componentLog,
}),
};
}, [id, pluginContext, registryState, isLoadingAppPlugins]);
}, [id, pluginContext, registryItem, isLoadingAppPlugins]);
}
@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import {
type ComponentTypeWithExtensionMeta,
@@ -9,8 +8,8 @@ import {
} from '@grafana/data';
import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime';
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry';
import { useAddedComponentsRegistrySlice } from './registry/useRegistrySlice';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
@@ -20,8 +19,7 @@ export function usePluginComponents<Props extends object = {}>({
limitPerPlugin,
extensionPointId,
}: UsePluginComponentsOptions): UsePluginComponentsResult<Props> {
const registry = useAddedComponentsRegistry();
const registryState = useObservable(registry.asObservable());
const registryItems = useAddedComponentsRegistrySlice<Props>(extensionPointId);
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
@@ -38,7 +36,7 @@ export function usePluginComponents<Props extends object = {}>({
const components: Array<ComponentTypeWithExtensionMeta<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
for (const registryItem of registryState?.[extensionPointId] ?? []) {
for (const registryItem of registryItems ?? []) {
const { pluginId } = registryItem;
// Only limit if the `limitPerPlugin` is set
@@ -50,10 +48,7 @@ export function usePluginComponents<Props extends object = {}>({
extensionsByPlugin[pluginId] = 0;
}
const component = createComponentWithMeta<Props>(
registryItem as AddedComponentRegistryItem<Props>,
extensionPointId
);
const component = createComponentWithMeta<Props>(registryItem, extensionPointId);
components.push(component);
extensionsByPlugin[pluginId] += 1;
@@ -63,7 +58,7 @@ export function usePluginComponents<Props extends object = {}>({
isLoading: false,
components,
};
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]);
}
export function createComponentWithMeta<Props extends JSX.IntrinsicAttributes>(
@@ -1,10 +1,9 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data';
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime';
import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext';
import { useAddedFunctionsRegistrySlice } from './registry/useRegistrySlice';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies } from './utils';
import { validateExtensionPoint } from './validateExtensionPoint';
@@ -14,8 +13,7 @@ export function usePluginFunctions<Signature>({
limitPerPlugin,
extensionPointId,
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
const registry = useAddedFunctionsRegistry();
const registryState = useObservable(registry.asObservable());
const registryItems = useAddedFunctionsRegistrySlice<Signature>(extensionPointId);
const pluginContext = usePluginContext();
const deps = getExtensionPointPluginDependencies(extensionPointId);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
@@ -33,7 +31,7 @@ export function usePluginFunctions<Signature>({
const results: Array<PluginExtensionFunction<Signature>> = [];
const extensionsByPlugin: Record<string, number> = {};
for (const registryItem of registryState?.[extensionPointId] ?? []) {
for (const registryItem of registryItems ?? []) {
const { pluginId } = registryItem;
// Only limit if the `limitPerPlugin` is set
@@ -51,7 +49,7 @@ export function usePluginFunctions<Signature>({
title: registryItem.title,
description: registryItem.description ?? '',
pluginId: pluginId,
fn: registryItem.fn as Signature,
fn: registryItem.fn,
});
extensionsByPlugin[pluginId] += 1;
}
@@ -60,5 +58,5 @@ export function usePluginFunctions<Signature>({
isLoading: false,
functions: results,
};
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
}, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]);
}
@@ -1,11 +1,10 @@
import { isString } from 'lodash';
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data';
import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime';
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import { useAddedLinksRegistrySlice } from './registry/useRegistrySlice';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import {
generateExtensionId,
@@ -23,9 +22,8 @@ export function usePluginLinks({
extensionPointId,
context,
}: UsePluginLinksOptions): UsePluginLinksResult {
const registry = useAddedLinksRegistry();
const registryItems = useAddedLinksRegistrySlice(extensionPointId);
const pluginContext = usePluginContext();
const registryState = useObservable(registry.asObservable());
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
@@ -46,7 +44,7 @@ export function usePluginLinks({
const extensions: PluginExtensionLink[] = [];
const extensionsByPlugin: Record<string, number> = {};
for (const addedLink of registryState?.[extensionPointId] ?? []) {
for (const addedLink of registryItems ?? []) {
const { pluginId } = addedLink;
const linkLog = pointLog.child({
path: addedLink.path ?? '',
@@ -96,5 +94,5 @@ export function usePluginLinks({
isLoading: false,
links: extensions,
};
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]);
}, [context, extensionPointId, limitPerPlugin, registryItems, pluginContext, isLoadingAppPlugins]);
}