mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf: write the plugin list to storage once per burst of changes
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user