perf: write the plugin list to storage once per burst of changes

This commit is contained in:
Linus Rath
2026-10-25 02:12:50 +02:00
parent 782d4f8bf5
commit 9a568aa4f4
2 changed files with 114 additions and 2 deletions
+52 -1
View File
@@ -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();
}
});
});
+62 -1
View File
@@ -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<void>;
}
// ─── 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<S>(): PersistStorage<S> {
let pending: { name: string; value: StorageValue<S> } | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
const written = new Map<string, string>();
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<S>;
},
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<PluginStoreState>()(
@@ -330,6 +390,7 @@ export const usePluginStore = create<PluginStoreState>()(
}),
{
name: 'plugin-storage',
storage: batchedLocalStorage(),
partialize: (state) => ({
plugins: state.plugins.map(p => ({
...p,