mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(remote): recover terminal attach and renderer HMR
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<PtyTransport['connect']>[0] | null = null
|
||||
let lastAttachOptions: Parameters<PtyTransport['attach']>[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<string | null | undefined | false> {
|
||||
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<boolean> {
|
||||
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<string | null | undefined | false> {
|
||||
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<PtyConnectResult | undefined> {
|
||||
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 &&
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<RendererRoot />
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<PopoutSettingsSync />
|
||||
|
||||
Reference in New Issue
Block a user