diff --git a/lib/__tests__/server-plugin-list.test.ts b/lib/__tests__/server-plugin-list.test.ts new file mode 100644 index 000000000..cf8e216a3 --- /dev/null +++ b/lib/__tests__/server-plugin-list.test.ts @@ -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()), + 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 }); + }); +}); diff --git a/lib/server-plugin-list.ts b/lib/server-plugin-list.ts new file mode 100644 index 000000000..4b1c1a04a --- /dev/null +++ b/lib/server-plugin-list.ts @@ -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 | null = null; + +export function fetchServerPluginList(): Promise { + 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; +} diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 8de589761..dc399547e 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -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 | ((state: PluginStoreState) => Partial)) => 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)); diff --git a/stores/theme-store.ts b/stores/theme-store.ts index 23bbc9b9b..5dad5661a 100644 --- a/stores/theme-store.ts +++ b/stores/theme-store.ts @@ -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()( 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;