diff --git a/src/renderer/src/components/editor/PluginCustomEditorFrame.tsx b/src/renderer/src/components/editor/PluginCustomEditorFrame.tsx index d9ff8556ed9..35ddeacb3de 100644 --- a/src/renderer/src/components/editor/PluginCustomEditorFrame.tsx +++ b/src/renderer/src/components/editor/PluginCustomEditorFrame.tsx @@ -6,6 +6,7 @@ import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../../../shared/plugins/frame import { CUSTOM_EDITOR_LARGE_DOCUMENT_BYTES } from '../../../../shared/plugins/frames/plugin-custom-editor-bridge' import { fillPluginPanelShell } from '../right-sidebar/plugin-panel-design-token-css' import { usePluginFrameHost } from '../right-sidebar/use-plugin-frame-host' +import { usePluginFrameEntry } from '../right-sidebar/use-plugin-frame-entry' import { editorShortcutMatches } from './editor-shortcuts' import { registerEditorFrameFlush } from './editor-pending-flush' import { @@ -29,12 +30,6 @@ type PluginCustomEditorFrameProps = { onBytesChange?: (document: CustomEditorBinaryDocument) => void } -type EntryState = - | { status: 'loading' } - | { status: 'error' } - | { status: 'unresponsive' } - | { status: 'ready'; html: string; sessionToken: string | null; revision: number } - function FrameMessage({ children }: { children: React.ReactNode }): React.JSX.Element { return (
@@ -55,7 +50,6 @@ export default function PluginCustomEditorFrame({ binaryDocument, onBytesChange }: PluginCustomEditorFrameProps): React.JSX.Element { - const [entry, setEntry] = useState({ status: 'loading' }) const [refusal, setRefusal] = useState(null) const [saveBlocked, setSaveBlocked] = useState(false) const iframeRef = useRef(null) @@ -89,58 +83,7 @@ export default function PluginCustomEditorFrame({ const isLarge = (binaryDocument?.bytes.byteLength ?? content.length) > CUSTOM_EDITOR_LARGE_DOCUMENT_BYTES - const { pluginKey, id: editorId } = editor - useEffect(() => { - let cancelled = false - let currentHtml: string | null = null - let revision = 0 - // Why: refresh events start overlapping reads; only the newest may set the entry. - let latestRead = 0 - const pluginsApi = window.api?.plugins - const readEntry = pluginsApi?.readCustomEditorEntry - if (!pluginsApi || !readEntry) { - setEntry({ status: 'error' }) - return - } - setEntry({ status: 'loading' }) - const load = (): void => { - const read = ++latestRead - readEntry({ pluginKey, editorId }) - .then((result) => { - if (cancelled || read !== latestRead) { - return - } - if (!result) { - currentHtml = null - setEntry({ status: 'error' }) - return - } - // Plugin list changes fire often; only new bytes remount the frame. - if (result.html !== currentHtml) { - currentHtml = result.html - revision += 1 - } - setEntry({ - status: 'ready', - html: result.html, - sessionToken: result.sessionToken, - revision - }) - }) - .catch(() => { - if (!cancelled && read === latestRead) { - currentHtml = null - setEntry({ status: 'error' }) - } - }) - } - load() - const unsubscribe = pluginsApi.onChanged ? pluginsApi.onChanged(load) : null - return () => { - cancelled = true - unsubscribe?.() - } - }, [pluginKey, editorId]) + const { entry, markUnresponsive } = usePluginFrameEntry('editor', editor.pluginKey, editor.id) const frameKey = entry.status === 'ready' ? `${editor.key}:${entry.revision}` : null const entryHtml = entry.status === 'ready' ? entry.html : null @@ -197,7 +140,7 @@ export default function PluginCustomEditorFrame({ frameKey, sessionToken: entry.status === 'ready' ? entry.sessionToken : null, editor: host.channel, - onUnresponsive: () => setEntry({ status: 'unresponsive' }) + onUnresponsive: markUnresponsive }) useEffect(() => { diff --git a/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx b/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx index 6c7497e0f26..6d9024075dc 100644 --- a/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx +++ b/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx @@ -331,6 +331,26 @@ describe('PluginPanel', () => { expect(container.textContent).toContain('stopped responding and was suspended') }) + it('stays suspended, and reported as failed, when the plugin list changes but its HTML does not', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

Hello plugin

', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:samples.my-plugin/dashboard') + await act(async () => watchdogCallbacks.onUnresponsive?.()) + + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + expect(container.textContent).toContain('stopped responding and was suspended') + expect(setPanelHealthMock).toHaveBeenLastCalledWith( + 'plugin:samples.my-plugin/dashboard', + 'error' + ) + }) + it('keeps a watchdog error published when navigation unmounts the failed panel', async () => { readPanelEntryMock.mockResolvedValue({ html: '

Hello plugin

', diff --git a/src/renderer/src/components/right-sidebar/PluginPanel.tsx b/src/renderer/src/components/right-sidebar/PluginPanel.tsx index 2abf2109b98..77c4d9e3295 100644 --- a/src/renderer/src/components/right-sidebar/PluginPanel.tsx +++ b/src/renderer/src/components/right-sidebar/PluginPanel.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' import { isPluginPanelTabKey } from '../../../../shared/plugins/manifest/plugin-manifest' import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../../../shared/plugins/frames/plugin-frame-bridge' import { fillPluginPanelShell } from './plugin-panel-design-token-css' import { usePluginFrameHost } from './use-plugin-frame-host' +import { usePluginFrameEntry } from './use-plugin-frame-entry' import { usePluginPanels, usePluginPanelsStore } from '@/store/plugin-panels' import { translate } from '@/i18n/i18n' @@ -10,12 +11,6 @@ type PluginPanelProps = { tabKey: string } -type PluginPanelEntryState = - | { status: 'loading' } - | { status: 'error' } - | { status: 'unresponsive' } - | { status: 'ready'; shellHtml: string; documentRevision: number } - function PluginPanelMessage({ children }: { children: React.ReactNode }): React.JSX.Element { return (
@@ -30,93 +25,34 @@ function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element { const panel = isPluginPanelTabKey(tabKey) ? (panels.find((entry) => entry.tabKey === tabKey) ?? null) : null - const [entryState, setEntryState] = useState({ status: 'loading' }) - const [sessionToken, setSessionToken] = useState(null) const iframeRef = useRef(null) - - const pluginKey = panel?.pluginKey ?? null - const panelId = panel?.id ?? null - const panelShell = entryState.status === 'ready' ? entryState.shellHtml : null + const { entry, markUnresponsive } = usePluginFrameEntry( + 'panel', + panel?.pluginKey ?? null, + panel?.id ?? null + ) + const panelShell = entry.status === 'ready' ? entry.html : null // Why memoized: a new srcdoc reloads the frame; theme changes arrive as messages instead. const panelDocument = useMemo( () => (panelShell ? fillPluginPanelShell(panelShell) : null), [panelShell] ) - const panelFrameKey = - entryState.status === 'ready' ? `${tabKey}:${entryState.documentRevision}` : null + const panelFrameKey = entry.status === 'ready' ? `${tabKey}:${entry.revision}` : null const { onFrameLoad } = usePluginFrameHost({ surface: 'panel', iframeRef, frameKey: panelFrameKey, - sessionToken, - onUnresponsive: () => { - setPanelHealth(tabKey, 'error') - setEntryState({ status: 'unresponsive' }) - } + sessionToken: entry.status === 'ready' ? entry.sessionToken : null, + onUnresponsive: markUnresponsive }) useEffect(() => { - if (!pluginKey || !panelId) { - return - } - let cancelled = false - let currentHtml: string | null = null - let documentRevision = 0 - setEntryState({ status: 'loading' }) - setSessionToken(null) - const pluginsApi = window.api?.plugins - if (!pluginsApi) { + if (entry.status === 'ready') { + setPanelHealth(tabKey, 'healthy') + } else if (entry.status !== 'loading') { setPanelHealth(tabKey, 'error') - setEntryState({ status: 'error' }) - return } - let loadGeneration = 0 - const load = (): void => { - const generation = ++loadGeneration - pluginsApi - .readPanelEntry({ pluginKey, panelId }) - .then((entry) => { - if (cancelled || generation !== loadGeneration) { - return - } - if (!entry) { - currentHtml = null - setSessionToken(null) - setPanelHealth(tabKey, 'error') - setEntryState({ status: 'error' }) - return - } - // Rotate authority without replacing unchanged content or restarting its watchdog. - setSessionToken(entry.sessionToken) - setPanelHealth(tabKey, 'healthy') - if (entry.html !== currentHtml) { - currentHtml = entry.html - documentRevision += 1 - setPanelHealth(tabKey, 'healthy') - setEntryState({ - status: 'ready', - shellHtml: entry.html, - documentRevision - }) - } - }) - .catch(() => { - if (!cancelled && generation === loadGeneration) { - currentHtml = null - setSessionToken(null) - setPanelHealth(tabKey, 'error') - setEntryState({ status: 'error' }) - } - }) - } - load() - const unsubscribe = pluginsApi.onChanged ? pluginsApi.onChanged(load) : null - return () => { - cancelled = true - loadGeneration += 1 - unsubscribe?.() - } - }, [panelId, pluginKey, setPanelHealth, tabKey]) + }, [entry.status, setPanelHealth, tabKey]) // Persisted tabs can outlive an uninstalled or disabled plugin. if (!panel) { @@ -130,7 +66,7 @@ function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element { ) } - if (entryState.status === 'loading') { + if (entry.status === 'loading') { return ( {translate('auto.components.right.sidebar.PluginPanel.loading', 'Loading plugin panel...')} @@ -138,7 +74,7 @@ function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element { ) } - if (entryState.status === 'unresponsive') { + if (entry.status === 'unresponsive') { return ( {translate( @@ -149,7 +85,7 @@ function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element { ) } - if (entryState.status === 'error') { + if (entry.status === 'error') { return ( {translate( diff --git a/src/renderer/src/components/right-sidebar/use-plugin-frame-entry.ts b/src/renderer/src/components/right-sidebar/use-plugin-frame-entry.ts new file mode 100644 index 00000000000..f9115e4e9c3 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-plugin-frame-entry.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useState } from 'react' + +export type PluginFrameEntryState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'unresponsive' } + | { status: 'ready'; html: string; sessionToken: string | null; revision: number } + +/** + * A plugin frame's document (panel or custom editor), reread on every plugin change. Only new + * HTML bumps the revision that remounts the frame; a suspended frame stays suspended until then. + */ +export function usePluginFrameEntry( + surface: 'panel' | 'editor', + /** Null while there is no plugin to read from. */ + pluginKey: string | null, + /** The panel or custom editor id in the plugin's manifest. */ + contributionId: string | null +): { entry: PluginFrameEntryState; markUnresponsive: () => void } { + const [entry, setEntry] = useState({ status: 'loading' }) + + useEffect(() => { + if (!pluginKey || !contributionId) { + return + } + let cancelled = false + let currentHtml: string | null = null + let revision = 0 + // Why: refresh events start overlapping reads; only the newest may set the entry. + let latestRead = 0 + setEntry({ status: 'loading' }) + const pluginsApi = window.api?.plugins + if (!pluginsApi) { + setEntry({ status: 'error' }) + return + } + const fail = (): void => { + currentHtml = null + setEntry({ status: 'error' }) + } + const load = (): void => { + const readId = ++latestRead + const pending = + surface === 'panel' + ? pluginsApi.readPanelEntry({ pluginKey, panelId: contributionId }) + : pluginsApi.readCustomEditorEntry?.({ pluginKey, editorId: contributionId }) + if (!pending) { + fail() + return + } + pending + .then((result) => { + if (cancelled || readId !== latestRead) { + return + } + if (!result) { + fail() + return + } + const changed = result.html !== currentHtml + if (changed) { + currentHtml = result.html + revision += 1 + } + const ready: PluginFrameEntryState = { + status: 'ready', + html: result.html, + sessionToken: result.sessionToken, + revision + } + // Why: a session rotation must not revive a frame the watchdog suspended. + setEntry((previous) => + previous.status === 'unresponsive' && !changed ? previous : ready + ) + }) + .catch(() => { + if (!cancelled && readId === latestRead) { + fail() + } + }) + } + load() + const unsubscribe = pluginsApi.onChanged ? pluginsApi.onChanged(load) : null + return () => { + cancelled = true + unsubscribe?.() + } + }, [surface, pluginKey, contributionId]) + + const markUnresponsive = useCallback(() => setEntry({ status: 'unresponsive' }), []) + return { entry, markUnresponsive } +}