From 2d608461ddd1cfb5517d437b980142c61633dbc8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:46:45 -0700 Subject: [PATCH] fix(remote): recover terminal attach and renderer HMR --- .../terminal-pane/pty-connection.test.ts | 48 +++++++ .../terminal-pane/pty-connection.ts | 69 +++++++++- .../remote-runtime-pty-transport.test.ts | 40 ++++++ .../remote-runtime-pty-transport.ts | 126 ++++++++++++++---- .../src/lib/react-renderer-root.test.ts | 32 +++++ src/renderer/src/lib/react-renderer-root.ts | 20 +++ src/renderer/src/main.tsx | 4 +- src/renderer/src/popout.tsx | 4 +- 8 files changed, 314 insertions(+), 29 deletions(-) create mode 100644 src/renderer/src/lib/react-renderer-root.test.ts create mode 100644 src/renderer/src/lib/react-renderer-root.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index db6030d00fd..acfb2627b6f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -5510,6 +5510,7 @@ describe('connectPanePty', () => { // Once replay completes (guard cleared), real keystrokes flow through. replayingPanesRef.current.delete(1) setFitOverride('pty-live', 'remote-desktop-fit', 80, 24) + transport.claimViewport.mockClear() ;(onDataHandler as (data: string) => void)('a') expect(transport.sendInput).toHaveBeenCalledWith('a') expect(transport.claimViewport).toHaveBeenCalledTimes(1) @@ -23564,6 +23565,53 @@ describe('connectPanePty', () => { expect(transport.resize).not.toHaveBeenCalled() }) + it('claims a visible remote mirror once when its passive fit hold arrives', async () => { + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { connectPanePty } = await import('./pty-connection') + const ptyId = 'remote:env-1@@terminal-visible' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: ptyId }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const pane = createPane(2) + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => ({ cols: 132, rows: 42 })) + } as never + const binding = connectPanePty(pane as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + transport.claimViewport.mockClear() + + setFitOverride(ptyId, 'remote-desktop-fit', 10, 4) + + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + setFitOverride(ptyId, 'desktop-fit', 132, 42) + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + expect(transport.claimViewport).toHaveBeenCalledWith(132, 42) + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + deps.isVisibleRef.current = false + binding.syncProcessTracking() + deps.isVisibleRef.current = true + binding.noteVisibilityResume() + transport.claimViewport.mockClear() + setFitOverride(ptyId, 'remote-desktop-fit', 80, 24) + expect(transport.claimViewport).not.toHaveBeenCalled() + + setFitOverride(ptyId, 'desktop-fit', 132, 42) + binding.dispose() + }) + it('does NOT re-assert while a mobile-fit override parks the PTY at phone dims', async () => { const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') vi.mocked(window.api.pty.getSize).mockResolvedValue({ cols: 80, rows: 24 }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 188fdf11c55..26bf52f8e8a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -69,7 +69,11 @@ import { type SafeFitContinuationHandle } from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' -import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' +import { + bindPanePtyId, + getFitOverrideForPty, + onOverrideChange +} from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' @@ -2249,14 +2253,26 @@ export function connectPanePty( terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true }) } + let visibleRemoteViewportClaimPtyId: string | null = null + let pendingVisibleRemoteViewportClaim = false const setPanePtyFitBinding = (ptyId: string): void => { bindPanePtyId(pane.id, ptyId, deps.tabId) pane.container.dataset.ptyId = ptyId + if ( + deps.isVisibleRef.current && + isRemoteRuntimePtyId(ptyId) && + visibleRemoteViewportClaimPtyId !== ptyId + ) { + // Why: the initial fit event consumes this activation arm before later peer ownership changes. + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } // Why: override hydration can arrive before this pane knows its PTY. Once - // data-pty-id is bound, safeFit can park xterm at the held phone grid. + // data-pty-id is bound, safeFit can park xterm at the authoritative grid. if (getFitOverrideForPty(ptyId)) { safeFit(pane) } + claimPendingVisibleRemoteViewport() } let activePanePtyBinding: string | null = null // Why: bind time lets async liveness reconcile ignore a request started @@ -2310,6 +2326,8 @@ export function connectPanePty( // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. bindPanePtyId(pane.id, null, deps.tabId) + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false activePanePtyBinding = null activePanePtyBindingBoundAt = null delete pane.container.dataset.ptyId @@ -3847,6 +3865,45 @@ export function connectPanePty( transport.claimViewport?.(cols, rows) } } + const claimPendingVisibleRemoteViewport = (): void => { + if (!pendingVisibleRemoteViewportClaim || !deps.isVisibleRef.current) { + return + } + claimViewportForUserActivity() + } + const armVisibleRemoteViewportClaim = (): void => { + const ptyId = transport.getPtyId() + if (!ptyId || !isRemoteRuntimePtyId(ptyId)) { + visibleRemoteViewportClaimPtyId = null + pendingVisibleRemoteViewportClaim = false + return + } + if ( + visibleRemoteViewportClaimPtyId !== ptyId || + pendingVisibleRemoteViewportClaim || + getFitOverrideForPty(ptyId)?.mode === 'remote-desktop-fit' + ) { + visibleRemoteViewportClaimPtyId = ptyId + pendingVisibleRemoteViewportClaim = true + } + } + const unsubscribeRemoteDesktopActivationClaim = onOverrideChange((event) => { + if (event.ptyId !== transport.getPtyId() || !isRemoteRuntimePtyId(event.ptyId)) { + return + } + if (event.mode === 'desktop-fit') { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = false + return + } + if (event.mode === 'remote-desktop-fit') { + if (deps.isVisibleRef.current && visibleRemoteViewportClaimPtyId !== event.ptyId) { + visibleRemoteViewportClaimPtyId = event.ptyId + pendingVisibleRemoteViewportClaim = true + } + claimPendingVisibleRemoteViewport() + } + }) // Why: an unbound transport (detached during a remount/move and never // rebound) silently rejects every keystroke while the PTY stays alive and @@ -8726,15 +8783,22 @@ export function connectPanePty( agentCompletionCoordinator.startProcessTracking() // Why: the hidden-delivery gate must follow every pane visibility flip. syncHiddenRendererPtyDelivery() + if (!deps.isVisibleRef.current) { + pendingVisibleRemoteViewportClaim = false + } }, // Why: visible-resume size readback repairs dropped hidden resizes without refitting against xterm's transient hidden DOM fallback. noteVisibilityResume() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() requestKnownDroidReconfirmation() sampleVisiblePaneForegroundAgent() }, reassertPtySizeAfterWindowWake() { + armVisibleRemoteViewportClaim() + claimPendingVisibleRemoteViewport() ptySizeReassertion.request({ fit: false }) }, // Why: mobile wake reaches this pane while it's hidden on the desktop, so consume only the armed hibernation wake — no size/foreground reads. @@ -8813,6 +8877,7 @@ export function connectPanePty( // Why: park/reconnect/remount doesn't advance the recovery epoch, so invalidate this xterm or its delayed retry could hit the next instance. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + unsubscribeRemoteDesktopActivationClaim() cancelHiddenOutputSnapshotScrollRestore() structuralReplayCoordinator.dispose() cancelFreshSpawnFollowReset() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 1a7ca3d7170..7637081e229 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -701,6 +701,46 @@ describe('createRemoteRuntimePtyTransport', () => { ) }) + it('retries initial web mirror inventory after a transient runtime close', async () => { + const healthyRuntimeCall = runtimeCall.getMockImplementation() + let activateAttempts = 0 + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate' && activateAttempts++ === 0) { + throw Object.assign(new Error('Remote Orca runtime closed the connection.'), { + code: 'remote_runtime_unavailable' + }) + } + return healthyRuntimeCall?.(request) + }) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const recoveryPhases: string[] = [] + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-host-tab-1', + leafId: 'pane:1' + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@stale-client-handle', + cols: 100, + rows: 30, + callbacks: { + onError, + onRecoveryStateChange: (state) => recoveryPhases.push(state.phase) + } + }) + + await vi.waitFor(() => expect(runtimeSubscribe).toHaveBeenCalledTimes(1)) + expect(activateAttempts).toBe(2) + expect(onError).not.toHaveBeenCalled() + expect(recoveryPhases).toContain('backoff') + expect(latestSubscribePayload().terminal).toBe('terminal-1') + expect(runtimeCall.mock.calls.some(([request]) => request.method === 'terminal.create')).toBe( + false + ) + }) + it('resolves a HUB-native SSH PTY wake hint to its runtime terminal handle', async () => { const leafId = '11111111-1111-4111-8111-111111111111' runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 7048e324e63..99111478043 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -164,6 +164,7 @@ export function createRemoteRuntimePtyTransport( let resubscribeRequestedRequiresReplacement = false let recoveryRequiresReplacement = false let stopWaitingForPublishedHandle: (() => void) | null = null + let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let attachGeneration = 0 let subscriptionGeneration = 0 @@ -213,6 +214,13 @@ export function createRemoteRuntimePtyTransport( subscriptionGeneration += 1 closeMultiplexedStream() } + if ( + recovery.currentPhase === 'disconnected' || + recovery.currentPhase === 'disposed' || + recovery.currentPhase === 'idle' + ) { + settleHostSessionAttachRetry?.(false) + } emitRecoveryState() }) let lastRecoveryStateKey = '' @@ -228,6 +236,7 @@ export function createRemoteRuntimePtyTransport( let agentSessionRequiresHostAuthorityReplay = false let terminalCreateUnknownOutcomeError: unknown = null let lastConnectOptions: Parameters[0] | null = null + let lastAttachOptions: Parameters[0] | null = null let resolvePaneUnavailable = false let recoveringPaneHandle: string | null = null const adoptExecutionMetadata = (terminal: { @@ -397,7 +406,8 @@ export function createRemoteRuntimePtyTransport( } async function waitForHostSessionHandle( - hostTabId: string + hostTabId: string, + isCurrent: () => boolean ): Promise { if (!worktreeId) { return undefined @@ -425,7 +435,7 @@ export function createRemoteRuntimePtyTransport( } const startedAt = Date.now() - while (!destroyed) { + while (isCurrent()) { const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) if (remainingMs <= 0) { return undefined @@ -457,6 +467,61 @@ export function createRemoteRuntimePtyTransport( return undefined } + function waitForHostSessionAttachRetry(recoveryEpoch: number): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (retry: boolean): void => { + if (settled) { + return + } + settled = true + if (settleHostSessionAttachRetry === settle) { + settleHostSessionAttachRetry = null + } + resolve(retry) + } + settleHostSessionAttachRetry?.(false) + settleHostSessionAttachRetry = settle + if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + settle(false) + } + }) + } + + async function waitForHostSessionHandleWithRecovery( + hostTabId: string, + isCurrent: () => boolean + ): Promise { + let recoveryEpoch = recovery.isActive ? recovery.currentEpoch : undefined + while (isCurrent()) { + try { + const hostHandle = await waitForHostSessionHandle(hostTabId, isCurrent) + if (!isCurrent()) { + return undefined + } + if (recoveryEpoch !== undefined) { + if (!recovery.isCurrent(recoveryEpoch)) { + return undefined + } + recovery.markHealthy() + } + return hostHandle + } catch (error) { + if ( + !isRecoverableRemoteRuntimeConnectionError(toRemoteRuntimeClientErrorLike(error)) || + !isCurrent() + ) { + throw error + } + recoveryEpoch = recovery.isActive ? recovery.currentEpoch : recovery.begin() + if (!(await waitForHostSessionAttachRetry(recoveryEpoch)) || !isCurrent()) { + return undefined + } + } + } + return undefined + } + async function waitForResubscribeHostSessionHandle( hostTabId: string, previousHandle: string, @@ -523,29 +588,27 @@ export function createRemoteRuntimePtyTransport( async function attachHostSessionMirror( options: { cols?: number; rows?: number }, notifySpawn = true, - expectedAttachGeneration?: number + expectedAttachGeneration?: number, + expectedLifecycleEpoch?: number ): Promise { if (!tabId || !isWebTerminalSurfaceTabId(tabId)) { return undefined } + const isCurrent = (): boolean => + !destroyed && + (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) && + (expectedLifecycleEpoch === undefined || expectedLifecycleEpoch === lifecycleEpoch) const hostTabId = toHostSessionTabId(tabId) - const hostHandle = await waitForHostSessionHandle(hostTabId) - if (hostHandle === undefined || destroyed) { + const hostHandle = await waitForHostSessionHandleWithRecovery(hostTabId, isCurrent) + if (hostHandle === undefined || !isCurrent()) { return undefined } if (hostHandle === null) { storedCallbacks.onError?.('Remote terminal was closed.') return undefined } - if ( - !hostHandle || - destroyed || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { - if ( - !destroyed && - (expectedAttachGeneration === undefined || expectedAttachGeneration === attachGeneration) - ) { + if (!hostHandle || !isCurrent()) { + if (isCurrent()) { storedCallbacks.onError?.('Remote terminal was closed.') } return undefined @@ -573,6 +636,9 @@ export function createRemoteRuntimePtyTransport( } } + if (!isCurrent()) { + return undefined + } handle = hostHandle remotePtyId = toRemoteRuntimePtyId(hostHandle, currentRuntimeEnvironmentId) registerShutdownHandlers(remotePtyId) @@ -592,12 +658,7 @@ export function createRemoteRuntimePtyTransport( throw error } } - if ( - destroyed || - !connected || - !remotePtyId || - (expectedAttachGeneration !== undefined && expectedAttachGeneration !== attachGeneration) - ) { + if (!connected || !remotePtyId || !isCurrent()) { return undefined } @@ -1548,6 +1609,7 @@ export function createRemoteRuntimePtyTransport( const connectLifecycleEpoch = ++lifecycleEpoch const createEnvironmentId = currentRuntimeEnvironmentId lastConnectOptions = options + lastAttachOptions = null storedCallbacks = options.callbacks recoveryRequiresReplacement = false terminalEnded = false @@ -1559,7 +1621,7 @@ export function createRemoteRuntimePtyTransport( try { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - return await attachHostSessionMirror(options) + return await attachHostSessionMirror(options, true, undefined, connectLifecycleEpoch) } if (options.sessionId && !getRemoteRuntimeTerminalHandle(options.sessionId)) { @@ -1762,12 +1824,13 @@ export function createRemoteRuntimePtyTransport( }, attach(options) { - lifecycleEpoch += 1 + const attachLifecycleEpoch = ++lifecycleEpoch const generation = ++attachGeneration cancelTerminalCreateRetryWait() recovery.cancel() recoveryRequiresReplacement = false clearPublishedHandleWait() + lastAttachOptions = options storedCallbacks = options.callbacks terminalEnded = false connecting = true @@ -1798,7 +1861,7 @@ export function createRemoteRuntimePtyTransport( const persistedHandle = nextHandle void (async () => { if (isWebTerminalSurfaceTabId(tabId ?? '')) { - await attachHostSessionMirror(options, false, generation) + await attachHostSessionMirror(options, false, generation, attachLifecycleEpoch) return } if (!tabId || !leafId || !worktreeId) { @@ -1998,6 +2061,23 @@ export function createRemoteRuntimePtyTransport( getRecoveryState, retryRecovery() { + if ( + !destroyed && + !terminalEnded && + !connected && + isWebTerminalSurfaceTabId(tabId ?? '') && + recovery.currentPhase === 'disconnected' + ) { + recovery.cancel() + if (lastAttachOptions) { + transport.attach(lastAttachOptions) + return true + } + if (lastConnectOptions) { + void transport.connect(lastConnectOptions) + return true + } + } if ( !destroyed && !terminalEnded && diff --git a/src/renderer/src/lib/react-renderer-root.test.ts b/src/renderer/src/lib/react-renderer-root.test.ts new file mode 100644 index 00000000000..3592196f969 --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { getOrCreateRendererRoot } from './react-renderer-root' + +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn() +})) + +beforeEach(() => { + vi.mocked(createRoot).mockReset() +}) + +describe('getOrCreateRendererRoot', () => { + it('reuses the root retained by an HMR entry module', () => { + const hotData: { orcaRendererRoot?: Root } = {} + const root = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValue(root) + + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(getOrCreateRendererRoot({} as HTMLElement, hotData)).toBe(root) + expect(createRoot).toHaveBeenCalledTimes(1) + }) + + it('creates an independent root without HMR state', () => { + const first = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + const second = { render: vi.fn(), unmount: vi.fn() } as unknown as Root + vi.mocked(createRoot).mockReturnValueOnce(first).mockReturnValueOnce(second) + + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(first) + expect(getOrCreateRendererRoot({} as HTMLElement)).toBe(second) + }) +}) diff --git a/src/renderer/src/lib/react-renderer-root.ts b/src/renderer/src/lib/react-renderer-root.ts new file mode 100644 index 00000000000..0a73210ecfa --- /dev/null +++ b/src/renderer/src/lib/react-renderer-root.ts @@ -0,0 +1,20 @@ +import { createRoot, type Root } from 'react-dom/client' + +type RendererRootHotData = { + orcaRendererRoot?: Root +} + +export function getOrCreateRendererRoot( + container: HTMLElement, + hotData?: RendererRootHotData +): Root { + const existingRoot = hotData?.orcaRendererRoot + if (existingRoot) { + return existingRoot + } + const root = createRoot(container) + if (hotData) { + hotData.orcaRendererRoot = root + } + return root +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index a980c8ed730..7acc230bb5a 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import App from './App' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -14,6 +13,7 @@ import { installTypingLatencyDiagnostic } from './lib/typing-latency-diagnostic' import { shouldEnableReactGrab } from './lib/react-grab-dev-gate' import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' recordRendererCrashBreadcrumb('renderer_bootstrap_started', { dev: import.meta.env.DEV }) installRendererCrashDiagnostics() @@ -55,7 +55,7 @@ function RendererRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render( diff --git a/src/renderer/src/popout.tsx b/src/renderer/src/popout.tsx index e68565f509c..15f1be88fb6 100644 --- a/src/renderer/src/popout.tsx +++ b/src/renderer/src/popout.tsx @@ -1,7 +1,6 @@ import './assets/main.css' import { StrictMode, useEffect } from 'react' -import { createRoot } from 'react-dom/client' import { useTranslation } from 'react-i18next' import { DashboardPopoutRoot } from './components/dashboard-popout/DashboardPopoutRoot' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' @@ -15,6 +14,7 @@ import { I18nProvider } from './i18n/I18nProvider' import { translate } from './i18n/i18n' import { useAppStore } from './store' import type { GlobalSettings } from '../../shared/types' +import { getOrCreateRendererRoot } from './lib/react-renderer-root' // Why: the pop-out window is a separate BrowserWindow with its own React root, // so it must run the same renderer bootstrap as main.tsx (crash diagnostics, @@ -114,7 +114,7 @@ function PopoutRoot(): React.JSX.Element { ) } -createRoot(rootElement).render( +getOrCreateRendererRoot(rootElement, import.meta.hot?.data).render(