diff --git a/lib/__tests__/plugin-store.test.ts b/lib/__tests__/plugin-store.test.ts index 1b9de3251..4a8e602ac 100644 --- a/lib/__tests__/plugin-store.test.ts +++ b/lib/__tests__/plugin-store.test.ts @@ -189,6 +189,24 @@ describe('usePluginStore', () => { expect(p).toMatchObject({ managed: true, enabled: true, bundleHash: 'hash-v1' }); }); + it('lists every server plugin when IndexedDB cannot be written (#1327)', async () => { + mocks.apiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ plugins: [serverPlugin, { ...serverPlugin, id: 'optional-plugin', forceEnabled: false }], themes: [] }), + }); + vi.mocked(pluginStorage.getCode).mockRejectedValue(new Error('IndexedDB unavailable')); + vi.mocked(pluginStorage.saveCode).mockRejectedValueOnce(new Error('IndexedDB unavailable')); + vi.mocked(pluginStorage.saveCode).mockRejectedValueOnce(new Error('IndexedDB unavailable')); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await usePluginStore.getState().initializePlugins(); + + const plugins = usePluginStore.getState().plugins; + expect(plugins.map(p => p.id)).toEqual(['managed-plugin', 'optional-plugin']); + expect(plugins[0]).toMatchObject({ managed: true, enabled: true }); + expect(plugins[1]).toMatchObject({ managed: true, enabled: false }); + }); + it('re-downloads the bundle when the record is current but IndexedDB has no copy (#636)', async () => { usePluginStore.setState({ plugins: [syncedRecord()] }); vi.mocked(pluginStorage.getCode).mockResolvedValue(null); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 9a20b34b6..016e08980 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -524,7 +524,7 @@ async function syncServerPlugins( // New server plugin - download bundle and install. const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); + await cacheManagedBundle(sp.id, code); const plugin: InstalledPlugin = { id: sp.id, @@ -569,7 +569,7 @@ async function syncServerPlugins( if (needsBundle) { const code = await downloadPluginBundle(sp.id, sp.bundleHash, sp.tier); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); + await cacheManagedBundle(sp.id, code); } // Force-enable in the same pass when the server flips it on, so the user @@ -625,6 +625,19 @@ async function downloadPluginBundle(pluginId: string, bundleHash?: string, tier? } } +/** + * Caching is best effort. Where IndexedDB can't be written (some private + * windows, storage disabled) the plugin must still be listed: the loader + * fetches a managed bundle again when no local copy exists (#1327). + */ +async function cacheManagedBundle(pluginId: string, code: string): Promise { + try { + await pluginStorage.saveCode(pluginId, code); + } catch (err) { + console.warn(`[plugin-store] Could not cache bundle for "${pluginId}":`, err); + } +} + // ─── Server-side admin-approval helpers ─────────────────────── async function checkServerApproval(pluginId: string, bundleHash: string): Promise<{ status: 'pending' | 'approved' | 'denied' | 'not-requested' } | null> {