perf: fetch the server plugin list once per page load

This commit is contained in:
Linus Rath
2026-10-25 02:15:13 +02:00
parent 9a568aa4f4
commit be8adde945
4 changed files with 90 additions and 6 deletions
+55
View File
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mocks = vi.hoisted(() => ({ apiFetch: vi.fn() }));
vi.mock('@/lib/browser-navigation', async (orig) => ({
...(await orig<typeof import('@/lib/browser-navigation')>()),
apiFetch: (...args: unknown[]) => mocks.apiFetch(...args),
}));
vi.mock('@/lib/plugin-loader', () => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
deactivatePlugin: vi.fn(),
setPluginStoreAccessor: vi.fn(),
setupAutoDisable: vi.fn(),
setSandboxLocale: vi.fn(),
}));
import { fetchServerPluginList } from '@/lib/server-plugin-list';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
const pluginListCalls = () => mocks.apiFetch.mock.calls.filter(([url]) => url === '/api/plugins');
beforeEach(() => {
mocks.apiFetch.mockReset();
mocks.apiFetch.mockImplementation(async () => new Response(JSON.stringify({ plugins: [], themes: [] })));
});
describe('the server plugin list', () => {
it('is fetched once when the plugin and theme stores sync at the same time', async () => {
usePluginStore.setState({ plugins: [], initialized: false });
// What the mail view does on mount.
await Promise.all([
usePluginStore.getState().initializePlugins(),
useThemeStore.getState().syncServerThemes(),
]);
expect(pluginListCalls()).toHaveLength(1);
expect(usePluginStore.getState().initialized).toBe(true);
});
it('is fetched again by a later call', async () => {
const first = await fetchServerPluginList();
const second = await fetchServerPluginList();
expect(pluginListCalls()).toHaveLength(2);
expect(first.data).toEqual({ plugins: [], themes: [] });
expect(second.ok).toBe(true);
});
it('passes a refused request on without a body', async () => {
mocks.apiFetch.mockImplementation(async () => new Response('{"error":"Not authenticated"}', { status: 401 }));
await expect(fetchServerPluginList()).resolves.toEqual({ ok: false, status: 401, data: null });
});
});
+27
View File
@@ -0,0 +1,27 @@
// GET /api/plugins, shared by the plugin store and the theme store.
//
// Both sync from the same list when the app starts, and each used to fetch
// it: two copies of a response that carries every plugin's translations
// (hundreds of kB compressed) on every page load. Calls that overlap share
// one request; a later call (after signing in, say) fetches again.
import { apiFetch } from '@/lib/browser-navigation';
export interface ServerPluginListResult {
ok: boolean;
status: number;
/** The parsed body when `ok`, otherwise null. */
data: unknown;
}
let inFlight: Promise<ServerPluginListResult> | null = null;
export function fetchServerPluginList(): Promise<ServerPluginListResult> {
if (!inFlight) {
inFlight = (async () => {
const res = await apiFetch('/api/plugins');
return { ok: res.ok, status: res.status, data: res.ok ? await res.json() : null };
})().finally(() => { inFlight = null; });
}
return inFlight;
}
+4 -3
View File
@@ -22,6 +22,7 @@ import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { coerceSetting, effectiveSettings } from '@/lib/plugin-settings';
import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import { fetchServerPluginList } from '@/lib/server-plugin-list';
import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
import type { Permission } from '@/lib/plugin-types';
import { IS_LITE } from '@/lib/lite';
@@ -569,12 +570,12 @@ async function syncServerPlugins(
set: (partial: Partial<PluginStoreState> | ((state: PluginStoreState) => Partial<PluginStoreState>)) => void,
): Promise<'signed-out' | void> {
try {
const res = await apiFetch('/api/plugins');
const res = await fetchServerPluginList();
if (res.status === 401) return 'signed-out';
if (!res.ok) return;
const data: { plugins: ServerPluginInfo[] } = await res.json();
if (!data.plugins || !Array.isArray(data.plugins)) return;
const data = res.data as { plugins: ServerPluginInfo[] };
if (!data?.plugins || !Array.isArray(data.plugins)) return;
const serverPlugins = data.plugins;
const serverPluginIds = new Set(serverPlugins.map(p => p.id));
+4 -3
View File
@@ -14,6 +14,7 @@ import { extractTheme } from '@/lib/plugin-validator';
import { BUILTIN_THEMES } from '@/lib/builtin-themes';
import { usePolicyStore } from '@/stores/policy-store';
import { apiFetch } from '@/lib/browser-navigation';
import { fetchServerPluginList } from '@/lib/server-plugin-list';
import { themeHooks } from '@/lib/plugin-hooks';
import { syncThemeColorMeta } from '@/lib/theme-color-meta';
import { IS_LITE } from '@/lib/lite';
@@ -368,11 +369,11 @@ export const useThemeStore = create<ThemeState>()(
themeSyncPromise = (async () => {
try {
const res = await apiFetch('/api/plugins');
const res = await fetchServerPluginList();
if (!res.ok) return;
const data: { themes: ServerThemeInfo[] } = await res.json();
if (!data.themes || !Array.isArray(data.themes)) return;
const data = res.data as { themes: ServerThemeInfo[] };
if (!data?.themes || !Array.isArray(data.themes)) return;
const serverThemes = data.themes;