diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0e1632ecfbe..20bed570fe0 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -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) { diff --git a/src/main/window/privileged-window-navigation.test.ts b/src/main/window/privileged-window-navigation.test.ts new file mode 100644 index 00000000000..319eb49b3e8 --- /dev/null +++ b/src/main/window/privileged-window-navigation.test.ts @@ -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 = {} + 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) + }) +}) diff --git a/src/main/window/privileged-window-navigation.ts b/src/main/window/privileged-window-navigation.ts index a5afbbe0a98..23d5e72b7c4 100644 --- a/src/main/window/privileged-window-navigation.ts +++ b/src/main/window/privileged-window-navigation.ts @@ -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) { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 859be396e2e..8ba0e9f4bf5 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -983,8 +983,9 @@ export type AppApi = { * sessions survive and can reattach after the new process starts. */ restart: () => Promise /** Reloads the current app renderer through main so expected renderer - * teardown can be classified before Electron emits process-gone events. */ - reload: () => Promise + * 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 /** Stages the renderer's final state synchronously before unload. */ stageBeforeUnloadSync: (args: { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] diff --git a/src/preload/index.ts b/src/preload/index.ts index de829aafe37..6738d69468c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -500,7 +500,7 @@ const api = { throw error } }, - reload: (): Promise => ipcRenderer.invoke('app:reload'), + reload: (): Promise => ipcRenderer.invoke('app:reload'), stageBeforeUnloadSync: (args: Parameters[0]) => { const result = ipcRenderer.sendSync('app:stage-before-unload-sync', args) as { ok?: unknown diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9874acecd66..62529f86f68 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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, diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 80bb0037eac..a23ba5a398d 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -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'") diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx index 8da2783e309..ffa9d2f5b2d 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx @@ -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 diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts index 9cdf4fa728d..cb95eeae4d2 100644 --- a/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.test.ts @@ -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 + +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) + }) }) diff --git a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts index 64e78fc283a..5c239a9cf43 100644 --- a/src/renderer/src/lib/lazy-chunk-recovery-reload.ts +++ b/src/renderer/src/lib/lazy-chunk-recovery-reload.ts @@ -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): Promise { + 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' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 6199c35b831..1c144c17207 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -547,7 +547,11 @@ function createWebPreloadApi(): Partial { 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.