From f0477bc4fdf5481fe6a05f8507cff2a58c2e7aa2 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:08:35 +0100 Subject: [PATCH] fix: keep the composer's proofreading off the plugin store, and safe with a stand-in editor #1397 --- .../proofreading/text-check-extension.ts | 8 +++--- .../email/proofreading/use-proofreading.ts | 10 +++---- lib/__tests__/plugin-known-plugins.test.ts | 17 ++++++++++++ lib/plugin-sandbox/known-plugins.ts | 26 +++++++++++++++++++ stores/plugin-store.ts | 5 ++++ 5 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/plugin-known-plugins.test.ts create mode 100644 lib/plugin-sandbox/known-plugins.ts diff --git a/components/email/proofreading/text-check-extension.ts b/components/email/proofreading/text-check-extension.ts index 1fa08bddf..f1c912e64 100644 --- a/components/email/proofreading/text-check-extension.ts +++ b/components/email/proofreading/text-check-extension.ts @@ -194,21 +194,21 @@ export function createTextCheckExtension(options: () => TextCheckOptions) { /** Put the checkers' issues on the editor, replacing what was there. */ export function setTextIssues(editor: Editor, issues: TextIssueRange[]): void { - if (editor.isDestroyed) return; + if (editor.isDestroyed || !editor.state) return; const { tr } = editor.state; editor.view.dispatch(tr.setMeta(textCheckKey, { issues } satisfies Meta).setMeta('addToHistory', false)); } /** Mark the issue whose popup is open (or none). */ export function setActiveIssue(editor: Editor, key: string | null): void { - if (editor.isDestroyed) return; + if (editor.isDestroyed || !editor.state) return; const { tr } = editor.state; editor.view.dispatch(tr.setMeta(textCheckKey, { active: key } satisfies Meta).setMeta('addToHistory', false)); } /** The issues drawn now, in document order, with their current ranges. */ -export function drawnIssues(state: EditorState): TextIssueRange[] { - const set = textCheckKey.getState(state)?.decos; +export function drawnIssues(state: EditorState | undefined): TextIssueRange[] { + const set = state ? textCheckKey.getState(state)?.decos : undefined; if (!set) return []; return set.find().map((d) => { const spec = d.spec as IssueSpec; diff --git a/components/email/proofreading/use-proofreading.ts b/components/email/proofreading/use-proofreading.ts index 6811e2aa8..dbc5c5a9d 100644 --- a/components/email/proofreading/use-proofreading.ts +++ b/components/email/proofreading/use-proofreading.ts @@ -18,7 +18,7 @@ import { actionsFor, subscribeActions } from "@/lib/plugin-sandbox/actions"; import { all as allActivePlugins, subscribe as subscribeActivePlugins } from "@/lib/plugin-sandbox/registry"; import { replaceTextareaRange, type PlainOwnOptions } from "@/lib/plugin-sandbox/composer-edit"; import { usePolicyStore } from "@/stores/policy-store"; -import { usePluginStore } from "@/stores/plugin-store"; +import { knownPlugins, subscribeKnownPlugins } from "@/lib/plugin-sandbox/known-plugins"; import type { InstalledPlugin } from "@/lib/plugin-types"; import { useSettingsStore } from "@/stores/settings-store"; import { @@ -48,7 +48,7 @@ export function policySettled(s: Pick /** Plugins turned on that aren't running yet (still loading). */ function startingPlugins(running: InstalledPlugin[]): InstalledPlugin[] { const up = new Set(running.map((p) => p.id)); - return usePluginStore.getState().plugins.filter((p) => p.enabled && p.status !== 'error' && p.status !== 'disabled' && !up.has(p.id)); + return knownPlugins().filter((p) => p.enabled && p.status !== 'error' && p.status !== 'disabled' && !up.has(p.id)); } // The per-composer switch outlives a remount (pop-out, layout switch). @@ -148,7 +148,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p subscribeCheckers(() => p.sync()), subscribeActions(() => p.gateChanged()), subscribeActivePlugins(() => p.gateChanged()), - usePluginStore.subscribe(() => p.gateChanged()), + subscribeKnownPlugins(() => p.gateChanged()), usePolicyStore.subscribe(() => p.gateChanged()), useSettingsStore.subscribe((s, prev) => { if (s.writingLanguage !== prev.writingLanguage) p.edited("now"); @@ -205,7 +205,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p // Underlines in the rich editor. const drawnRef = useRef(""); useEffect(() => { - if (!editor || editor.isDestroyed || plainTextMode) return; + if (!editor || editor.isDestroyed || !editor.state || plainTextMode) return; const ranges = issues.map((i) => ({ key: i.key, from: i.from, to: i.to, kind: i.kind, text: i.annotation.text })); const signature = ranges.map((r) => `${r.key}@${r.from}`).join(","); if (signature === drawnRef.current) return; @@ -217,7 +217,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p // and the list and the count follow. const [drawnKeys, setDrawnKeys] = useState(null); useEffect(() => { - if (!editor || editor.isDestroyed || plainTextMode) { setDrawnKeys(null); return; } + if (!editor || editor.isDestroyed || !editor.state || plainTextMode) { setDrawnKeys(null); return; } const read = () => { const keys = drawnIssues(editor.state).map((r) => r.key); setDrawnKeys((prev) => (prev && prev.length === keys.length && prev.every((k, i) => k === keys[i]) ? prev : keys)); diff --git a/lib/__tests__/plugin-known-plugins.test.ts b/lib/__tests__/plugin-known-plugins.test.ts new file mode 100644 index 000000000..c4c59b92c --- /dev/null +++ b/lib/__tests__/plugin-known-plugins.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { usePluginStore } from '@/stores/plugin-store'; +import { knownPlugins } from '@/lib/plugin-sandbox/known-plugins'; +import type { InstalledPlugin } from '@/lib/plugin-types'; + +// The composer's proofreading reads the installed plugins from here, so it +// knows which signing plugin is still starting, without importing the store. + +describe('known plugins', () => { + it('follow the plugin store', () => { + const smime = { id: 'smime', name: 'S/MIME', permissions: ['crypto:full'], enabled: true, status: 'enabled' } as unknown as InstalledPlugin; + usePluginStore.setState({ plugins: [smime] }); + expect(knownPlugins()).toEqual([smime]); + usePluginStore.setState({ plugins: [] }); + expect(knownPlugins()).toEqual([]); + }); +}); diff --git a/lib/plugin-sandbox/known-plugins.ts b/lib/plugin-sandbox/known-plugins.ts new file mode 100644 index 000000000..83b9d62e7 --- /dev/null +++ b/lib/plugin-sandbox/known-plugins.ts @@ -0,0 +1,26 @@ +// The user's installed plugins as the plugin store has them, for code that +// needs the list without loading the store and everything it imports (the +// composer's proofreading checks which plugins are turned on but not running +// yet). The plugin store keeps it current. + +import type { InstalledPlugin } from '../plugin-types'; + +let plugins: readonly InstalledPlugin[] = []; +const listeners = new Set<() => void>(); + +export function setKnownPlugins(next: readonly InstalledPlugin[]): void { + if (next === plugins) return; + plugins = next; + for (const l of listeners) { + try { l(); } catch { /* ignore */ } + } +} + +export function knownPlugins(): readonly InstalledPlugin[] { + return plugins; +} + +export function subscribeKnownPlugins(listener: () => void): () => void { + listeners.add(listener); + return () => { listeners.delete(listener); }; +} diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 3ded83644..84572efff 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -1,6 +1,7 @@ // Plugin store - manages installed plugins and lifecycle. Slot registrations // are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store. +import { setKnownPlugins } from '@/lib/plugin-sandbox/known-plugins'; import { cancelConnectFor, forgetCredentials } from '@/lib/plugin-sandbox/connect-dialog'; import { create } from 'zustand'; import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware'; @@ -876,3 +877,7 @@ if (!IS_LITE) { }, }); } + +// The list for code that shouldn't import this store (lib/plugin-sandbox/known-plugins.ts). +setKnownPlugins(usePluginStore.getState().plugins); +usePluginStore.subscribe((state) => setKnownPlugins(state.plugins));