mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user