From 12bb6aeabedab77dae86bdfd07b7627717487ea9 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Wed, 28 Oct 2026 21:56:21 -0700
Subject: [PATCH] refactor(plugins): share one frame-entry loader between
panels and custom editors (#35680)
Panels and custom editors each had their own copy of the read-entry,
newest-read-wins, reload-on-change loop. The panel copy also cleared its
failure dot on the next plugin-list change while it still showed the
suspended message. One hook now loads both; a suspended frame stays suspended
(and reported failed) until its HTML actually changes, and panel health is
derived from the entry state.
---
.../editor/PluginCustomEditorFrame.tsx | 63 +----------
.../right-sidebar/PluginPanel.test.tsx | 20 ++++
.../components/right-sidebar/PluginPanel.tsx | 100 ++++--------------
.../right-sidebar/use-plugin-frame-entry.ts | 92 ++++++++++++++++
4 files changed, 133 insertions(+), 142 deletions(-)
create mode 100644 src/renderer/src/components/right-sidebar/use-plugin-frame-entry.ts
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 }
+}