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.
This commit is contained in:
Neil
2026-10-28 21:56:21 -07:00
committed by GitHub
parent a50a44bb8d
commit 12bb6aeabe
4 changed files with 133 additions and 142 deletions
@@ -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 (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
@@ -55,7 +50,6 @@ export default function PluginCustomEditorFrame({
binaryDocument,
onBytesChange
}: PluginCustomEditorFrameProps): React.JSX.Element {
const [entry, setEntry] = useState<EntryState>({ status: 'loading' })
const [refusal, setRefusal] = useState<CustomEditorEditRefusal | null>(null)
const [saveBlocked, setSaveBlocked] = useState(false)
const iframeRef = useRef<HTMLIFrameElement | null>(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(() => {
@@ -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: '<h1>Hello plugin</h1>',
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: '<h1>Hello plugin</h1>',
@@ -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 (
<div className="flex min-h-0 flex-1 items-center justify-center p-6 text-center text-sm text-muted-foreground">
@@ -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<PluginPanelEntryState>({ status: 'loading' })
const [sessionToken, setSessionToken] = useState<string | null>(null)
const iframeRef = useRef<HTMLIFrameElement | null>(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 (
<PluginPanelMessage>
{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 (
<PluginPanelMessage>
{translate(
@@ -149,7 +85,7 @@ function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element {
)
}
if (entryState.status === 'error') {
if (entry.status === 'error') {
return (
<PluginPanelMessage>
{translate(
@@ -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<PluginFrameEntryState>({ 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 }
}