Performance: Load shared frontend plugin dependencies on demand (#87644)

* feat(plugins): only load shared plugin dependencies when needed

* feat(plugins): add react-redux and fix up comments

* feat(plugins): attempt to load async deps in fe sandbox

* feat(frontend): defer script execution to prevent systemjs from loading app.js
This commit is contained in:
Jack Westbrook 2024-08-27 15:10:26 +02:00 committed by GitHub
parent 1e32e98bf6
commit 892a50a3b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 66 additions and 61 deletions

View File

@ -1,25 +1,4 @@
import * as emotion from '@emotion/css';
import * as emotionReact from '@emotion/react';
import * as kusto from '@kusto/monaco-kusto';
import * as d3 from 'd3';
import * as i18next from 'i18next';
import jquery from 'jquery';
import _ from 'lodash'; // eslint-disable-line lodash/import-scope
import moment from 'moment'; // eslint-disable-line no-restricted-imports
import prismjs from 'prismjs';
import react from 'react';
import reactDom from 'react-dom';
import * as reactInlineSvg from 'react-inlinesvg';
import * as reactRedux from 'react-redux'; // eslint-disable-line no-restricted-imports
import * as reactRouterDom from 'react-router-dom';
import * as reactRouterCompat from 'react-router-dom-v5-compat';
import * as redux from 'redux';
import * as rxjs from 'rxjs';
import * as rxjsOperators from 'rxjs/operators';
import slate from 'slate';
import slatePlain from 'slate-plain-serializer';
import slateReact from 'slate-react';
import 'vendor/flot/jquery.flot';
import 'vendor/flot/jquery.flot.selection';
import 'vendor/flot/jquery.flot.time';
@ -65,14 +44,14 @@ const jQueryFlotDeps = [
'jquery.flot',
].reduce((acc, flotDep) => ({ ...acc, [flotDep]: { fakeDep: 1 } }), {});
export const sharedDependenciesMap: Record<string, System.Module> = {
'@emotion/css': emotion,
'@emotion/react': emotionReact,
export const sharedDependenciesMap = {
'@emotion/css': () => import('@emotion/css'),
'@emotion/react': () => import('@emotion/react'),
'@grafana/data': grafanaData,
'@grafana/runtime': grafanaRuntime,
'@grafana/slate-react': slateReact, // for backwards compatibility with older plugins
'@grafana/slate-react': () => import('slate-react'),
'@grafana/ui': grafanaUI,
'@kusto/monaco-kusto': kusto,
'@kusto/monaco-kusto': () => import('@kusto/monaco-kusto'),
'app/core/app_events': {
default: appEvents,
__useDefault: true,
@ -102,29 +81,23 @@ export const sharedDependenciesMap: Record<string, System.Module> = {
'app/features/dashboard/impression_store': {
impressions: impressionSrv,
},
d3: d3,
emotion: emotion,
d3: () => import('d3'),
emotion: () => import('@emotion/css'),
// bundling grafana-ui in plugins requires sharing i18next state
i18next: i18next,
i18next: () => import('i18next'),
jquery: {
default: jquery,
__useDefault: true,
},
...jQueryFlotDeps,
lodash: {
default: _,
__useDefault: true,
},
moment: {
default: moment,
__useDefault: true,
},
prismjs: prismjs,
react: react,
'react-dom': reactDom,
lodash: () => import('lodash').then((module) => ({ ...module, __useDefault: true })),
moment: () => import('moment').then((module) => ({ ...module, __useDefault: true })),
prismjs: () => import('prismjs'),
react: () => import('react'),
'react-dom': () => import('react-dom'),
// bundling grafana-ui in plugins requires sharing react-inlinesvg for the icon cache
'react-inlinesvg': reactInlineSvg,
'react-redux': reactRedux,
'react-inlinesvg': () => import('react-inlinesvg'),
'react-redux': () => import('react-redux'),
// Migration - React Router v5 -> v6
// =================================
// Plugins that still use "react-router-dom@v5" don't depend on react-router directly, so they will not use this import.
@ -137,12 +110,12 @@ export const sharedDependenciesMap: Record<string, System.Module> = {
// just exposing "react-router-dom-v5-compat".
//
// (This means that we are exposing two versions of the same package).
'react-router-dom': reactRouterDom, // react-router-dom@v5
'react-router': reactRouterCompat, // react-router-dom@v6, react-router@v6 (included)
redux: redux,
rxjs: rxjs,
'rxjs/operators': rxjsOperators,
slate: slate,
'slate-plain-serializer': slatePlain,
'slate-react': slateReact,
'react-router-dom': () => import('react-router-dom'),
'react-router': () => import('react-router-dom-v5-compat'),
redux: () => import('redux'),
rxjs: () => import('rxjs'),
'rxjs/operators': () => import('rxjs/operators'),
slate: () => import('slate'),
'slate-plain-serializer': () => import('slate-plain-serializer'),
'slate-react': () => import('slate-react'),
};

View File

@ -1,6 +1,8 @@
import 'systemjs/dist/system';
// Add ability to load plugins bundled as AMD format
import 'systemjs/dist/extras/amd';
// Add named register for on demand dependency loading
import 'systemjs/dist/extras/named-register.js';
// Add ability to load plugins bundled as CJS format
import 'systemjs-cjs-extra';

View File

@ -3,7 +3,6 @@ import { config } from '@grafana/runtime';
import { sandboxPluginDependencies } from '../sandbox/plugin_dependencies';
import { SHARED_DEPENDENCY_PREFIX } from './constants';
import { trackPackageUsage } from './packageMetrics';
import { SystemJS } from './systemjs';
export function buildImportMap(importMap: Record<string, System.Module>) {
@ -11,14 +10,9 @@ export function buildImportMap(importMap: Record<string, System.Module>) {
// Use the 'package:' prefix to act as a URL instead of a bare specifier
const module_name = `${SHARED_DEPENDENCY_PREFIX}:${key}`;
// get the module to use
const module = config.featureToggles.pluginsAPIMetrics ? trackPackageUsage(importMap[key], key) : importMap[key];
// expose dependency to loaders
addPreload(module_name, importMap[key]);
// expose dependency to SystemJS
SystemJS.set(module_name, module);
// expose dependency to sandboxed plugins
// the sandbox handles its own way of plugins api metrics
sandboxPluginDependencies.set(key, importMap[key]);
acc[key] = module_name;
@ -26,6 +20,37 @@ export function buildImportMap(importMap: Record<string, System.Module>) {
}, {});
}
function addPreload(id: string, preload: (() => Promise<System.Module>) | System.Module) {
if (SystemJS.has(id)) {
return;
}
let resolvedId;
try {
resolvedId = SystemJS.resolve(id);
} catch (e) {
console.log(e);
}
if (resolvedId && SystemJS.has(resolvedId)) {
return;
}
const moduleId = resolvedId || id;
if (typeof preload === 'function') {
SystemJS.register(id, [], (_export) => {
return {
execute: async function () {
const module = await preload();
_export(module);
},
};
});
} else {
SystemJS.set(moduleId, preload);
}
}
export function isHostedOnCDN(path: string) {
return Boolean(config.pluginsCDNBaseURL) && path.startsWith(config.pluginsCDNBaseURL);
}

View File

@ -2,4 +2,4 @@
* Map with all dependencies that are exposed to plugins sandbox
* e.g.: @grafana/ui, @grafana/data, etc...
*/
export const sandboxPluginDependencies = new Map<string, System.Module>([]);
export const sandboxPluginDependencies = new Map<string, System.Module | (() => Promise<System.Module>)>([]);

View File

@ -162,7 +162,7 @@ async function doImportPluginModuleInSandbox(meta: SandboxPluginMeta): Promise<S
}
try {
const resolvedDeps = resolvePluginDependencies(dependencies, meta);
const resolvedDeps = await resolvePluginDependencies(dependencies, meta);
// execute the plugin's code
const pluginExportsRaw = factory.apply(null, resolvedDeps);
// only after the plugin has been executed
@ -220,7 +220,7 @@ async function doImportPluginModuleInSandbox(meta: SandboxPluginMeta): Promise<S
* https://github.com/requirejs/requirejs/wiki/Differences-between-the-simplified-CommonJS-wrapper-and-standard-AMD-define#magic
*
*/
function resolvePluginDependencies(deps: string[], pluginMeta: SandboxPluginMeta) {
async function resolvePluginDependencies(deps: string[], pluginMeta: SandboxPluginMeta) {
const pluginExports = {};
const pluginModuleDep: ModuleMeta = {
id: pluginMeta.id,
@ -232,6 +232,10 @@ function resolvePluginDependencies(deps: string[], pluginMeta: SandboxPluginMeta
const resolvedDeps: CompartmentDependencyModule[] = [];
for (const dep of deps) {
let resolvedDep = sandboxPluginDependencies.get(dep);
if (typeof resolvedDep === 'function') {
resolvedDep = await resolvedDep();
}
if (resolvedDep?.__useDefault) {
resolvedDep = resolvedDep.default;
}

View File

@ -331,6 +331,7 @@
nonce="[[$.Nonce]]"
src="[[$asset.FilePath]]"
type="text/javascript"
defer
></script>
[[end]]