diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 08ff4b611..e4d906d37 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -57,7 +57,7 @@ vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({ import { register as registerActive, deregister as deregisterActive, get as getActive } from '@/lib/plugin-sandbox/registry'; // Import after mocks -import { usePluginStore } from '@/stores/plugin-store'; +import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store'; import { pluginStorage } from '@/lib/plugin-storage'; function resetStore() { @@ -430,3 +430,54 @@ describe('plugins the admin removed', () => { expect(pluginStorage.deletePreview).toHaveBeenCalledWith('gone'); }); }); + +describe('saving the plugin list', () => { + const writes = (setItem: { mock: { calls: unknown[][] } }) => + setItem.mock.calls.filter(([key]) => key === 'plugin-storage') as [string, string][]; + + it('writes once for a burst of changes, and not again when only the status changed', () => { + // Write what the reset in beforeEach left pending, before counting. + window.dispatchEvent(new Event('pagehide')); + vi.useFakeTimers(); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + try { + usePluginStore.setState({ plugins: [mockPlugin({ enabled: true, status: 'enabled', locales: { de: { hello: 'Hallo' } } })] }); + // Start-up: one status change per plugin and step. + for (let i = 0; i < 10; i++) usePluginStore.getState().setPluginStatus('test-plugin', i % 2 ? 'running' : 'enabled'); + expect(writes(setItem)).toHaveLength(0); + + vi.advanceTimersByTime(PLUGIN_PERSIST_DELAY_MS); + expect(writes(setItem)).toHaveLength(1); + expect(JSON.parse(writes(setItem)[0][1]).state.plugins[0]).toMatchObject({ id: 'test-plugin', status: 'enabled', locales: { de: { hello: 'Hallo' } } }); + + // The status is not stored, so the text is the same: nothing to write. + usePluginStore.getState().setPluginStatus('test-plugin', 'error', 'boom'); + vi.advanceTimersByTime(PLUGIN_PERSIST_DELAY_MS); + expect(writes(setItem)).toHaveLength(1); + + usePluginStore.setState((s) => ({ plugins: s.plugins.map((p) => ({ ...p, settings: { theme: 'dark' } })) })); + vi.advanceTimersByTime(PLUGIN_PERSIST_DELAY_MS); + expect(writes(setItem)).toHaveLength(2); + expect(JSON.parse(writes(setItem)[1][1]).state.plugins[0].settings).toEqual({ theme: 'dark' }); + } finally { + setItem.mockRestore(); + vi.useRealTimers(); + } + }); + + it('writes a pending change right away when the page is hidden', () => { + window.dispatchEvent(new Event('pagehide')); + vi.useFakeTimers(); + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + try { + usePluginStore.setState({ plugins: [mockPlugin({ id: 'hidden-test', enabled: true })] }); + expect(writes(setItem)).toHaveLength(0); + window.dispatchEvent(new Event('pagehide')); + expect(writes(setItem)).toHaveLength(1); + expect(JSON.parse(writes(setItem)[0][1]).state.plugins[0].id).toBe('hidden-test'); + } finally { + setItem.mockRestore(); + vi.useRealTimers(); + } + }); +}); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index c234767cc..8de589761 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -3,7 +3,7 @@ import { cancelConnectFor, forgetCredentials } from '@/lib/plugin-sandbox/connect-dialog'; import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; +import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware'; import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types'; import { pluginStorage } from '@/lib/plugin-storage'; import { extractPlugin } from '@/lib/plugin-validator'; @@ -62,6 +62,66 @@ interface PluginStoreState { initializePlugins: () => Promise; } +// ─── Persistence ───────────────────────────────────────────── + +/** How long a change waits before it is written, so a burst becomes one write. */ +export const PLUGIN_PERSIST_DELAY_MS = 250; + +/** + * localStorage for the plugin list, written once per burst of changes and + * only when the stored text changes. The list carries every plugin's locale + * tables (about 1 MB with a dozen plugins), and the stock storage serialized + * and wrote all of it on every set(): start-up, one status change per plugin, + * wrote 13 MB and blocked the page for ~400 ms. Status is not even persisted + * (partialize resets it), so most of those writes stored the same text again. + * Pending changes are written when the page is hidden or unloaded. + */ +function batchedLocalStorage(): PersistStorage { + let pending: { name: string; value: StorageValue } | null = null; + let timer: ReturnType | null = null; + const written = new Map(); + + const flush = () => { + if (timer) { clearTimeout(timer); timer = null; } + if (!pending) return; + const { name, value } = pending; + pending = null; + try { + const text = JSON.stringify(value); + if (written.get(name) === text) return; + localStorage.setItem(name, text); + written.set(name, text); + } catch { + // Storage full or blocked: the list is still in memory for this page. + } + }; + + if (typeof window !== 'undefined') { + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') flush(); + }); + } + + return { + getItem: (name) => { + const text = localStorage.getItem(name); + if (text === null) return null; + written.set(name, text); + return JSON.parse(text) as StorageValue; + }, + setItem: (name, value) => { + pending = { name, value }; + if (!timer) timer = setTimeout(flush, PLUGIN_PERSIST_DELAY_MS); + }, + removeItem: (name) => { + if (pending?.name === name) pending = null; + written.delete(name); + localStorage.removeItem(name); + }, + }; +} + // ─── Store ─────────────────────────────────────────────────── export const usePluginStore = create()( @@ -330,6 +390,7 @@ export const usePluginStore = create()( }), { name: 'plugin-storage', + storage: batchedLocalStorage(), partialize: (state) => ({ plugins: state.plugins.map(p => ({ ...p,