mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
fix(renderer): land the lazy-chunk recovery reload
A lazy chunk import() that rejects after an in-place update requested win.location.reload(), a renderer-initiated navigation that the privileged window guard preventDefaults unconditionally. The breadcrumbs show lazy_chunk_reload -> 10s -> lazy_chunk_reload_vetoed outcome=never-landed, after which the raw error files a crash and the surface stays dead until quit. - Prefer the browser-initiated app:reload bridge, which no navigation guard can veto; keep the in-document reload as the paired-web/declined fallback. - app:reload now reports whether it issued the reload so the dashboard pop-out (a sender the handler declines) falls back instead of waiting out the grace. - Allow will-navigate to the WebContents' own document; other targets, including file:///etc/passwd, stay blocked. - Pass reloadKey at the App and right-sidebar lazy call sites so the recovery breadcrumbs name the failing chunk instead of reporting 'unknown'.
This commit is contained in:
@@ -286,16 +286,19 @@ function registerAppReloadHandler(
|
||||
activeAppReloadHandlerToken = handlerToken
|
||||
const mainWebContents = mainWindow.webContents
|
||||
ipcMain.removeHandler('app:reload')
|
||||
ipcMain.handle('app:reload', (event) => {
|
||||
// Returns whether the reload was actually issued so a declined sender (the dashboard
|
||||
// pop-out, a stale window) can fall back instead of waiting on a reload that never comes.
|
||||
ipcMain.handle('app:reload', (event): boolean => {
|
||||
if (
|
||||
mainWindow.isDestroyed() ||
|
||||
mainWebContents.isDestroyed() ||
|
||||
event.sender !== mainWebContents
|
||||
) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
onBeforeRendererReload?.({ webContentsId: mainWebContents.id, ignoreCache: false })
|
||||
mainWebContents.reload()
|
||||
return true
|
||||
})
|
||||
mainWindow.on('closed', () => {
|
||||
if (activeAppReloadHandlerToken !== handlerToken) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WebContents } from 'electron'
|
||||
import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation'
|
||||
|
||||
const openExternalMock = vi.hoisted(() => vi.fn())
|
||||
const isMock = vi.hoisted(() => ({ dev: false }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
shell: { openExternal: openExternalMock }
|
||||
}))
|
||||
|
||||
vi.mock('@electron-toolkit/utils', () => ({
|
||||
is: isMock
|
||||
}))
|
||||
|
||||
const PACKAGED_DOCUMENT_URL =
|
||||
'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/index.html'
|
||||
|
||||
type NavigationHandler = (event: { preventDefault: () => void }, url: string) => void
|
||||
|
||||
function installPolicy(currentUrl: string): NavigationHandler {
|
||||
const handlers: Record<string, NavigationHandler> = {}
|
||||
const contents = {
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
getURL: vi.fn(() => currentUrl),
|
||||
on: vi.fn((event: string, handler: NavigationHandler) => {
|
||||
handlers[event] = handler
|
||||
})
|
||||
}
|
||||
installPrivilegedWindowNavigationPolicy(contents as unknown as WebContents)
|
||||
return handlers['will-navigate']
|
||||
}
|
||||
|
||||
describe('installPrivilegedWindowNavigationPolicy', () => {
|
||||
beforeEach(() => {
|
||||
openExternalMock.mockClear()
|
||||
isMock.dev = false
|
||||
// Why: a dev shell exports this; stub (not mutate) so the value is restored for other suites.
|
||||
vi.stubEnv('ELECTRON_RENDERER_URL', undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('lets the renderer reload its own packaged document', () => {
|
||||
const willNavigate = installPolicy(PACKAGED_DOCUMENT_URL)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
willNavigate({ preventDefault }, PACKAGED_DOCUMENT_URL)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(openExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets the dashboard pop-out reload its own query-bearing document', () => {
|
||||
const popoutUrl =
|
||||
'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/popout.html?view=kanban'
|
||||
const willNavigate = installPolicy(popoutUrl)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
willNavigate({ preventDefault }, popoutUrl)
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still blocks a different local file from the packaged document', () => {
|
||||
const willNavigate = installPolicy(PACKAGED_DOCUMENT_URL)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
willNavigate({ preventDefault }, 'file:///etc/passwd')
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(openExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still blocks remote navigation and hands it to the OS browser', () => {
|
||||
const willNavigate = installPolicy(PACKAGED_DOCUMENT_URL)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
willNavigate({ preventDefault }, 'https://example.com/docs')
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(openExternalMock).toHaveBeenCalledWith('https://example.com/docs')
|
||||
})
|
||||
|
||||
it('lets the dev server reload its own origin', () => {
|
||||
isMock.dev = true
|
||||
vi.stubEnv('ELECTRON_RENDERER_URL', 'http://localhost:5173')
|
||||
const willNavigate = installPolicy('http://localhost:5173/index.html')
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
willNavigate({ preventDefault }, 'http://localhost:5173/login')
|
||||
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
expect(openExternalMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still blocks a sibling packaged document', () => {
|
||||
const willNavigate = installPolicy(PACKAGED_DOCUMENT_URL)
|
||||
const preventDefault = vi.fn()
|
||||
|
||||
willNavigate(
|
||||
{ preventDefault },
|
||||
'file:///Applications/Orca.app/Contents/Resources/app.asar/out/renderer/popout.html'
|
||||
)
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,29 @@ import { shell, type WebContents } from 'electron'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { normalizeExternalBrowserUrl } from '../../shared/browser-url'
|
||||
|
||||
/**
|
||||
* A reload is a renderer-initiated navigation to the document already loaded here, so the
|
||||
* blanket guard below strands recovery reloads (lazy-chunk recovery, GH auth retry). Re-entering
|
||||
* the same URL grants no privilege the live document does not already hold.
|
||||
*/
|
||||
function isOwnDocumentNavigation(contents: WebContents, url: string): boolean {
|
||||
try {
|
||||
const current = contents.getURL()
|
||||
if (!current) {
|
||||
return false
|
||||
}
|
||||
const target = new URL(url)
|
||||
const loaded = new URL(current)
|
||||
// Hash-only changes never reach will-navigate; ignore the fragment so they cannot differ.
|
||||
target.hash = ''
|
||||
loaded.hash = ''
|
||||
return target.href === loaded.href
|
||||
} catch {
|
||||
// A destroyed WebContents or malformed URL is never a self-navigation.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep remote documents from inheriting an Orca window's privileged preload. */
|
||||
export function installPrivilegedWindowNavigationPolicy(contents: WebContents): void {
|
||||
contents.setWindowOpenHandler(({ url }) => {
|
||||
@@ -13,6 +36,9 @@ export function installPrivilegedWindowNavigationPolicy(contents: WebContents):
|
||||
})
|
||||
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
if (isOwnDocumentNavigation(contents, url)) {
|
||||
return
|
||||
}
|
||||
const externalUrl = normalizeExternalBrowserUrl(url)
|
||||
if (externalUrl) {
|
||||
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
|
||||
|
||||
@@ -983,8 +983,9 @@ export type AppApi = {
|
||||
* sessions survive and can reattach after the new process starts. */
|
||||
restart: () => Promise<void>
|
||||
/** Reloads the current app renderer through main so expected renderer
|
||||
* teardown can be classified before Electron emits process-gone events. */
|
||||
reload: () => Promise<void>
|
||||
* teardown can be classified before Electron emits process-gone events.
|
||||
* Resolves false when main declines the sender (e.g. the dashboard pop-out). */
|
||||
reload: () => Promise<boolean>
|
||||
/** Stages the renderer's final state synchronously before unload. */
|
||||
stageBeforeUnloadSync: (args: {
|
||||
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
|
||||
|
||||
@@ -500,7 +500,7 @@ const api = {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
reload: (): Promise<void> => ipcRenderer.invoke('app:reload'),
|
||||
reload: (): Promise<boolean> => ipcRenderer.invoke('app:reload'),
|
||||
stageBeforeUnloadSync: (args: Parameters<PreloadApi['app']['stageBeforeUnloadSync']>[0]) => {
|
||||
const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as {
|
||||
ok?: unknown
|
||||
|
||||
+94
-48
@@ -326,71 +326,117 @@ function WindowControls(): React.JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
const Landing = lazy(() => import('./components/Landing'))
|
||||
// reloadKey names the failing chunk on the recovery breadcrumbs and LazyChunkLoadError.
|
||||
const Landing = lazy(() => import('./components/Landing'), { reloadKey: 'landing' })
|
||||
const WorktreeCreationPanel = lazy(
|
||||
() => import('./components/worktree-creation/WorktreeCreationPanel')
|
||||
() => import('./components/worktree-creation/WorktreeCreationPanel'),
|
||||
{ reloadKey: 'worktree-creation-panel' }
|
||||
)
|
||||
const TaskPage = lazy(() => import('./components/TaskPage'))
|
||||
const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage'))
|
||||
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'))
|
||||
const Settings = lazy(() => import('./components/settings/Settings'))
|
||||
const SkillsPage = lazy(() => import('./components/skills/SkillsPage'))
|
||||
const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage'))
|
||||
const MobilePage = lazy(() => import('./components/mobile/MobilePage'))
|
||||
const QuickOpen = lazy(() => import('./components/QuickOpen'))
|
||||
const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette'))
|
||||
const TaskPage = lazy(() => import('./components/TaskPage'), { reloadKey: 'task-page' })
|
||||
const AutomationsPage = lazy(() => import('./components/automations/AutomationsPage'), {
|
||||
reloadKey: 'automations-page'
|
||||
})
|
||||
const ActivityPrototypePage = lazy(() => import('./components/activity/ActivityPrototypePage'), {
|
||||
reloadKey: 'activity-prototype-page'
|
||||
})
|
||||
const Settings = lazy(() => import('./components/settings/Settings'), { reloadKey: 'settings' })
|
||||
const SkillsPage = lazy(() => import('./components/skills/SkillsPage'), {
|
||||
reloadKey: 'skills-page'
|
||||
})
|
||||
const WorkspaceSpacePage = lazy(() => import('./components/workspace-space/WorkspaceSpacePage'), {
|
||||
reloadKey: 'workspace-space-page'
|
||||
})
|
||||
const MobilePage = lazy(() => import('./components/mobile/MobilePage'), {
|
||||
reloadKey: 'mobile-page'
|
||||
})
|
||||
const QuickOpen = lazy(() => import('./components/QuickOpen'), { reloadKey: 'quick-open' })
|
||||
const WorktreeJumpPalette = lazy(() => import('./components/WorktreeJumpPalette'), {
|
||||
reloadKey: 'worktree-jump-palette'
|
||||
})
|
||||
const WorkspaceCleanupDialog = lazy(
|
||||
() => import('./components/workspace-cleanup/WorkspaceCleanupDialog')
|
||||
() => import('./components/workspace-cleanup/WorkspaceCleanupDialog'),
|
||||
{ reloadKey: 'workspace-cleanup-dialog' }
|
||||
)
|
||||
const Terminal = lazy(() => import('./components/Terminal'))
|
||||
const StatusBar = lazy(() =>
|
||||
import('./components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar }))
|
||||
const Terminal = lazy(() => import('./components/Terminal'), { reloadKey: 'terminal' })
|
||||
const StatusBar = lazy(
|
||||
() =>
|
||||
import('./components/status-bar/StatusBar').then((module) => ({ default: module.StatusBar })),
|
||||
{ reloadKey: 'status-bar' }
|
||||
)
|
||||
const SetupGuideModal = lazy(() => import('./components/setup-guide/SetupGuideModal'))
|
||||
const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal'))
|
||||
const FeatureTipsModal = lazy(() => import('./components/feature-tips/FeatureTipsModal'))
|
||||
const AddRepoDialog = lazy(() => import('./components/sidebar/AddRepoDialog'))
|
||||
const NonGitFolderDialog = lazy(() => import('./components/sidebar/NonGitFolderDialog'))
|
||||
const SetupGuideModal = lazy(() => import('./components/setup-guide/SetupGuideModal'), {
|
||||
reloadKey: 'setup-guide-modal'
|
||||
})
|
||||
const FeatureWallModal = lazy(() => import('./components/feature-wall/FeatureWallModal'), {
|
||||
reloadKey: 'feature-wall-modal'
|
||||
})
|
||||
const FeatureTipsModal = lazy(() => import('./components/feature-tips/FeatureTipsModal'), {
|
||||
reloadKey: 'feature-tips-modal'
|
||||
})
|
||||
const AddRepoDialog = lazy(() => import('./components/sidebar/AddRepoDialog'), {
|
||||
reloadKey: 'add-repo-dialog'
|
||||
})
|
||||
const NonGitFolderDialog = lazy(() => import('./components/sidebar/NonGitFolderDialog'), {
|
||||
reloadKey: 'non-git-folder-dialog'
|
||||
})
|
||||
const AddProjectFromFolderDialog = lazy(
|
||||
() => import('./components/sidebar/AddProjectFromFolderDialog')
|
||||
() => import('./components/sidebar/AddProjectFromFolderDialog'),
|
||||
{ reloadKey: 'add-project-from-folder-dialog' }
|
||||
)
|
||||
const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog'))
|
||||
const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog'))
|
||||
const DictationController = lazy(() =>
|
||||
import('./components/dictation/DictationController').then((module) => ({
|
||||
default: module.DictationController
|
||||
}))
|
||||
const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog'), {
|
||||
reloadKey: 'project-added-dialog'
|
||||
})
|
||||
const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog'), {
|
||||
reloadKey: 'delete-worktree-dialog'
|
||||
})
|
||||
const DictationController = lazy(
|
||||
() =>
|
||||
import('./components/dictation/DictationController').then((module) => ({
|
||||
default: module.DictationController
|
||||
})),
|
||||
{ reloadKey: 'dictation-controller' }
|
||||
)
|
||||
const SshPassphraseDialog = lazy(() =>
|
||||
import('./components/settings/SshPassphraseDialog').then((module) => ({
|
||||
default: module.SshPassphraseDialog
|
||||
}))
|
||||
const SshPassphraseDialog = lazy(
|
||||
() =>
|
||||
import('./components/settings/SshPassphraseDialog').then((module) => ({
|
||||
default: module.SshPassphraseDialog
|
||||
})),
|
||||
{ reloadKey: 'ssh-passphrase-dialog' }
|
||||
)
|
||||
const UpdateCard = lazy(() =>
|
||||
import('./components/UpdateCard').then((module) => ({ default: module.UpdateCard }))
|
||||
const UpdateCard = lazy(
|
||||
() => import('./components/UpdateCard').then((module) => ({ default: module.UpdateCard })),
|
||||
{ reloadKey: 'update-card' }
|
||||
)
|
||||
const RemoteServerUpdateDialog = lazy(
|
||||
() => import('./components/settings/RemoteServerUpdateDialog')
|
||||
() => import('./components/settings/RemoteServerUpdateDialog'),
|
||||
{ reloadKey: 'remote-server-update-dialog' }
|
||||
)
|
||||
const ContextualTourOverlay = lazy(() =>
|
||||
import('./components/contextual-tours/ContextualTourOverlay').then((module) => ({
|
||||
default: module.ContextualTourOverlay
|
||||
}))
|
||||
const ContextualTourOverlay = lazy(
|
||||
() =>
|
||||
import('./components/contextual-tours/ContextualTourOverlay').then((module) => ({
|
||||
default: module.ContextualTourOverlay
|
||||
})),
|
||||
{ reloadKey: 'contextual-tour-overlay' }
|
||||
)
|
||||
const SetupGuideTelemetryObserver = lazy(() =>
|
||||
import('./components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({
|
||||
default: module.SetupGuideTelemetryObserver
|
||||
}))
|
||||
const SetupGuideTelemetryObserver = lazy(
|
||||
() =>
|
||||
import('./components/setup-guide/SetupGuideTelemetryObserver').then((module) => ({
|
||||
default: module.SetupGuideTelemetryObserver
|
||||
})),
|
||||
{ reloadKey: 'setup-guide-telemetry-observer' }
|
||||
)
|
||||
const FloatingTerminalPanel = lazy(() =>
|
||||
import('./components/floating-terminal/FloatingTerminalPanel').then((module) => ({
|
||||
default: module.FloatingTerminalPanel
|
||||
}))
|
||||
const FloatingTerminalPanel = lazy(
|
||||
() =>
|
||||
import('./components/floating-terminal/FloatingTerminalPanel').then((module) => ({
|
||||
default: module.FloatingTerminalPanel
|
||||
})),
|
||||
{ reloadKey: 'floating-terminal-panel' }
|
||||
)
|
||||
// Why: lazy so the WebP asset + overlay module aren't fetched unless the experimental flag is on.
|
||||
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'))
|
||||
const PetOverlay = lazy(() => import('./components/pet/PetOverlay'), { reloadKey: 'pet-overlay' })
|
||||
// Why: lazy so onboarding's step modules + assets aren't fetched for users past first-launch.
|
||||
const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow'))
|
||||
const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow'), {
|
||||
reloadKey: 'onboarding-flow'
|
||||
})
|
||||
|
||||
function applyRemoteWorkspacePatchStatus(
|
||||
targetId: string,
|
||||
|
||||
@@ -305,7 +305,8 @@ describe('renderer startup runtime routing', () => {
|
||||
it('does not load the terminal workbench on the no-workspace landing path', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
|
||||
expect(source).toContain("const Terminal = lazy(() => import('./components/Terminal'))")
|
||||
// Trailing paren omitted so the assertion tolerates lazyWithRetry options (reloadKey).
|
||||
expect(source).toContain("const Terminal = lazy(() => import('./components/Terminal')")
|
||||
expect(source).not.toContain("from './components/Terminal'")
|
||||
expect(source).toContain('const hasMountedTerminalWorkbenchRef = useRef(false)')
|
||||
expect(source).toContain('hasMountedTerminalWorkbenchRef.current = true')
|
||||
@@ -337,10 +338,10 @@ describe('renderer startup runtime routing', () => {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(appSource).toContain("lazy(() => import('./components/sidebar/AddRepoDialog'))")
|
||||
expect(appSource).toContain("lazy(() => import('./components/sidebar/NonGitFolderDialog'))")
|
||||
expect(appSource).toContain("lazy(() => import('./components/sidebar/AddRepoDialog')")
|
||||
expect(appSource).toContain("lazy(() => import('./components/sidebar/NonGitFolderDialog')")
|
||||
expect(appSource).toContain("import('./components/sidebar/AddProjectFromFolderDialog')")
|
||||
expect(appSource).toContain("lazy(() => import('./components/sidebar/ProjectAddedDialog'))")
|
||||
expect(appSource).toContain("lazy(() => import('./components/sidebar/ProjectAddedDialog')")
|
||||
expect(appSource).toContain("activeModal === 'add-repo'")
|
||||
expect(appSource).toContain("activeModal === 'confirm-non-git-folder'")
|
||||
expect(appSource).toContain("activeModal === 'confirm-add-project-from-folder'")
|
||||
|
||||
@@ -3,14 +3,19 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry'
|
||||
import type { ActiveRightSidebarTab } from '@/store/slices/editor'
|
||||
import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest'
|
||||
|
||||
const FileExplorer = lazy(() => import('./FileExplorer'))
|
||||
const SourceControl = lazy(() => import('./SourceControl'))
|
||||
const ChecksPanel = lazy(() => import('./ChecksPanel'))
|
||||
const PortsPanel = lazy(() => import('./PortsPanel'))
|
||||
const AiVaultPanel = lazy(() => import('./AiVaultPanel'))
|
||||
const FolderWorkspaceWorktreesPanel = lazy(() => import('./FolderWorkspaceWorktreesPanel'))
|
||||
const FolderWorkspacePrChecksPanel = lazy(() => import('./FolderWorkspacePrChecksPanel'))
|
||||
const PluginPanel = lazy(() => import('./PluginPanel'))
|
||||
// reloadKey names the failing chunk on the recovery breadcrumbs and LazyChunkLoadError.
|
||||
const FileExplorer = lazy(() => import('./FileExplorer'), { reloadKey: 'file-explorer' })
|
||||
const SourceControl = lazy(() => import('./SourceControl'), { reloadKey: 'source-control' })
|
||||
const ChecksPanel = lazy(() => import('./ChecksPanel'), { reloadKey: 'checks-panel' })
|
||||
const PortsPanel = lazy(() => import('./PortsPanel'), { reloadKey: 'ports-panel' })
|
||||
const AiVaultPanel = lazy(() => import('./AiVaultPanel'), { reloadKey: 'ai-vault-panel' })
|
||||
const FolderWorkspaceWorktreesPanel = lazy(() => import('./FolderWorkspaceWorktreesPanel'), {
|
||||
reloadKey: 'folder-workspace-worktrees-panel'
|
||||
})
|
||||
const FolderWorkspacePrChecksPanel = lazy(() => import('./FolderWorkspacePrChecksPanel'), {
|
||||
reloadKey: 'folder-workspace-pr-checks-panel'
|
||||
})
|
||||
const PluginPanel = lazy(() => import('./PluginPanel'), { reloadKey: 'plugin-panel' })
|
||||
|
||||
type RightSidebarPanelContentProps = {
|
||||
effectiveTab: ActiveRightSidebarTab
|
||||
|
||||
@@ -3,9 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
|
||||
import { requestLazyChunkRecoveryReload } from './lazy-chunk-recovery-reload'
|
||||
|
||||
type HostReload = () => Promise<boolean>
|
||||
|
||||
function installHostBridge(reload: HostReload): void {
|
||||
;(window as unknown as { api: unknown }).api = { app: { reload } }
|
||||
}
|
||||
|
||||
describe('requestLazyChunkRecoveryReload', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete (window as unknown as { api?: unknown }).api
|
||||
})
|
||||
|
||||
it('refuses the reload when the staged checkpoint never reaches disk', async () => {
|
||||
@@ -36,4 +43,49 @@ describe('requestLazyChunkRecoveryReload', () => {
|
||||
|
||||
expect(order).toEqual(['flushed', 'reload'])
|
||||
})
|
||||
|
||||
it('asks the host for a browser-initiated reload instead of navigating in-document', async () => {
|
||||
const locationReload = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined)
|
||||
const hostReload = vi.fn(async () => {
|
||||
// A real landed reload destroys the document; veto so the wait settles.
|
||||
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
|
||||
return true
|
||||
})
|
||||
installHostBridge(hostReload)
|
||||
|
||||
await expect(requestLazyChunkRecoveryReload(window, async () => undefined)).resolves.toBe(
|
||||
'unload-vetoed'
|
||||
)
|
||||
|
||||
expect(hostReload).toHaveBeenCalledTimes(1)
|
||||
expect(locationReload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back in-document when the host declines the sender', async () => {
|
||||
const locationReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {
|
||||
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
|
||||
})
|
||||
const hostReload = vi.fn(async () => false)
|
||||
installHostBridge(hostReload)
|
||||
|
||||
await expect(requestLazyChunkRecoveryReload(window, async () => undefined)).resolves.toBe(
|
||||
'unload-vetoed'
|
||||
)
|
||||
|
||||
expect(hostReload).toHaveBeenCalledTimes(1)
|
||||
expect(locationReload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back in-document when the host reload rejects', async () => {
|
||||
const locationReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {
|
||||
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
|
||||
})
|
||||
installHostBridge(() => Promise.reject(new Error('no host bridge')))
|
||||
|
||||
await expect(requestLazyChunkRecoveryReload(window, async () => undefined)).resolves.toBe(
|
||||
'unload-vetoed'
|
||||
)
|
||||
|
||||
expect(locationReload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,24 @@ function waitForRefusedNavigation(win: Window): RefusedNavigationWait {
|
||||
return { outcome, cancel }
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser-initiated reload cannot be vetoed by the main-process navigation guard, unlike
|
||||
* `location.reload()`. Resolves false when there is no host bridge or main declined this
|
||||
* sender (the dashboard pop-out), i.e. the in-document reload is still required.
|
||||
*/
|
||||
async function requestHostReload(settled: Promise<unknown>): Promise<boolean> {
|
||||
const hostReload = window.api?.app?.reload
|
||||
if (typeof hostReload !== 'function') {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
// Race the refusal wait: a landed reload can tear this document down before the reply.
|
||||
return await Promise.race([hostReload(), settled.then(() => true)])
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves only if the navigation is refused; a landed reload destroys this document. */
|
||||
export async function requestLazyChunkRecoveryReload(
|
||||
win: Window,
|
||||
@@ -69,7 +87,9 @@ export async function requestLazyChunkRecoveryReload(
|
||||
try {
|
||||
const refused = waitForRefusedNavigation(win)
|
||||
cancelRefusalWait = refused.cancel
|
||||
win.location.reload()
|
||||
if (!(await requestHostReload(refused.outcome))) {
|
||||
win.location.reload()
|
||||
}
|
||||
return await refused.outcome
|
||||
} catch {
|
||||
return 'request-failed'
|
||||
|
||||
@@ -547,7 +547,11 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
getFeatureWallAssetBaseUrl: () => Promise.resolve('/'),
|
||||
relaunch: () => Promise.resolve(window.location.reload()),
|
||||
restart: () => Promise.resolve(window.location.reload()),
|
||||
reload: () => Promise.resolve(window.location.reload()),
|
||||
// The web host reloads in-document itself, so report it as issued.
|
||||
reload: () => {
|
||||
window.location.reload()
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
stageBeforeUnloadSync: ({ sessions, ui }) => {
|
||||
// Why: beforeunload cannot await the paired runtime, so the web adapter
|
||||
// guarantees immediate browser-local durability for the final snapshot.
|
||||
|
||||
Reference in New Issue
Block a user