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:
Neil
2026-08-03 23:42:49 -07:00
parent dbbeca0916
commit 7b3579ba60
11 changed files with 335 additions and 67 deletions
@@ -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) {
+3 -2
View File
@@ -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 }[]
+1 -1
View File
@@ -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
View File
@@ -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,
+5 -4
View File
@@ -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'
+5 -1
View File
@@ -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.