diff --git a/config/react-compiler-bailout-baseline.txt b/config/react-compiler-bailout-baseline.txt index 62b0b0c36d6..06b3aed8c79 100644 --- a/config/react-compiler-bailout-baseline.txt +++ b/config/react-compiler-bailout-baseline.txt @@ -65,7 +65,6 @@ 2 src/renderer/src/components/browser-pane/passwords/BrowserPasswordFillDropdown.tsx 1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts 4 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts -1 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts 2 src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-wheel.ts 3 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-directory-access.tsx 1 src/renderer/src/components/browser-pane/workspace-doc/doc-preview-overflow-menu.tsx diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx index 471f1f4725c..e223b76fedf 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-workspace-pane.adoption-chrome.test.tsx @@ -58,7 +58,10 @@ vi.mock('../stream-remote/use-remote-browser-page-lifecycle', () => ({ useRemoteBrowserPageLifecycle: () => ({ lifecycle: { session: { - ensureRemotePage: mocks.ensureRemotePage, + resolveNavigationPage: mocks.ensureRemotePage, + invalidateTabInfoReads: vi.fn(), + setPageLoading: vi.fn(), + refreshTabInfo: vi.fn(async () => null), scheduleTabInfoRefresh: vi.fn() }, tokens: {} @@ -575,7 +578,6 @@ describe.each([{ tree: 'under StrictMode', strict: true }])( adoptOntoServer() }) - expect(mocks.ensureRemotePage).toHaveBeenCalledTimes(1) expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( expect.objectContaining({ environmentId: ENVIRONMENT_ID }), 'browser.goto', diff --git a/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts b/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts index 6bed75438f0..f9be89d6fc1 100644 --- a/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts +++ b/src/renderer/src/components/browser-pane/describe-page/browser-page-types.ts @@ -8,6 +8,7 @@ import type { } from '../../../../../shared/browser/element-grab/browser-grab-types' import type { BrowserContextMenuRequestedEvent } from '../../../../../shared/browser/guest-lifecycle/browser-guest-events' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' +import type { BrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership' import type { GrabModeHook } from '../annotate/useGrabMode' import type { BrowserOverlayViewport } from './browser-annotation-geometry' @@ -18,6 +19,12 @@ export type BrowserTabPageState = Partial< > > +export type BrowserPageStateWriter = ( + pageId: string, + updates: BrowserTabPageState, + loadingOwner?: BrowserPageLoadingOwner +) => void + export type BrowserPageUrlSetter = ( tabId: string, url: string, diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts index 357ca71570c..dbc80cd7d9a 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-input-model.ts @@ -57,7 +57,7 @@ export const WHEEL_DELTA_LINE = 1 export const WHEEL_DELTA_PAGE = 2 export type RemoteBrowserStreamBridge = { - applyTabInfo: (tab: Pick) => void + applyTabInfo: (tab: BrowserTabInfo) => void clearFrame: () => void handleFrameBytes: (token: RemoteBrowserStreamToken, bytes: Uint8Array) => void closeMissingRemotePage: (remotePageId: string | null) => void diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-loading.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-loading.test.ts new file mode 100644 index 00000000000..ae01865cb02 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-loading.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment happy-dom +import { act, cleanup } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { recordBrowserInitializationReceipt } from '@/runtime/web-session-tabs-sync/browser-initialization-presentation' +import type { RuntimeMobileSessionTabsResult } from '../../../../../shared/runtime-types' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts' +import { + currentPage, + deferred, + failedTab, + renderNavigation, + page +} from './remote-browser-page-navigation.test-fixture' + +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() })) +vi.mock('@/runtime/browser/client-hosting/browser-client-host-identity', () => ({ + readBrowserClientHostId: () => 'loading-client' +})) +beforeEach(() => vi.mocked(callRuntimeRpc).mockReset()) +afterEach(cleanup) + +function applyNativeLoading(loading: boolean, pendingOnClient = false): void { + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: 'worktree-a', + publicationEpoch: 'loading-test', + snapshotVersion: pendingOnClient ? 2 : 1, + activeTabType: 'browser', + activeTabId: 'host-tab', + activeGroupId: 'host-group', + tabs: [ + { + type: 'browser', + id: 'host-tab', + browserWorkspaceId: 'workspace-a', + browserPageId: 'remote-page-a', + url: 'about:blank', + title: 'New Tab', + loading, + canGoBack: false, + canGoForward: false, + isActive: true, + loadError: null, + certificateFailure: null, + ...(pendingOnClient + ? { + initializationState: 'pending', + placement: { + kind: 'client', + browserHostClientId: 'loading-client', + browserHostGeneration: 1, + pageHostGeneration: 0 + } + } + : {}) + } + ] + } + if (pendingOnClient) { + recordBrowserInitializationReceipt('environment-a', snapshot, 2) + } + useAppStore.setState((state) => + applyWebSessionTabsSnapshot(state, snapshot, 'environment-a', 100) + ) +} + +describe('remote command loading ownership', () => { + it.each([ + ['synthetic', true, false], + ['explicit', true, true], + ['native', true, true], + ['native', false, true] + ] as const)( + 'settles only %s loading after local updates=%s', + async (owner, edit, expectedLoading) => { + const { result, busy } = renderNavigation(1, 'mailto:help@example.test') + act(() => applyNativeLoading(false)) + const read = deferred<{ tab: BrowserTabInfo }>() + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => { + if (method === 'browser.reload') { + return { url: 'about:blank', title: 'New Tab' } + } + if (method === 'browser.tabShow') { + return read.promise + } + return undefined + }) + let command!: Promise + await act(async () => { + command = result.current.runRemoteNavigation('browser.reload') + }) + expect(currentPage().loading).toBe(true) + const before = useAppStore.getState() + const changed = vi.fn() + const unsubscribe = useAppStore.subscribe(changed) + act(() => { + if (owner === 'native') { + applyNativeLoading(true) + } + if (owner === 'explicit') { + useAppStore.getState().updateBrowserPageState('page-a', { loading: true }) + } + }) + unsubscribe() + expect(useAppStore.getState().browserPagesByWorkspace).toBe(before.browserPagesByWorkspace) + expect(useAppStore.getState().browserTabsByWorktree).toBe(before.browserTabsByWorktree) + expect(changed).not.toHaveBeenCalled() + if (edit) { + act(() => { + result.current.submitAddressBar() + useAppStore.getState().setBrowserPageViewportPreset('page-a', 'mobile-s') + }) + expect(currentPage().loadError).toMatchObject({ + code: 0, + validatedUrl: 'mailto:help@example.test' + }) + } + await act(async () => { + read.resolve({ tab: { ...failedTab('stale'), title: 'Stale result' } }) + await command + }) + expect(currentPage().loading).toBe(expectedLoading) + expect(currentPage().title).toBe('New Tab') + expect(currentPage().url).toBe('about:blank') + expect(busy).toHaveBeenLastCalledWith(false) + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + if (edit) { + expect(currentPage().loadError).toMatchObject({ + code: 0, + validatedUrl: 'mailto:help@example.test' + }) + expect(currentPage().viewportPresetId).toBe('mobile-s') + } + } + ) + + it('keeps initialization loading when an older remote command retires', async () => { + const { session, tokens } = renderNavigation() + act(() => applyNativeLoading(false)) + const token = tokens.createOperationToken('remote-page-a')! + act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState)) + const read = deferred<{ tab: BrowserTabInfo }>() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(read.promise) + const pending = session.refreshTabInfo(token) + act(() => applyNativeLoading(true, true)) + await act(async () => { + read.resolve({ tab: failedTab('stale') }) + expect(await pending).toBeNull() + session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState) + }) + expect(currentPage().loading).toBe(true) + expect(currentPage().loadError).toBeNull() + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + }) + + it('discharges synthetic loading across external ABA without admitting the old read', async () => { + const { session, tokens } = renderNavigation() + const token = tokens.createOperationToken('remote-page-a')! + act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState)) + const read = deferred<{ tab: BrowserTabInfo }>() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(read.promise) + const pending = session.refreshTabInfo(token) + act(() => { + useAppStore.getState().updateBrowserPageState('page-a', { loadError: failedTab().loadError }) + useAppStore.getState().updateBrowserPageState('page-a', { loadError: null }) + session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState) + }) + await act(async () => { + read.resolve({ tab: failedTab('stale') }) + expect(await pending).toBeNull() + }) + expect(currentPage().loading).toBe(false) + expect(currentPage().loadError).toBeNull() + expect(currentPage().url).toBe('about:blank') + }) + + it('does not claim an explicit equal loading write from a synchronous store subscriber', () => { + const { session, tokens } = renderNavigation() + const token = tokens.createOperationToken('remote-page-a')! + const unsubscribe = useAppStore.subscribe(() => { + useAppStore.getState().updateBrowserPageState('page-a', { loading: true }) + }) + act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState)) + unsubscribe() + act(() => session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState)) + expect(currentPage().loading).toBe(true) + }) + + it.each([100, 1000])('native takeover visits no unrelated records at N=%s', async (count) => { + const { session, tokens } = renderNavigation() + act(() => applyNativeLoading(false)) + const token = tokens.createOperationToken('remote-page-a')! + act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState)) + const read = deferred<{ tab: BrowserTabInfo }>() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(read.promise) + const pending = session.refreshTabInfo(token) + const unrelated = Object.fromEntries( + Array.from({ length: count }, (_, index) => { + const workspaceId = `unrelated-${index}` + return [workspaceId, [{ ...page(`other-${index}`), workspaceId, worktreeId: 'other' }]] + }) + ) + let enumerations = 0 + let unrelatedReads = 0 + const pages = new Proxy( + { ...useAppStore.getState().browserPagesByWorkspace, ...unrelated }, + { + ownKeys(target) { + enumerations += 1 + return Reflect.ownKeys(target) + }, + get(target, key, receiver) { + if (typeof key === 'string' && key.startsWith('unrelated-')) { + unrelatedReads += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy must forward record access unchanged while counting unrelated lookups. + return Reflect.get(target, key, receiver) + } + } + ) + useAppStore.setState({ browserPagesByWorkspace: pages }) + const changed = vi.fn() + const unsubscribe = useAppStore.subscribe(changed) + act(() => applyNativeLoading(true)) + unsubscribe() + expect(enumerations).toBe(0) + expect(unrelatedReads).toBe(0) + expect(useAppStore.getState().browserPagesByWorkspace).toBe(pages) + expect(changed).not.toHaveBeenCalled() + await act(async () => { + read.resolve({ tab: failedTab('stale') }) + expect(await pending).toBeNull() + session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState) + }) + expect(currentPage().loading).toBe(true) + expect(currentPage().loadError).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-navigation-command.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-navigation-command.ts new file mode 100644 index 00000000000..55a2453f32f --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-navigation-command.ts @@ -0,0 +1,166 @@ +import type { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation' +import type { + BrowserBackResult, + BrowserGotoResult, + BrowserReloadResult, + BrowserTabInfo +} from '../../../../../shared/browser/runtime/runtime-browser-contracts' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import { redactKagiSessionToken } from '../../../../../shared/browser-url' +import { deferBrowserPageNavigation } from '../navigate/browser-page-deferred-navigation' +import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' + +type RemoteBrowserNavigationCommandOptions = Pick< + Parameters[0], + | 'browserTab' + | 'stagedPage' + | 'lifecycle' + | 'runtimeWorktree' + | 'runtimeTarget' + | 'createRemoteOperationToken' + | 'isCurrentRemoteOperationToken' + | 'closeMissingRemotePage' + | 'onUpdatePageState' + | 'setPaneNotice' + | 'setPaneBusy' +> & { + navigationSequence: { current: number } + applyRemoteTabMetadata: (tab: Pick) => void +} + +export async function runRemoteBrowserNavigationCommand( + { + browserTab, + stagedPage, + lifecycle, + runtimeWorktree, + runtimeTarget, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + onUpdatePageState, + setPaneNotice, + setPaneBusy, + navigationSequence, + applyRemoteTabMetadata + }: RemoteBrowserNavigationCommandOptions, + method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', + url?: string +): Promise { + const target = runtimeTarget() + if (!target) { + return + } + if (stagedPage) { + // No staged host page exists yet: park goto until adoption; history/reload cannot replay against an uncreated page. + if (method === 'browser.goto' && url) { + deferBrowserPageNavigation(browserTab.id, url) + onUpdatePageState(browserTab.id, { loading: true, loadError: null }) + } + return + } + const operationToken = createRemoteOperationToken() + if (!operationToken || !isCurrentRemoteOperationToken(operationToken)) { + return + } + const sequence = ++navigationSequence.current + lifecycle.session.invalidateTabInfoReads() + let pageId: string | null = null + const ownsAction = (): boolean => + sequence === navigationSequence.current && + isCurrentRemoteOperationToken({ ...operationToken, remotePageId: pageId }) + const reportFailure = (error: unknown): void => { + if (!ownsAction()) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + closeMissingRemotePage(pageId) + return + } + const message = error instanceof Error ? error.message : 'Remote browser command failed.' + setPaneNotice({ kind: 'consequence', text: message }) + // Why: goto results carry no load error, but the rejection keeps Electron's "ERR_NAME (-code)" text. + const netError = /\b(ERR_[A-Z_]+) \((-\d+)\)/.exec(message) + onUpdatePageState(browserTab.id, { + loading: false, + // Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does. + loadError: { + code: netError ? Number(netError[2]) : 0, + description: netError ? netError[1] : message, + validatedUrl: redactKagiSessionToken(url ?? browserTab.url) + } + }) + } + setPaneBusy(true) + setPaneNotice(null) + // A standing failure stays visible until the native outcome is observed. + lifecycle.session.setPageLoading(operationToken, true, onUpdatePageState) + try { + pageId = await lifecycle.session.resolveNavigationPage(operationToken) + if (!pageId || !ownsAction()) { + return + } + const pageToken = { ...operationToken, remotePageId: pageId } + const params = + method === 'browser.goto' + ? { worktree: runtimeWorktree, page: pageId, url: url ?? 'about:blank' } + : { worktree: runtimeWorktree, page: pageId } + let commandError: unknown + let result: BrowserGotoResult | BrowserBackResult | BrowserReloadResult | undefined + try { + result = await callRuntimeRpc( + target, + method, + params, + { timeoutMs: 30_000, suppressFeatureInteraction: true } + ) + } catch (error) { + if (isRemoteBrowserPageMissingError(error)) { + throw error + } + commandError = error + } + if (!ownsAction()) { + return + } + await lifecycle.session.refreshTabInfo(pageToken, { + onApplied: (tab) => { + if (!ownsAction() || !commandError || tab.loadError) { + return + } + if (tab.loadError === undefined) { + reportFailure(commandError) + } else { + setPaneNotice({ + kind: 'consequence', + text: + commandError instanceof Error + ? commandError.message + : 'Remote browser command failed.' + }) + } + }, + onError: (error) => { + if (!ownsAction()) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + reportFailure(error) + } else if (result) { + if (!result.url.startsWith('chrome-error:')) { + applyRemoteTabMetadata(result) + } + } else { + reportFailure(commandError ?? error) + } + } + }) + } catch (error) { + reportFailure(error) + } finally { + if (ownsAction()) { + lifecycle.session.setPageLoading(operationToken, false, onUpdatePageState) + setPaneBusy(false) + } + } +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-navigation.test-fixture.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-navigation.test-fixture.ts new file mode 100644 index 00000000000..bc069c7fe81 --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-navigation.test-fixture.ts @@ -0,0 +1,184 @@ +import { renderHook, type RenderHookResult } from '@testing-library/react' +import { expect, vi, type Mock } from 'vitest' +import { useAppStore } from '@/store' +import { annotateBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' +import { findPage } from '@/store/slices/browser/pages/browser-page-records' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import type { BrowserPage } from '../../../../../shared/browser-workspace-types' +import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts' +import { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation' +import { + readRemoteBrowserPage, + readRemoteBrowserPageObservation +} from './remote-browser-page-observation' +import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import type { RemoteBrowserScreencastSubscribe } from './remote-browser-screencast-subscription' + +type NavigationOptions = Parameters[0] +type NavigationFixture = RenderHookResult< + ReturnType, + unknown +> & { + lifecycle: RemoteBrowserStreamLifecycle + subscribe: Mock + session: RemoteBrowserStreamLifecycle['session'] + tokens: RemoteBrowserStreamLifecycle['tokens'] + identity: { mounted: boolean; active: boolean; environmentId: string } + busy: Mock + notice: Mock + closeMissing: Mock +} + +export function page(id = 'page-a'): BrowserPage { + return { + id, + workspaceId: 'workspace-a', + worktreeId: 'worktree-a', + url: 'about:blank', + title: 'New Tab', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } +} + +export function failedTab(challengeId = 'challenge-x'): BrowserTabInfo { + return { + browserPageId: 'remote-page-a', + index: 0, + active: true, + url: 'https://selfsigned.test/', + title: 'TLS', + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://selfsigned.test/' + }, + certificateFailure: { + challengeId, + browserPageId: 'remote-page-a', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://selfsigned.test', + displayHost: 'selfsigned.test', + canProceed: true, + observedAt: 1 + } + } +} + +export function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { + resolve = done + reject = fail + }) + return { promise, resolve, reject } +} + +export function renderNavigation(count = 1, addressBarValue = 'about:blank'): NavigationFixture { + const pages = Array.from({ length: count }, (_, index) => + page(index ? `page-${index}` : 'page-a') + ) + const first = pages[0] + useAppStore.setState({ + browserPagesByWorkspace: { 'workspace-a': pages }, + browserTabsByWorktree: { + 'worktree-a': [ + { + ...first, + id: 'workspace-a', + activePageId: 'page-a', + pageIds: pages.map((entry) => entry.id) + } + ] + }, + unifiedTabsByWorktree: {}, + browserCertificateFailuresByPageId: {}, + browserUrlHistory: [], + remoteBrowserPageHandlesByPageId: annotateBrowserPageHandles( + { 'page-a': { environmentId: 'environment-a', remotePageId: 'remote-page-a' } }, + ['page-a'] + ) + }) + const identity = { mounted: true, active: true, environmentId: 'environment-a' } + const paneIdentity = { + isMounted: () => identity.mounted, + isActive: () => identity.active, + getTabId: () => 'page-a', + getEnvironmentId: () => identity.environmentId, + browserPageExists: (pageId) => + Boolean(readRemoteBrowserPage(useAppStore.getState(), 'workspace-a', pageId)) + } + let project: (tab: BrowserTabInfo) => void = () => {} + const subscribe = vi.fn(async () => ({ unsubscribe: vi.fn() })) + const lifecycle = new RemoteBrowserStreamLifecycle({ + identity: paneIdentity, + subscribeScreencast: subscribe, + askJavaScriptDialog: async () => null, + waitForViewportSize: async () => ({ width: 800, height: 600 }), + readViewportSize: () => ({ width: 800, height: 600 }), + syncViewport: async () => {}, + getDeviceScaleFactor: () => 1, + setStatus: vi.fn(), + clearFrame: vi.fn(), + handleFrameBytes: vi.fn(), + callRpc: callRuntimeRpc, + getWorktreeSelector: () => 'worktree-a', + getCurrentUrl: () => currentPage().url, + readCurrentObservation: () => + readRemoteBrowserPageObservation(useAppStore.getState(), 'workspace-a', 'page-a'), + readStoredHandle: () => ({ environmentId: 'environment-a', remotePageId: 'remote-page-a' }), + writeStoredHandle: vi.fn(), + removeStoredHandle: vi.fn(), + applyTabInfo: (tab) => project(tab), + closeMissingRemotePage: vi.fn() + }) + const { session, tokens } = lifecycle + tokens.setRemotePage('remote-page-a') + const closeMissing = vi.fn() + const busy = vi.fn() + const notice = vi.fn() + const hook = renderHook(() => + useRemoteBrowserPageNavigation({ + browserTab: first, + stagedPage: false, + addressBarValue, + setAddressBarValueFromPage: vi.fn(), + lifecycle, + runtimeWorktree: 'worktree-a', + runtimeTarget: () => ({ kind: 'environment', environmentId: identity.environmentId }), + createRemoteOperationToken: () => tokens.createOperationToken(), + isCurrentRemoteOperationToken: (token) => tokens.isCurrent(token), + closeMissingRemotePage: closeMissing, + onSetUrl: useAppStore.getState().setBrowserPageUrl, + onUpdatePageState: useAppStore.getState().updateBrowserPageState, + setPaneNotice: notice, + setPaneBusy: busy + }) + ) + project = (tab) => hook.result.current.applyRemoteTabInfo(tab) + return { ...hook, lifecycle, subscribe, session, tokens, identity, busy, notice, closeMissing } +} + +export function currentPage(): BrowserPage { + return findPage(useAppStore.getState().browserPagesByWorkspace, 'page-a')! +} + +export function expectPending(challengeId = 'challenge-x') { + expect(currentPage().url).toBe('https://selfsigned.test/') + expect(currentPage().loadError).toEqual({ + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://selfsigned.test/' + }) + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toMatchObject({ + challengeId, + browserPageId: 'remote-page-a', + canProceed: true + }) +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-observation.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-observation.ts new file mode 100644 index 00000000000..890bd6f42ca --- /dev/null +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-observation.ts @@ -0,0 +1,27 @@ +import type { + BrowserPage, + BrowserCertificateFailure +} from '../../../../../shared/browser-workspace-types' +import { readBrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership' + +type BrowserObservationState = { + browserPagesByWorkspace: Record + browserCertificateFailuresByPageId: Record +} + +export function readRemoteBrowserPage( + state: BrowserObservationState, + workspaceId: string, + pageId: string +): BrowserPage | undefined { + return state.browserPagesByWorkspace[workspaceId]?.find((page) => page.id === pageId) +} + +export function readRemoteBrowserPageObservation( + state: BrowserObservationState, + workspaceId: string, + pageId: string +): readonly unknown[] { + const page = readRemoteBrowserPage(state, workspaceId, pageId) + return [page, state.browserCertificateFailuresByPageId[pageId], readBrowserPageLoadingOwner(page)] +} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx index 893eda43de7..946c053e580 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -21,7 +21,7 @@ import { import type { BrowserChromeShortcutScope, BrowserPageUrlSetter, - BrowserTabPageState + BrowserPageStateWriter } from '../describe-page/browser-page-types' import type { RemoteBrowserPaneNotice } from './remote-browser-page-input-model' import { useRemoteBrowserPageLifecycle } from './use-remote-browser-page-lifecycle' @@ -57,10 +57,9 @@ export function RemoteBrowserPagePane({ worktreeId: string isActive: boolean chromeShortcutScope: BrowserChromeShortcutScope - onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onUpdatePageState: BrowserPageStateWriter onSetUrl: BrowserPageUrlSetter }): React.JSX.Element { - const activeRuntimeEnvironmentId = runtimeEnvironmentId const addressBarInputRef = useRef(null) const imageRef = useRef(null) const remoteViewportRef = useRef(null) @@ -94,16 +93,15 @@ export function RemoteBrowserPagePane({ // Why: runtimes predating browser.certificate-trust.v1 can't honor a proceed request, so hide "Proceed Anyway" until support is advertised. const [remoteCertificateTrustSupported, setRemoteCertificateTrustSupported] = useState(false) - const remoteCertificateEnvironmentId = remotePageHandle?.environmentId ?? null const certificateChallengeId = certificateFailure?.challengeId ?? null useEffect(() => { - if (!remoteCertificateEnvironmentId || !certificateChallengeId) { + if (!remotePageHandle?.environmentId || !certificateChallengeId) { setRemoteCertificateTrustSupported(false) return } let cancelled = false void runtimeEnvironmentSupportsCapability( - remoteCertificateEnvironmentId, + remotePageHandle?.environmentId, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY ) .then((supported) => { @@ -119,7 +117,7 @@ export function RemoteBrowserPagePane({ return () => { cancelled = true } - }, [remoteCertificateEnvironmentId, certificateChallengeId]) + }, [remotePageHandle?.environmentId, certificateChallengeId]) const { enqueueRemoteInput, @@ -154,7 +152,7 @@ export function RemoteBrowserPagePane({ } = useRemoteBrowserPageLifecycle({ browserTab, worktreeId, - activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: runtimeEnvironmentId, isActive, setPaneNotice, setPaneBusy, @@ -174,6 +172,7 @@ export function RemoteBrowserPagePane({ const { applyRemoteTabInfo, + prepareCertificateProceed, scheduleRemoteTabInfoRefresh, runRemoteNavigation, navigateToUrl, @@ -224,7 +223,7 @@ export function RemoteBrowserPagePane({ }, [hasStreamFrame]) const { reconnectRemoteStream } = useRemoteBrowserPageStream({ - activeRuntimeEnvironmentId, + activeRuntimeEnvironmentId: runtimeEnvironmentId, browserPageId: browserTab.id, isActive, lifecycle, @@ -405,7 +404,7 @@ export function RemoteBrowserPagePane({ remoteCertificateTrustSupported={remoteCertificateTrustSupported} certificateFailure={certificateFailure} remotePageHandle={remotePageHandle} - activeRuntimeEnvironmentId={activeRuntimeEnvironmentId} + activeRuntimeEnvironmentId={runtimeEnvironmentId} worktreeId={worktreeId} runtimeWorktree={runtimeWorktree} runtimeTarget={runtimeTarget} @@ -413,6 +412,7 @@ export function RemoteBrowserPagePane({ onGoto={(url) => runRemoteNavigation('browser.goto', url)} onNavigateToUrl={navigateToUrl} onReconnect={reconnectRemoteStream} + onBeforeProceedCertificate={prepareCertificateProceed} handleRemotePointerDown={handleRemotePointerDown} handleRemotePointerUp={handleRemotePointerUp} handleRemoteContextMenu={handleRemoteContextMenu} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.test.ts index edf85ba93ab..3c522a48d7c 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.test.ts @@ -53,6 +53,7 @@ function setup(args: { show: ShowReply; switchOn?: boolean; onShow?: () => void tokens, callRpc, getWorktreeSelector: () => 'id:wt-1', + readCurrentObservation: () => [], getCurrentUrl: () => 'about:blank', readStoredHandle: () => stored, writeStoredHandle: (handle) => { diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts index d4e9b579c57..a3fafbb93d2 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-session.ts @@ -1,4 +1,6 @@ import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' +import type { BrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership' +import type { BrowserPageStateWriter } from '../describe-page/browser-page-types' import '../../../../../shared/constants' import { ORCA_BROWSER_BLANK_URL } from '../../../../../shared/browser/browser-blank-page' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' @@ -26,20 +28,148 @@ export type RemoteBrowserPageSessionDeps = { callRpc: RemoteBrowserRpcCall getWorktreeSelector(): string getCurrentUrl(): string + readCurrentObservation(): readonly unknown[] readStoredHandle(): RemoteBrowserPageHandle | null writeStoredHandle(handle: RemoteBrowserPageHandle): void removeStoredHandle(remotePageId: string): void - applyTabInfo(tab: Pick): void + applyTabInfo(tab: BrowserTabInfo): void closeMissingRemotePage(remotePageId: string | null): void } // Keep runtime page identity and post-input refresh separate from screencast ownership. export class RemoteBrowserPageSession { private tabRefreshTimer: ReturnType | null = null + private observationGeneration = 0 + private nextObservationId = 0 + private appliedObservationId = 0 + private observationAnchor: readonly unknown[] | null = null + private loadingOwner: BrowserPageLoadingOwner | null = null constructor(private readonly deps: RemoteBrowserPageSessionDeps) {} - async fetchTabInfo(token: RemoteBrowserOperationToken): Promise { + invalidateTabInfoReads(): void { + this.observationGeneration += 1 + this.cancelTabInfoRefresh() + } + + private observeExternalChanges(): readonly unknown[] { + const current = this.deps.readCurrentObservation() + if ( + this.observationAnchor && + (this.observationAnchor.length !== current.length || + this.observationAnchor.some((value, index) => value !== current[index])) + ) { + this.observationGeneration += 1 + if (this.loadingOwner && current[2] !== this.loadingOwner) { + this.loadingOwner.active = false + this.loadingOwner = null + } + } + this.observationAnchor = current + return current + } + + private applyObservedWrite(write: () => void): void { + try { + write() + } finally { + this.observationAnchor = this.deps.readCurrentObservation() + } + } + + setPageLoading( + token: RemoteBrowserOperationToken, + loading: boolean, + update: BrowserPageStateWriter + ): void { + if (!this.deps.tokens.isCurrent(token)) { + return + } + const observation = this.observeExternalChanges() + if (!loading && (!this.loadingOwner || observation[2] !== this.loadingOwner)) { + return + } + const owner = loading ? { active: true } : undefined + this.loadingOwner = owner ?? null + this.applyObservedWrite(() => update(token.tabId, { loading }, owner)) + } + + captureTabInfoAdmission(token: RemoteBrowserOperationToken): { + isCurrent: () => boolean + apply: (tab: BrowserTabInfo, onApplied?: (tab: BrowserTabInfo) => void) => boolean + } { + const id = ++this.nextObservationId + const active = this.deps.tokens.isCurrent(token) + if (active) { + this.observeExternalChanges() + } + const generation = this.observationGeneration + const isCurrent = (): boolean => { + if ( + generation !== this.observationGeneration || + !active || + id < this.appliedObservationId || + !this.deps.tokens.isCurrent(token) + ) { + return false + } + this.observeExternalChanges() + return generation === this.observationGeneration + } + return { + isCurrent, + apply: (tab, onApplied) => { + if (!isCurrent()) { + return false + } + this.applyObservedWrite(() => { + this.deps.applyTabInfo(tab) + this.appliedObservationId = id + this.loadingOwner = null + onApplied?.(tab) + }) + return true + } + } + } + + async refreshTabInfo( + token: RemoteBrowserOperationToken, + callbacks: { + onApplied?: (tab: BrowserTabInfo) => void + onError?: (error: unknown) => void + } = {} + ): Promise | null> { + const admission = this.captureTabInfoAdmission(token) + let tab: BrowserTabInfo | null + try { + tab = await this.readTabInfo(token) + } catch (error) { + if (!admission.isCurrent()) { + return null + } + if (callbacks.onError) { + this.applyObservedWrite(() => callbacks.onError?.(error)) + return null + } + throw error + } + return tab && admission.apply(tab, callbacks.onApplied) ? { loadError: tab.loadError } : null + } + + async resolveNavigationPage(token: RemoteBrowserOperationToken): Promise { + if (!this.deps.tokens.isCurrent(token)) { + return null + } + const handle = this.deps.readStoredHandle() + if (handle?.environmentId === token.environmentId) { + this.deps.tokens.setRemotePage(handle.remotePageId) + return handle.remotePageId + } + return this.ensureRemotePage(token) + } + + private async readTabInfo(token: RemoteBrowserOperationToken): Promise { if (!this.deps.tokens.isCurrent(token) || !token.remotePageId) { return null } @@ -70,7 +200,7 @@ export class RemoteBrowserPageSession { } tokens.setRemotePage(existingHandle.remotePageId) try { - const cachedTab = await this.fetchTabInfo({ + const cachedTab = await this.readTabInfo({ ...token, remotePageId: existingHandle.remotePageId }) @@ -101,17 +231,13 @@ export class RemoteBrowserPageSession { if (!tokens.isCurrent(token)) { return } - void this.fetchTabInfo(token) - .then((tab) => { - if (tab && tokens.isCurrent(token)) { - this.deps.applyTabInfo(tab) - } - }) - .catch((error: unknown) => { - if (tokens.isCurrent(token) && isRemoteBrowserPageMissingError(error)) { + void this.refreshTabInfo(token, { + onError: (error) => { + if (isRemoteBrowserPageMissingError(error)) { this.deps.closeMissingRemotePage(token.remotePageId) } - }) + } + }) }, delayMs) } @@ -137,7 +263,7 @@ export class RemoteBrowserPageSession { tokens.setRemotePage(provisional) try { // tabShow also starts a saved page the host has not opened yet. - if (!(await this.fetchTabInfo(probe)) || !tokens.isCurrent(probe)) { + if (!(await this.readTabInfo(probe)) || !tokens.isCurrent(probe)) { release() return null } diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx index 5b446b726b0..385f2f304f5 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-viewport.tsx @@ -46,6 +46,7 @@ export function RemoteBrowserPageViewport({ onGoto, onNavigateToUrl, onReconnect, + onBeforeProceedCertificate, handleRemotePointerDown, handleRemotePointerUp, handleRemoteContextMenu, @@ -73,6 +74,7 @@ export function RemoteBrowserPageViewport({ /** The address bar's submit path, which also parks a URL typed before the host page exists. */ onNavigateToUrl: (url: string) => void onReconnect: () => void + onBeforeProceedCertificate: () => void handleRemotePointerDown: (event: React.PointerEvent) => void handleRemotePointerUp: (event: React.PointerEvent) => void handleRemoteContextMenu: (event: React.MouseEvent) => void @@ -180,6 +182,7 @@ export function RemoteBrowserPageViewport({ ) { return { ok: false, reason: 'missing' } } + onBeforeProceedCertificate() return callRuntimeRpc( target, 'browser.certificate.proceed', diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts index c2052eb4fa9..2b281335e7c 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle-test-harness.ts @@ -190,6 +190,7 @@ export function createHarness() { callRpc, subscribeScreencast, getWorktreeSelector: () => 'worktree:wt-1', + readCurrentObservation: () => [], getCurrentUrl: () => 'https://example.test/', readStoredHandle: () => storedHandle, writeStoredHandle: (handle) => { diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts index 499a965110b..9ad4bcada40 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.test.ts @@ -15,6 +15,35 @@ describe('RemoteBrowserStreamLifecycle', () => { vi.useRealTimers() }) + it('applies current ready metadata after open and a viewport restart', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + const before = harness.appliedTitles.length + harness.streams[0].emitReady() + await settle() + expect(harness.appliedTitles.slice(before)).toEqual(['Example']) + harness.setViewportSize({ width: 1400, height: 900 }) + harness.lifecycle.restartForViewport('page-1') + await settle() + const beforeRestartReady = harness.appliedTitles.length + harness.streams.at(-1)!.emitReady() + await settle() + expect(harness.appliedTitles.slice(beforeRestartReady)).toEqual(['Example']) + }) + + it('keeps a stream live without applying ready metadata superseded by navigation', async () => { + const harness = createHarness() + harness.lifecycle.open() + await settle() + const before = [...harness.appliedTitles] + harness.lifecycle.session.invalidateTabInfoReads() + harness.streams[0].emitReady() + await settle() + expect(harness.appliedTitles).toEqual(before) + expect(harness.currentStatusKind).toBe('live') + }) + it("asks once about the streamed page's dialog and answers it on the host", async () => { const harness = createHarness() await openStreamAndConfirmReady(harness) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts index fb3c40ce406..0232af0439e 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-lifecycle.ts @@ -1,4 +1,3 @@ -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' import type { RuntimeStatus } from '../../../../../shared/runtime-types' import { RemoteBrowserPageSession } from './remote-browser-page-session' import { RemoteBrowserJavaScriptDialog } from './remote-browser-javascript-dialog' @@ -22,6 +21,7 @@ import { resolveRemoteBrowserStreamFailure } from './remote-browser-stream-errors' import { + toOperationToken, areRemoteViewportSizesNear, RemoteBrowserOperationTokens, type RemoteBrowserStreamSubscription, @@ -74,6 +74,25 @@ export class RemoteBrowserStreamLifecycle { this.setStatus(REMOTE_BROWSER_STREAM_IDLE) return () => {} } + const handleOpenFailure = (error: unknown): void => { + if (cancelled) { + return + } + if (isRemoteBrowserPageMissingError(error)) { + deps.closeMissingRemotePage(tokens.remotePage) + return + } + const failure = resolveRemoteBrowserStreamFailure(error, 'opening') + if (failure.logRawError) { + console.warn('[browser-pane] remote browser failed to open:', error) + } + // The host can close before subscribe rejects; preserve the newer armed restart’s ownership of status. + if (this.restartScheduler.isScheduled) { + return + } + // An initial open has no retry budget; preserve manual recovery. + this.setStatus(remoteBrowserStreamStopped(failure.message)) + } void this.session .ensureRemotePage(operationToken) .then(async (pageId) => { @@ -81,11 +100,14 @@ export class RemoteBrowserStreamLifecycle { return } const pageToken = { ...operationToken, remotePageId: pageId } - const tab = await this.session.fetchTabInfo(pageToken) - if (tab && !cancelled && tokens.isCurrent(pageToken)) { - deps.applyTabInfo(tab) - } - if (cancelled || !tokens.isCurrent(pageToken)) { + let readFailed = false + await this.session.refreshTabInfo(pageToken, { + onError: (error) => { + readFailed = true + handleOpenFailure(error) + } + }) + if (readFailed || cancelled || !tokens.isCurrent(pageToken)) { return } const subscription = await this.startStream(pageId) @@ -95,25 +117,7 @@ export class RemoteBrowserStreamLifecycle { } this.adoptSubscription(subscription) }) - .catch((error: unknown) => { - if (cancelled) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - deps.closeMissingRemotePage(tokens.remotePage) - return - } - const failure = resolveRemoteBrowserStreamFailure(error, 'opening') - if (failure.logRawError) { - console.warn('[browser-pane] remote browser failed to open:', error) - } - // The host can close before subscribe rejects; preserve the newer armed restart’s ownership of status. - if (this.restartScheduler.isScheduled) { - return - } - // An initial open has no retry budget; preserve manual recovery. - this.setStatus(remoteBrowserStreamStopped(failure.message)) - }) + .catch(handleOpenFailure) return () => { cancelled = true this.retireInFlightWork()?.unsubscribe() @@ -216,10 +220,8 @@ export class RemoteBrowserStreamLifecycle { if (!operationToken || !tokens.isCurrent(operationToken)) { return null } - const target: RuntimeClientTarget = { - kind: 'environment', - environmentId: operationToken.environmentId - } + const { environmentId } = operationToken + const target = { kind: 'environment' as const, environmentId } const status = await deps.callRpc(target, 'status.get', undefined, { timeoutMs: 15_000 }) @@ -236,6 +238,7 @@ export class RemoteBrowserStreamLifecycle { } this.streamViewportSize = viewportSize const token = tokens.claimStreamToken(operationToken, pageId) + const admitsTabInfo = this.session.captureTabInfoAdmission(toOperationToken(token)) // Treat a silent host as a drop so it spends the same bounded retry budget. this.liveness.watch(() => { if (this.tokens.isCurrentStreamToken(token)) { @@ -258,7 +261,7 @@ export class RemoteBrowserStreamLifecycle { // Record uptime; only sustained streams earn a refill at close. Live status also clears healed notices. this.liveness.markReady() this.setStatus(REMOTE_BROWSER_STREAM_LIVE) - deps.applyTabInfo(event.tab) + admitsTabInfo.apply(event.tab) void deps.syncViewport(event.browserPageId).catch(() => {}) }, onEnded: () => this.handleStreamClosed(token, true), @@ -324,7 +327,6 @@ export class RemoteBrowserStreamLifecycle { tokens: this.tokens, session: this.session, setStatus: (status) => this.setStatus(status), - applyTabInfo: (tab) => this.deps.applyTabInfo(tab), closeMissingRemotePage: (remotePageId) => this.deps.closeMissingRemotePage(remotePageId), startStream: (pageId) => this.startStream(pageId), adoptSubscription: (subscription) => this.adoptSubscription(subscription) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts index 415f1888538..6d365cf8f27 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-stream-restart-attempt.ts @@ -14,13 +14,11 @@ import { type RemoteBrowserStreamSubscription, type RemoteBrowserStreamToken } from './remote-browser-stream-tokens' -import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts' export type RemoteBrowserStreamRestartAttemptDeps = { tokens: RemoteBrowserOperationTokens session: RemoteBrowserPageSession setStatus: (status: RemoteBrowserStreamStatus) => void - applyTabInfo: (tab: BrowserTabInfo) => void closeMissingRemotePage: (remotePageId: string) => void startStream: (pageId: string) => Promise adoptSubscription: (subscription: RemoteBrowserStreamSubscription) => void @@ -41,10 +39,7 @@ export function createRemoteBrowserStreamRestartAttempt( deps.setStatus(remoteBrowserStreamRetrying(lastNotice)) const operationToken = toOperationToken(token) try { - const tab = await deps.session.fetchTabInfo(operationToken).catch(() => null) - if (tab && tokens.isCurrentStreamOperation(token)) { - deps.applyTabInfo(tab) - } + await deps.session.refreshTabInfo(operationToken).catch(() => null) if (!tokens.isCurrentStreamOperation(token)) { return false } diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts index 0f4f6a42ca5..c390f3032aa 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-lifecycle.ts @@ -11,7 +11,6 @@ import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-t import { toRuntimeWorktreeSelector } from '@/runtime/rpc/targets/runtime-worktree-selector' import type { BrowserScreencastFrameMetadata } from '../../../../../shared/browser/screencast/browser-screencast-protocol' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' -import { browserPageExists } from '../describe-page/browser-page-load-error' import { REMOTE_BROWSER_STREAM_IDLE, type RemoteBrowserStreamStatus @@ -23,6 +22,10 @@ import { type RemoteBrowserStreamBridge } from './remote-browser-page-input-model' import { subscribeRuntimeEnvironment } from '@/runtime/rpc/environment-revisions/runtime-environment-pairing-refresh' +import { + readRemoteBrowserPage, + readRemoteBrowserPageObservation +} from './remote-browser-page-observation' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { javaScriptDialogPrompt } from '../browser-javascript-dialog-prompt' @@ -60,6 +63,7 @@ export function useRemoteBrowserPageLifecycle({ const pendingFrameDecodeRef = useRef(0) const mountedRef = useRef(true) const isActiveRef = useRef(isActive) + const currentWorkspaceIdRef = useRef(browserTab.workspaceId) const currentBrowserTabIdRef = useRef(browserTab.id) const currentBrowserTabUrlRef = useRef(browserTab.url) const runtimeWorktree = useMemo(() => toRuntimeWorktreeSelector(worktreeId), [worktreeId]) @@ -74,12 +78,20 @@ export function useRemoteBrowserPageLifecycle({ const confirm = useConfirmationDialog() useLayoutEffect(() => { + currentWorkspaceIdRef.current = browserTab.workspaceId currentBrowserTabIdRef.current = browserTab.id currentBrowserTabUrlRef.current = browserTab.url activeRuntimeEnvironmentIdRef.current = activeRuntimeEnvironmentId isActiveRef.current = isActive runtimeWorktreeRef.current = runtimeWorktree - }, [activeRuntimeEnvironmentId, browserTab.id, browserTab.url, isActive, runtimeWorktree]) + }, [ + activeRuntimeEnvironmentId, + browserTab.id, + browserTab.workspaceId, + browserTab.url, + isActive, + runtimeWorktree + ]) if (!lifecycleRef.current) { lifecycleRef.current = new RemoteBrowserStreamLifecycle({ @@ -88,12 +100,21 @@ export function useRemoteBrowserPageLifecycle({ isActive: () => isActiveRef.current, getTabId: () => currentBrowserTabIdRef.current, getEnvironmentId: () => activeRuntimeEnvironmentIdRef.current, - browserPageExists + browserPageExists: (pageId) => + Boolean( + readRemoteBrowserPage(useAppStore.getState(), currentWorkspaceIdRef.current, pageId) + ) }, callRpc: callRuntimeRpc, subscribeScreencast: (args, callbacks) => subscribeRuntimeEnvironment(args, callbacks), getWorktreeSelector: () => runtimeWorktreeRef.current, getCurrentUrl: () => currentBrowserTabUrlRef.current, + readCurrentObservation: () => + readRemoteBrowserPageObservation( + useAppStore.getState(), + currentWorkspaceIdRef.current, + currentBrowserTabIdRef.current + ), readStoredHandle: () => useAppStore.getState().remoteBrowserPageHandlesByPageId[currentBrowserTabIdRef.current] ?? null, @@ -235,7 +256,12 @@ export function useRemoteBrowserPageLifecycle({ } const state = useAppStore.getState() const currentEnvironmentId = runtimeEnvironmentIdOrLocalUnlessTwin(state, worktreeId) - const pageStillExists = browserPageExists(browserTab.id) + const pageStillExists = + useAppStore + .getState() + .browserPagesByWorkspace[browserTab.workspaceId]?.some( + (page) => page.id === browserTab.id + ) ?? false if (currentEnvironmentId === activeRuntimeEnvironmentId && pageStillExists) { return } @@ -252,7 +278,14 @@ export function useRemoteBrowserPageLifecycle({ { timeoutMs: 15_000, suppressFeatureInteraction: true } ).catch(() => {}) } - }, [activeRuntimeEnvironmentId, browserTab.id, lifecycle, runtimeWorktree, worktreeId]) + }, [ + activeRuntimeEnvironmentId, + browserTab.id, + browserTab.workspaceId, + lifecycle, + runtimeWorktree, + worktreeId + ]) return { lifecycle, diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts index 481965f3b90..769c9fc1b92 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.test.ts @@ -1,74 +1,672 @@ // @vitest-environment happy-dom -import { act, cleanup, renderHook } from '@testing-library/react' +import { act, cleanup } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' -import type { BrowserPage } from '../../../../../shared/browser-workspace-types' -import { useRemoteBrowserPageNavigation } from './use-remote-browser-page-navigation' -import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' +import { applyWebSessionTabsSnapshot } from '@/runtime/web-session-tabs-sync/snapshot-api' +import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' +import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts' -vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ - callRuntimeRpc: vi.fn(async () => ({})) -})) +import { + page, + failedTab, + deferred, + renderNavigation, + currentPage, + expectPending +} from './remote-browser-page-navigation.test-fixture' -function page(): BrowserPage { - return { - id: 'page-a', - workspaceId: 'workspace-a', - worktreeId: 'worktree-a', - url: 'about:blank', - title: 'New Tab', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } -} +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: vi.fn() })) -function renderNavigation(): ReturnType< - typeof renderHook, void> -> { - return renderHook(() => - useRemoteBrowserPageNavigation({ - browserTab: page(), - stagedPage: false, - addressBarValue: 'about:blank', - setAddressBarValueFromPage: vi.fn(), - lifecycle: { - session: { ensureRemotePage: vi.fn(), scheduleTabInfoRefresh: vi.fn() } - } as unknown as RemoteBrowserStreamLifecycle, - runtimeWorktree: 'worktree-a', - runtimeTarget: () => null, - createRemoteOperationToken: () => null, - isCurrentRemoteOperationToken: () => false, - closeMissingRemotePage: vi.fn(), - onSetUrl: vi.fn(), - onUpdatePageState: vi.fn(), - setPaneNotice: vi.fn(), - setPaneBusy: vi.fn() - }) - ) -} +beforeEach(() => vi.mocked(callRuntimeRpc).mockReset()) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) -describe('useRemoteBrowserPageNavigation history recording', () => { - beforeEach(() => { - useAppStore.setState({ browserUrlHistory: [] }) +describe('remote browser authoritative page state', () => { + it('projects a pending host challenge through local actions and clears an old full snapshot without fields', () => { + const { result } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + expectPending() + act(() => + result.current.applyRemoteTabInfo({ + browserPageId: 'remote-page-a', + index: 0, + active: true, + url: 'https://example.test/', + title: 'Example' + }) + ) + expect(currentPage().loadError).toBeNull() + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() }) - afterEach(() => cleanup()) - it('redacts a Kagi session token before it reaches history', () => { const { result } = renderNavigation() - act(() => result.current.applyRemoteTabInfo({ + browserPageId: 'remote-page-a', + index: 0, + active: true, url: 'https://kagi.com/search?q=orca&token=secret-session', title: 'Kagi' }) ) - - const [entry] = useAppStore.getState().browserUrlHistory - expect(entry.url).not.toContain('secret-session') + expect(useAppStore.getState().browserUrlHistory[0].url).not.toContain('secret-session') }) + + it.each(['browser.goto', 'browser.back', 'browser.forward', 'browser.reload'] as const)( + 'reads authoritative state after %s rejects or resolves, without a pre-command read', + async (method) => { + const { result, busy, notice } = renderNavigation() + const calls: string[] = [] + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, called) => { + if (typeof called !== 'string' || !called.startsWith('browser.')) { + return undefined + } + calls.push(called) + if (called === 'browser.tabShow') { + return { tab: failedTab('challenge-y') } + } + if (called === 'browser.goto') { + throw new Error('ERR_CERT_AUTHORITY_INVALID (-202)') + } + return { url: 'chrome-error://chromewebdata/', title: 'Error' } + }) + await act(() => result.current.runRemoteNavigation(method, 'https://selfsigned.test/')) + expect(calls).toEqual([method, 'browser.tabShow']) + expectPending('challenge-y') + expect(currentPage().loading).toBe(false) + expect(busy).toHaveBeenLastCalledWith(false) + expect(notice).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'consequence' })) + expect( + useAppStore + .getState() + .browserUrlHistory.some((entry) => entry.url.startsWith('chrome-error:')) + ).toBe(false) + } + ) + + it('keeps a pending failure when a resolved reload outcome read times out', async () => { + const { result } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => { + if (method === 'browser.tabShow') { + throw new Error('request timed out') + } + return { url: 'chrome-error://chromewebdata/', title: 'Error' } + }) + await act(() => result.current.runRemoteNavigation('browser.reload')) + expectPending() + expect(currentPage().loading).toBe(false) + }) + + it('does not let a command settling after Proceed overwrite the native success or start another read', async () => { + const { result, busy, notice } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + const command = deferred() + vi.mocked(callRuntimeRpc).mockReturnValue(command.promise) + let pending!: Promise + await act(async () => { + pending = result.current.runRemoteNavigation('browser.reload') + await Promise.resolve() + }) + act(() => { + result.current.prepareCertificateProceed() + useAppStore.getState().updateBrowserPageState('page-a', { loadError: null, loading: false }) + }) + const publications = busy.mock.calls.length + await act(async () => { + command.resolve({ url: 'chrome-error://chromewebdata/', title: 'Old' }) + await pending + }) + expect(currentPage().loadError).toBeNull() + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + expect(vi.mocked(callRuntimeRpc).mock.calls.map((call) => call[1])).toEqual(['browser.reload']) + expect(busy.mock.calls.length).toBe(publications) + expect(notice).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'consequence' })) + }) + + it.each(['older-first', 'newer-first', 'newer-fails'] as const)( + 'retains current outcomes across command cleanup with %s settlement', + async (order) => { + const { result, session, tokens } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + const older = deferred<{ tab: BrowserTabInfo }>() + const newer = deferred<{ tab: BrowserTabInfo }>() + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ url: 'https://selfsigned.test/', title: 'Reload' }) + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise) + let command!: Promise + await act(async () => { + command = result.current.runRemoteNavigation('browser.reload') + }) + const refresh = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + if (order === 'newer-fails') { + await act(async () => { + newer.reject(new Error('input read unavailable')) + await expect(refresh).rejects.toThrow('input read unavailable') + }) + } else if (order === 'newer-first') { + await act(async () => { + newer.resolve({ tab: { ...failedTab('challenge-z'), title: 'Current input' } }) + await refresh + }) + } + await act(async () => { + older.resolve({ tab: { ...failedTab('challenge-y'), title: 'Reload outcome' } }) + await command + }) + if (order === 'older-first') { + await act(async () => { + newer.resolve({ tab: { ...failedTab('challenge-z'), title: 'Current input' } }) + await refresh + }) + } + expectPending(order === 'newer-fails' ? 'challenge-y' : 'challenge-z') + expect(currentPage().title).toBe(order === 'newer-fails' ? 'Reload outcome' : 'Current input') + expect(currentPage().loading).toBe(false) + } + ) + + it('releases retired Reload loading before failed Proceed without a native wake', async () => { + const { result } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + const reload = deferred() + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => + method === 'browser.reload' ? reload.promise : undefined + ) + let pending!: Promise + await act(async () => { + pending = result.current.runRemoteNavigation('browser.reload') + }) + expect(currentPage().loading).toBe(true) + act(() => result.current.prepareCertificateProceed()) + expect(currentPage().loading).toBe(false) + await act(async () => { + reload.reject(new Error('connection lost before reaching host')) + await pending + }) + expectPending() + expect(currentPage().loading).toBe(false) + }) + + it('does not bless external ABA or clear native-owned loading during local cleanup', async () => { + const { session, tokens, result } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + const token = tokens.createOperationToken('remote-page-a')! + act(() => session.setPageLoading(token, true, useAppStore.getState().updateBrowserPageState)) + const response = deferred<{ tab: BrowserTabInfo }>() + vi.mocked(callRuntimeRpc).mockReturnValue(response.promise) + const pending = session.refreshTabInfo(token) + act(() => { + useAppStore.getState().updateBrowserPageState('page-a', { loadError: null }) + result.current.applyRemoteTabInfo(failedTab()) + useAppStore.getState().updateBrowserPageState('page-a', { loading: true }) + session.setPageLoading(token, false, useAppStore.getState().updateBrowserPageState) + }) + await act(async () => { + response.resolve({ tab: failedTab('stale') }) + await pending + }) + expectPending() + expect(currentPage().loading).toBe(true) + }) + + it.each(['initial-open', 'restart'] as const)( + 'preserves command failure when %s fails before ready', + async (phase) => { + vi.useFakeTimers() + const { lifecycle, subscribe, result } = renderNavigation() + const status = deferred<{ capabilities: string[] }>() + const read = deferred<{ tab: BrowserTabInfo }>() + let held = phase === 'initial-open' + let commandStarted = false + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => { + if (method === 'status.get') { + return held ? status.promise : { capabilities: ['browser.screencast.v1'] } + } + if (method === 'browser.goto') { + commandStarted = true + throw new Error('ERR_NAME_NOT_RESOLVED (-105)') + } + if (method === 'browser.tabShow') { + return commandStarted ? read.promise : { tab: failedTab() } + } + return undefined + }) + try { + lifecycle.open() + await act(() => vi.advanceTimersByTimeAsync(0)) + if (phase === 'restart') { + const callbacks = subscribe.mock.calls[0][1] + callbacks.onResponse({ + id: 'stream-1', + ok: true, + result: { + type: 'ready', + subscriptionId: 'stream-1', + browserPageId: 'remote-page-a', + format: 'jpeg', + tab: failedTab() + }, + _meta: { runtimeId: 'host-a' } + }) + held = true + callbacks.onClose?.() + await act(() => vi.runOnlyPendingTimersAsync()) + } + let command!: Promise + await act(async () => { + command = result.current.runRemoteNavigation('browser.goto', 'https://missing.test/') + }) + subscribe.mockRejectedValueOnce( + Object.assign(new Error('stream unavailable'), { code: 'browser_screencast_unsupported' }) + ) + await act(async () => { + status.resolve({ capabilities: ['browser.screencast.v1'] }) + await vi.advanceTimersByTimeAsync(0) + }) + await act(async () => { + read.reject(new Error('outcome read unavailable')) + await command + }) + expect(currentPage().loadError).toEqual({ + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + validatedUrl: 'https://missing.test/' + }) + expect(currentPage().loading).toBe(false) + } finally { + lifecycle.dispose() + } + } + ) + + it('rejects a late read after native replacement/clear, another action, host switch, or unmount, then accepts a current read', async () => { + const { result, session, tokens, identity } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + for (const supersede of [ + () => + useAppStore + .getState() + .setBrowserPageCertificateFailure('page-a', failedTab('challenge-y').certificateFailure!), + () => useAppStore.getState().updateBrowserPageState('page-a', { loadError: null }), + () => session.invalidateTabInfoReads(), + () => { + identity.environmentId = 'environment-b' + }, + () => { + identity.mounted = false + } + ]) { + const response = deferred() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise) + const read = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + act(supersede) + response.resolve({ tab: failedTab() }) + expect(await read).toBeNull() + identity.environmentId = 'environment-a' + identity.mounted = true + } + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ tab: failedTab('challenge-z') }) + await act(() => session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)) + expectPending('challenge-z') + }) + + it('keeps the second navigation loading when an older outcome read rejects', async () => { + const { result, busy, notice } = renderNavigation() + const stale = deferred() + const second = deferred() + vi.mocked(callRuntimeRpc) + .mockResolvedValueOnce({ url: 'about:blank' }) + .mockReturnValueOnce(stale.promise) + .mockReturnValueOnce(second.promise) + .mockResolvedValueOnce({ tab: failedTab('challenge-y') }) + let first!: Promise + let next!: Promise + await act(async () => { + first = result.current.runRemoteNavigation('browser.reload') + await Promise.resolve() + await Promise.resolve() + next = result.current.runRemoteNavigation('browser.back') + await Promise.resolve() + }) + await act(async () => { + stale.reject(new Error('stale read failed')) + await first + }) + expect(currentPage().loading).toBe(true) + expect(busy).toHaveBeenLastCalledWith(true) + expect(notice).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'consequence' })) + await act(async () => { + second.resolve({ url: 'chrome-error://chromewebdata/' }) + await next + }) + expectPending('challenge-y') + expect(currentPage().loading).toBe(false) + }) + + it.each(['success', 'failure'] as const)( + 'accepts a later native mirror %s after an early Proceed observation and rejects the older read', + async (outcome) => { + const { result, session, tokens } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + expectPending() + act(() => { + result.current.prepareCertificateProceed() + result.current.applyRemoteTabInfo({ + ...failedTab(), + certificateFailure: null + }) + }) + const stale = deferred() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(stale.promise) + const read = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + const failure = outcome === 'failure' ? failedTab('challenge-y') : null + act(() => + useAppStore.setState((state) => + applyWebSessionTabsSnapshot( + state, + { + worktree: 'worktree-a', + publicationEpoch: 'tls-test', + snapshotVersion: 1, + activeTabType: 'browser', + activeTabId: 'host-tab', + activeGroupId: 'host-group', + tabs: [ + { + type: 'browser', + id: 'host-tab', + browserWorkspaceId: 'workspace-a', + browserPageId: 'remote-page-a', + url: 'https://selfsigned.test/', + title: 'TLS', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true, + loadError: failure?.loadError ?? null, + certificateFailure: failure?.certificateFailure ?? null + } + ] + }, + 'environment-a', + 100 + ) + ) + ) + stale.resolve({ tab: failedTab() }) + expect(await read).toBeNull() + if (outcome === 'failure') { + expectPending('challenge-y') + } else { + expect(currentPage().loadError).toBeNull() + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + } + } + ) + + it.each([null, undefined])( + 'keeps an explicit native clear but retains the legacy omitted-error fallback (%s)', + async (loadError) => { + const { result } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => { + if (typeof method !== 'string') { + return undefined + } + if (method === 'browser.tabShow') { + return { + tab: { + ...failedTab(), + url: 'https://loaded.test/', + loadError, + certificateFailure: null + } + } + } + throw new Error('ERR_TIMED_OUT (-7)') + }) + await act(() => result.current.runRemoteNavigation('browser.goto', 'https://loaded.test/')) + expect(currentPage().url).toBe('https://loaded.test/') + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + if (loadError === null) { + expect(currentPage().loadError).toBeNull() + } else { + expect(currentPage().loadError).toEqual({ + code: -7, + description: 'ERR_TIMED_OUT', + validatedUrl: 'https://loaded.test/' + }) + } + } + ) + + it('retains parsed command failure when its authoritative read also fails', async () => { + const { result } = renderNavigation() + vi.mocked(callRuntimeRpc).mockImplementation(async (_target, method) => { + if (typeof method !== 'string') { + return undefined + } + throw new Error(method === 'browser.tabShow' ? 'read timed out' : 'ERR_TIMED_OUT (-7)') + }) + await act(() => result.current.runRemoteNavigation('browser.goto', 'https://loaded.test/')) + expect(currentPage().loadError).toEqual({ + code: -7, + description: 'ERR_TIMED_OUT', + validatedUrl: 'https://loaded.test/' + }) + expect(currentPage().loading).toBe(false) + }) + + it('does not reinstall X after native clear-to-failure-to-clear at the same URL', async () => { + const { result, session, tokens } = renderNavigation() + act(() => + result.current.applyRemoteTabInfo({ + ...failedTab(), + loadError: null, + certificateFailure: null + }) + ) + const response = deferred() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise) + const pending = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + act(() => { + useAppStore.getState().updateBrowserPageState('page-a', { loadError: failedTab().loadError }) + useAppStore + .getState() + .setBrowserPageCertificateFailure('page-a', failedTab().certificateFailure!) + useAppStore.getState().updateBrowserPageState('page-a', { loadError: null }) + }) + response.resolve({ tab: failedTab() }) + expect(await pending).toBeNull() + expect(currentPage().loadError).toBeNull() + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ tab: failedTab('challenge-y') }) + await act(() => session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)) + expectPending('challenge-y') + }) + + it('applies admitted state before resolving, so a queued native clear remains final', async () => { + const { session, tokens } = renderNavigation() + const response = deferred() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise) + const pending = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + const nativeClear = response.promise.then(() => + queueMicrotask(() => + useAppStore.getState().updateBrowserPageState('page-a', { loadError: null }) + ) + ) + await act(async () => { + response.resolve({ tab: failedTab() }) + await pending + await nativeClear + }) + expect(currentPage().loadError).toBeNull() + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + }) + + it.each(['rejected', 'resolved', 'missing'] as const)( + 'keeps a queued native replacement after an admitted %s read failure', + async (outcome) => { + const { result, notice, closeMissing } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + const read = deferred() + vi.mocked(callRuntimeRpc).mockImplementation((_target, method) => { + if (typeof method !== 'string') { + return Promise.resolve(undefined) + } + if (method === 'browser.tabShow') { + return read.promise + } + if (outcome === 'rejected') { + return Promise.reject(new Error('ERR_CERT_AUTHORITY_INVALID (-202)')) + } + return Promise.resolve({ url: 'https://old.test/', title: 'Old' }) + }) + let pending!: Promise + await act(async () => { + pending = result.current.runRemoteNavigation('browser.goto', 'https://old.test/') + await Promise.resolve() + await Promise.resolve() + }) + const native = read.promise.catch(() => + queueMicrotask(() => { + useAppStore.getState().setBrowserPageUrl('page-a', 'https://native.test/') + useAppStore + .getState() + .updateBrowserPageState('page-a', { loadError: null, loading: false, title: 'Native' }) + notice.mockClear() + closeMissing.mockClear() + }) + ) + await act(async () => { + read.reject( + outcome === 'missing' + ? Object.assign(new Error('page missing'), { code: 'browser_tab_not_found' }) + : new Error('read timed out') + ) + await pending + await native + }) + expect(currentPage().url).toBe('https://native.test/') + expect(currentPage().title).toBe('Native') + expect(currentPage().loadError).toBeNull() + expect(useAppStore.getState().browserCertificateFailuresByPageId['page-a']).toBeUndefined() + expect(notice).not.toHaveBeenCalled() + expect(closeMissing).not.toHaveBeenCalled() + } + ) + + it.each([100, 1000])( + 'drops an obsolete read without visiting %i unrelated workspace pages after map replacement', + async (count) => { + const { result, session, tokens } = renderNavigation() + act(() => result.current.applyRemoteTabInfo(failedTab())) + const response = deferred() + vi.mocked(callRuntimeRpc).mockReturnValueOnce(response.promise) + const pending = session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + let visits = 0 + let copies = 0 + const unrelated = Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `other-${index}`, + new Proxy([page(`other-${index}`)], { + get(target, key, receiver) { + if (key === 'map') { + copies += 1 + } + if (typeof key === 'string' && /^\d+$/.test(key)) { + visits += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy must forward array access unchanged while counting visits. + return Reflect.get(target, key, receiver) + } + }) + ]) + ) + useAppStore.setState((state) => ({ + browserPagesByWorkspace: { ...state.browserPagesByWorkspace, ...unrelated } + })) + session.invalidateTabInfoReads() + response.resolve({ tab: failedTab('stale') }) + expect(await pending).toBeNull() + expect(visits).toBe(0) + expect(copies).toBe(0) + expectPending() + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ tab: failedTab('challenge-y') }) + await act(() => session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!)) + expectPending('challenge-y') + } + ) + + it.each([100, 1000])( + 'publishes no work for identical decoded failure observations among %i pages', + async (count) => { + const { result, session, tokens } = renderNavigation(count) + const failures = Object.fromEntries( + Array.from({ length: count }, (_, index) => [ + `page-${index}`, + failedTab(`other-${index}`).certificateFailure! + ]) + ) + useAppStore.setState({ browserCertificateFailuresByPageId: failures }) + act(() => result.current.applyRemoteTabInfo(failedTab())) + let enumerations = 0 + let copies = 0 + let visits = 0 + const state = useAppStore.getState() + const counted = (value: T): T => + new Proxy(value, { + ownKeys(target) { + enumerations += 1 + return Reflect.ownKeys(target) + } + }) + const countedPages = new Proxy(state.browserPagesByWorkspace['workspace-a'], { + get(target, key, receiver) { + if (key === 'map') { + copies += 1 + } + if (typeof key === 'string' && /^\d+$/.test(key)) { + visits += 1 + } + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy must forward array access unchanged while counting visits. + return Reflect.get(target, key, receiver) + } + }) + useAppStore.setState({ + browserPagesByWorkspace: counted({ + ...state.browserPagesByWorkspace, + 'workspace-a': countedPages + }), + browserCertificateFailuresByPageId: counted(state.browserCertificateFailuresByPageId) + }) + currentPage() + const before = useAppStore.getState() + enumerations = 0 + copies = 0 + visits = 0 + const changed = vi.fn() + const unsubscribe = useAppStore.subscribe(changed) + vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ + tab: JSON.parse(JSON.stringify(failedTab())) + }) + await act(async () => { + await session.refreshTabInfo(tokens.createOperationToken('remote-page-a')!) + }) + unsubscribe() + expect(changed).not.toHaveBeenCalled() + expect(enumerations).toBe(0) + expect(copies).toBe(0) + expect(visits).toBeLessThanOrEqual(count + 20) + expect(useAppStore.getState()).toBe(before) + const unrelated = before.browserPagesByWorkspace['workspace-a'][1] + act(() => result.current.applyRemoteTabInfo(failedTab('challenge-y'))) + expectPending('challenge-y') + expect(useAppStore.getState().browserPagesByWorkspace['workspace-a'][1]).toBe(unrelated) + } + ) }) diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts index 7898e381226..0bf0af227b7 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-navigation.ts @@ -1,22 +1,18 @@ +import { runRemoteBrowserNavigationCommand } from './remote-browser-page-navigation-command' import { useCallback, useRef } from 'react' import { useAppStore } from '@/store' import { redactKagiSessionToken } from '../../../../../shared/browser-url' import { normalizeBrowserHistoryUrl } from '../../../../../shared/workspace-session/workspace-session-browser-history' import { resolveBrowserAddressBarSubmission } from '../navigate/browser-address-bar-navigation' import { routeWorkspaceDocAddressSubmission } from '../navigate/workspace-doc-address-submission' -import { deferBrowserPageNavigation } from '../navigate/browser-page-deferred-navigation' -import type { - BrowserBackResult, - BrowserGotoResult, - BrowserReloadResult, - BrowserTabInfo -} from '../../../../../shared/browser/runtime/runtime-browser-contracts' +import type { BrowserTabInfo } from '../../../../../shared/browser/runtime/runtime-browser-contracts' import type { BrowserPage as BrowserPageState } from '../../../../../shared/browser-workspace-types' -import { callRuntimeRpc } from '@/runtime/rpc/calls/runtime-rpc-call' -import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' import type { RemoteBrowserOperationToken } from './remote-browser-stream-tokens' import type { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' -import type { BrowserPageUrlSetter, BrowserTabPageState } from '../describe-page/browser-page-types' +import type { + BrowserPageUrlSetter, + BrowserPageStateWriter +} from '../describe-page/browser-page-types' import { getBrowserDisplayTitle, toDisplayUrl } from '../describe-page/browser-page-url-display' import type { RemoteBrowserPaneNotice, @@ -51,11 +47,12 @@ export function useRemoteBrowserPageNavigation({ isCurrentRemoteOperationToken: (token: RemoteBrowserOperationToken) => boolean closeMissingRemotePage: (remotePageId?: string | null) => void onSetUrl: BrowserPageUrlSetter - onUpdatePageState: (tabId: string, updates: BrowserTabPageState) => void + onUpdatePageState: BrowserPageStateWriter setPaneNotice: (notice: RemoteBrowserPaneNotice | null) => void setPaneBusy: (busy: boolean) => void }): { - applyRemoteTabInfo: (tab: Pick) => void + applyRemoteTabInfo: (tab: BrowserTabInfo) => void + prepareCertificateProceed: () => void scheduleRemoteTabInfoRefresh: (token: RemoteBrowserOperationToken, delayMs?: number) => void runRemoteNavigation: ( method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', @@ -66,16 +63,26 @@ export function useRemoteBrowserPageNavigation({ } { const addBrowserHistoryEntry = useAppStore((state) => state.addBrowserHistoryEntry) const lastFiledHistoryRef = useRef(null) + const navigationSequence = useRef(0) - const applyRemoteTabInfo = useCallback( - (tab: Pick): void => { + const applyRemoteTabMetadata = useCallback( + ( + tab: Pick & Partial> + ): void => { const safeUrl = redactKagiSessionToken(tab.url || 'about:blank') const title = getBrowserDisplayTitle(tab.title, safeUrl) - onSetUrl(browserTab.id, safeUrl) + const page = useAppStore + .getState() + .browserPagesByWorkspace[browserTab.workspaceId]?.find( + (entry) => entry.id === browserTab.id + ) + if (page?.url !== safeUrl) { + onSetUrl(browserTab.id, safeUrl, { preserveLoadError: true }) + } onUpdatePageState(browserTab.id, { title, loading: false, - loadError: null + ...(tab.loadError !== undefined ? { loadError: tab.loadError } : {}) }) // File remote navigations in client suggestion history only when URL/title changes; post-input tab reads must not rewrite the store. const filing = `${normalizeBrowserHistoryUrl(safeUrl)}\n${title}` @@ -85,9 +92,37 @@ export function useRemoteBrowserPageNavigation({ } setAddressBarValueFromPage(toDisplayUrl(safeUrl)) }, - [addBrowserHistoryEntry, browserTab.id, onSetUrl, onUpdatePageState, setAddressBarValueFromPage] + [ + addBrowserHistoryEntry, + browserTab.id, + browserTab.workspaceId, + onSetUrl, + onUpdatePageState, + setAddressBarValueFromPage + ] ) + const applyRemoteTabInfo = useCallback( + (tab: BrowserTabInfo): void => { + applyRemoteTabMetadata({ ...tab, loadError: tab.loadError ?? null }) + useAppStore + .getState() + .setBrowserPageCertificateFailure(browserTab.id, tab.certificateFailure ?? null) + }, + [applyRemoteTabMetadata, browserTab.id] + ) + + const prepareCertificateProceed = useCallback((): void => { + navigationSequence.current += 1 + const token = createRemoteOperationToken() + if (token) { + lifecycle.session.setPageLoading(token, false, onUpdatePageState) + } + lifecycle.session.invalidateTabInfoReads() + setPaneBusy(false) + setPaneNotice(null) + }, [createRemoteOperationToken, lifecycle, onUpdatePageState, setPaneBusy, setPaneNotice]) + const scheduleRemoteTabInfoRefresh = useCallback( (token: RemoteBrowserOperationToken, delayMs = 250): void => { lifecycle.session.scheduleTabInfoRefresh(token, delayMs) @@ -100,76 +135,29 @@ export function useRemoteBrowserPageNavigation({ method: 'browser.goto' | 'browser.back' | 'browser.forward' | 'browser.reload', url?: string ) => { - const target = runtimeTarget() - if (!target) { - return - } - if (stagedPage) { - // No staged host page exists yet: park goto until adoption; history/reload cannot replay against an uncreated page. - if (method === 'browser.goto' && url) { - deferBrowserPageNavigation(browserTab.id, url) - onUpdatePageState(browserTab.id, { loading: true, loadError: null }) - } - return - } - const operationToken = createRemoteOperationToken() - if (!operationToken) { - return - } - const pageId = await lifecycle.session.ensureRemotePage(operationToken) - if (!pageId) { - return - } - const pageToken = { ...operationToken, remotePageId: pageId } - if (!isCurrentRemoteOperationToken(pageToken)) { - return - } - setPaneBusy(true) - setPaneNotice(null) - // Why: a standing failure (and its auto-retry) stays up until a load succeeds or fails anew. - onUpdatePageState(browserTab.id, { loading: true }) - try { - const params = - method === 'browser.goto' - ? { worktree: runtimeWorktree, page: pageId, url: url ?? 'about:blank' } - : { worktree: runtimeWorktree, page: pageId } - const result = await callRuntimeRpc< - BrowserGotoResult | BrowserBackResult | BrowserReloadResult - >(target, method, params, { timeoutMs: 30_000, suppressFeatureInteraction: true }) - if (isCurrentRemoteOperationToken(pageToken)) { - applyRemoteTabInfo(result) - } - } catch (error) { - if (!isCurrentRemoteOperationToken(pageToken)) { - return - } - if (isRemoteBrowserPageMissingError(error)) { - closeMissingRemotePage(pageId) - return - } - const message = error instanceof Error ? error.message : 'Remote browser command failed.' - setPaneNotice({ kind: 'consequence', text: message }) - // Why: goto results carry no load error, but the rejection keeps Electron's "ERR_NAME (-code)" text. - const netError = /\b(ERR_[A-Z_]+) \((-\d+)\)/.exec(message) - onUpdatePageState(browserTab.id, { - loading: false, - // Why: validatedUrl is persisted, so redact the Kagi session token like the main-process failure path does. - loadError: { - code: netError ? Number(netError[2]) : 0, - description: netError ? netError[1] : message, - validatedUrl: redactKagiSessionToken(url ?? browserTab.url) - } - }) - } finally { - if (isCurrentRemoteOperationToken(pageToken)) { - setPaneBusy(false) - } - } + return runRemoteBrowserNavigationCommand( + { + browserTab, + stagedPage, + lifecycle, + runtimeWorktree, + runtimeTarget, + createRemoteOperationToken, + isCurrentRemoteOperationToken, + closeMissingRemotePage, + onUpdatePageState, + setPaneNotice, + setPaneBusy, + navigationSequence, + applyRemoteTabMetadata + }, + method, + url + ) }, [ - applyRemoteTabInfo, - browserTab.id, - browserTab.url, + applyRemoteTabMetadata, + browserTab, createRemoteOperationToken, lifecycle, closeMissingRemotePage, @@ -216,6 +204,7 @@ export function useRemoteBrowserPageNavigation({ return { applyRemoteTabInfo, + prepareCertificateProceed, scheduleRemoteTabInfoRefresh, runRemoteNavigation, navigateToUrl, diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts index 2b279627285..02002cd9c8c 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-stream.ts @@ -58,7 +58,7 @@ export function useRemoteBrowserPageStream({ pendingFrameDecodeRef: React.MutableRefObject streamBridgeRef: React.MutableRefObject isActiveRef: React.RefObject - applyTabInfo: (tab: Pick) => void + applyTabInfo: (tab: BrowserTabInfo) => void clearStreamFrame: () => void closeMissingRemotePage: (remotePageId?: string | null) => void clearPendingRemoteWheel: () => void diff --git a/src/renderer/src/lib/browser/browser-page-loading-ownership.ts b/src/renderer/src/lib/browser/browser-page-loading-ownership.ts new file mode 100644 index 00000000000..2f95e267a37 --- /dev/null +++ b/src/renderer/src/lib/browser/browser-page-loading-ownership.ts @@ -0,0 +1,39 @@ +import type { BrowserPage } from '../../../../shared/browser-workspace-types' + +export type BrowserPageLoadingOwner = { active: boolean } + +const loadingOwners = new WeakMap() + +export function readBrowserPageLoadingOwner( + page: BrowserPage | undefined +): BrowserPageLoadingOwner | undefined { + const owner = page && loadingOwners.get(page) + return owner?.active ? owner : undefined +} + +export function revokeBrowserPageLoadingOwner(page: BrowserPage): void { + const owner = loadingOwners.get(page) + if (owner) { + owner.active = false + loadingOwners.delete(page) + } +} + +export function preserveBrowserPageLoadingOwner(previous: BrowserPage, next: BrowserPage): void { + const owner = readBrowserPageLoadingOwner(previous) + if (owner && previous !== next) { + loadingOwners.delete(previous) + loadingOwners.set(next, owner) + } +} + +export function assignBrowserPageLoadingOwner( + previous: BrowserPage, + next: BrowserPage, + owner?: BrowserPageLoadingOwner +): void { + revokeBrowserPageLoadingOwner(previous) + if (owner?.active) { + loadingOwners.set(next, owner) + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts index 5a9834619f5..612e2106725 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-browser-records.ts @@ -1,5 +1,9 @@ import { annotateBrowserPageHandles } from '@/store/slices/browser/hosts/browser-initialization-presentation-ownership' import type { applyTerminalRecordUpdates } from './apply-terminal-records' +import { + preserveBrowserPageLoadingOwner, + revokeBrowserPageLoadingOwner +} from '@/lib/browser/browser-page-loading-ownership' import { browserCertificateFailureEqual, optionalRuntimeBrowserPlacementsEqual, @@ -50,6 +54,7 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) { if (nextBrowserPageIds.has(page.id)) { continue } + revokeBrowserPageLoadingOwner(page) if (nextBrowserCertificateFailuresByPageId[page.id]) { nextBrowserCertificateFailuresByPageId = nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId @@ -79,12 +84,21 @@ export function applyBrowserRecordUpdates(context: BrowserRecordContext) { for (const { page, + previousPage, + preservesLoading, certificateFailure, remotePageId, browserProfileId, placement, initializationPresentation } of mirroredBrowserTabs) { + if (previousPage) { + if (preservesLoading) { + preserveBrowserPageLoadingOwner(previousPage, page) + } else { + revokeBrowserPageLoadingOwner(previousPage) + } + } const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] if (!sameBrowserPages(current, [page])) { nextBrowserPagesByWorkspace = diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts index 2a0c094c33d..0dac89a95e9 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-browser-tabs.ts @@ -128,7 +128,13 @@ export function resolveMirroredBrowserPageContent( previousPresentation?: BrowserInitializationPresentation, stale = false, reconcile = false -): Pick { +): { + content: Pick< + BrowserPage, + 'url' | 'title' | 'loading' | 'canGoBack' | 'canGoForward' | 'faviconUrl' + > + preservesLoading: boolean +} { const localAction = existingPage && hasBrowserPageInitializationAction(existingPage.id) if ( existingPage && @@ -137,27 +143,33 @@ export function resolveMirroredBrowserPageContent( (!reconcile && clientHostsMirroredBrowserPage(tab) && !presentation && !previousPresentation)) ) { return { - url: existingPage.url, - title: existingPage.title, - loading: - presentation?.disposition === 'pending' - ? true - : presentation - ? false - : existingPage.loading, - canGoBack: existingPage.canGoBack, - canGoForward: existingPage.canGoForward, - faviconUrl: existingPage.faviconUrl + preservesLoading: !presentation, + content: { + url: existingPage.url, + title: existingPage.title, + loading: + presentation?.disposition === 'pending' + ? true + : presentation + ? false + : existingPage.loading, + canGoBack: existingPage.canGoBack, + canGoForward: existingPage.canGoForward, + faviconUrl: existingPage.faviconUrl + } } } return { - url: tab.url, - title: resolveMirroredBrowserTitle(tab, existingPage), - loading: tab.loading, - canGoBack: tab.canGoBack, - canGoForward: tab.canGoForward, - // Old hosts omit the field; keep what this client already has. - faviconUrl: tab.faviconUrl === undefined ? (existingPage?.faviconUrl ?? null) : tab.faviconUrl + preservesLoading: false, + content: { + url: tab.url, + title: resolveMirroredBrowserTitle(tab, existingPage), + loading: tab.loading, + canGoBack: tab.canGoBack, + canGoForward: tab.canGoForward, + // Old hosts omit the field; keep what this client already has. + faviconUrl: tab.faviconUrl === undefined ? (existingPage?.faviconUrl ?? null) : tab.faviconUrl + } } } @@ -210,7 +222,7 @@ export function buildMirroredBrowserTabs( const groupId = clientGroupId ?? hostGroupId const previousHandle = existing ? state.remoteBrowserPageHandlesByPageId[pageId] : undefined const presentation = browserInitializationPresentation(snapshot, tab, previousHandle) - const content = resolveMirroredBrowserPageContent( + const { content, preservesLoading } = resolveMirroredBrowserPageContent( tab, existing?.page, presentation, @@ -270,6 +282,8 @@ export function buildMirroredBrowserTabs( return { workspace, page, + previousPage: existing?.page, + preservesLoading, certificateFailure: tab.certificateFailure ?? null, remotePageId: tab.browserPageId, initializationPresentation: presentation, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index ef3e006cf6b..694d63e95ac 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -190,6 +190,8 @@ export type MirroredTerminalTab = { export type MirroredBrowserTab = { workspace: BrowserWorkspace page: BrowserPage + previousPage?: BrowserPage + preservesLoading: boolean certificateFailure: BrowserCertificateFailure | null remotePageId: string browserProfileId?: string diff --git a/src/renderer/src/store/slices/browser/browser-slice-contract.ts b/src/renderer/src/store/slices/browser/browser-slice-contract.ts index e6b9238b7f1..6831f7383f2 100644 --- a/src/renderer/src/store/slices/browser/browser-slice-contract.ts +++ b/src/renderer/src/store/slices/browser/browser-slice-contract.ts @@ -1,4 +1,5 @@ import type { StateCreator } from 'zustand' +import type { BrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership' import type { ManagedBrowserProfileOwner } from '../../../../../shared/browser/profiles/browser-profile-scope' /** What a new tab opens on; a partition only when it must not be derived from the profile. */ @@ -190,8 +191,16 @@ export type BrowserSlice = { options?: { surfacePane?: boolean } ) => void consumeAddressBarFocusRequest: (pageId: string) => boolean - updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void - updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void + updateBrowserTabPageState: ( + pageId: string, + updates: BrowserTabPageState, + loadingOwner?: BrowserPageLoadingOwner + ) => void + updateBrowserPageState: ( + pageId: string, + updates: BrowserTabPageState, + loadingOwner?: BrowserPageLoadingOwner + ) => void setBrowserPageCertificateFailure: ( pageId: string, failure: BrowserCertificateFailure | null diff --git a/src/renderer/src/store/slices/browser/pages/browser-page-records.ts b/src/renderer/src/store/slices/browser/pages/browser-page-records.ts index 1bd356ad44c..04589f91bb5 100644 --- a/src/renderer/src/store/slices/browser/pages/browser-page-records.ts +++ b/src/renderer/src/store/slices/browser/pages/browser-page-records.ts @@ -97,12 +97,17 @@ export function mirrorWorkspaceFromActivePage( workspace: BrowserWorkspace, pages: BrowserPage[] ): BrowserWorkspace { + const pageIds = + workspace.pageIds?.length === pages.length && + pages.every((page, index) => page.id === workspace.pageIds?.[index]) + ? workspace.pageIds + : pages.map((page) => page.id) const activePage = pages.find((page) => page.id === workspace.activePageId) ?? null if (!activePage) { return { ...workspace, activePageId: null, - pageIds: pages.map((page) => page.id), + pageIds, url: 'about:blank', title: translate('auto.store.slices.browser.08fc23631d', 'Browser'), loading: false, @@ -116,7 +121,7 @@ export function mirrorWorkspaceFromActivePage( return { ...workspace, activePageId: activePage.id, - pageIds: pages.map((page) => page.id), + pageIds, url: activePage.url, title: activePage.title, loading: activePage.loading, @@ -136,8 +141,9 @@ export function browserWorkspaceMirrorFieldsEqual( const mirroredPageIds = mirrored.pageIds ?? [] return ( workspace.activePageId === mirrored.activePageId && - workspacePageIds.length === mirroredPageIds.length && - workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]) && + (workspacePageIds === mirroredPageIds || + (workspacePageIds.length === mirroredPageIds.length && + workspacePageIds.every((pageId, index) => pageId === mirroredPageIds[index]))) && workspace.url === mirrored.url && workspace.title === mirrored.title && workspace.loading === mirrored.loading && diff --git a/src/renderer/src/store/slices/browser/pages/browser-page-state-actions.ts b/src/renderer/src/store/slices/browser/pages/browser-page-state-actions.ts index 65c73269684..79718fc4db3 100644 --- a/src/renderer/src/store/slices/browser/pages/browser-page-state-actions.ts +++ b/src/renderer/src/store/slices/browser/pages/browser-page-state-actions.ts @@ -1,5 +1,10 @@ import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from '../browser-slice-contract' import type { AppState } from '../../../types' +import { + assignBrowserPageLoadingOwner, + preserveBrowserPageLoadingOwner, + revokeBrowserPageLoadingOwner +} from '@/lib/browser/browser-page-loading-ownership' import '../../../../../../shared/constants' import { ORCA_BROWSER_BLANK_URL } from '../../../../../../shared/browser/browser-blank-page' import { @@ -12,6 +17,10 @@ import { } from './browser-page-records' import { normalizeBrowserHistoryUrl } from '../../../../../../shared/workspace-session/workspace-session-browser-history' import { replaceTabBuckets } from '../../../keyed-record-transitions' +import { + browserCertificateFailureEqual, + browserPageEqual +} from '@/runtime/web-session-tabs-sync/state-equality-tabs' export function createBrowserPageStateActions( set: BrowserSliceSet, @@ -25,9 +34,10 @@ export function createBrowserPageStateActions( | 'setBrowserPageUrl' > { return { - updateBrowserTabPageState: (pageId, updates) => get().updateBrowserPageState(pageId, updates), + updateBrowserTabPageState: (pageId, updates, loadingOwner) => + get().updateBrowserPageState(pageId, updates, loadingOwner), - updateBrowserPageState: (pageId, updates) => { + updateBrowserPageState: (pageId, updates, loadingOwner) => { set((s) => { const page = findPage(s.browserPagesByWorkspace, pageId) if (!page) { @@ -58,13 +68,12 @@ export function createBrowserPageStateActions( : -1 const unifiedLabelNeedsRepair = unifiedIndex !== -1 && unifiedTabs[unifiedIndex]?.label !== nextPage.title - const pageStateUnchanged = - nextPage.title === page.title && - nextPage.loading === page.loading && - nextPage.faviconUrl === page.faviconUrl && - nextPage.canGoBack === page.canGoBack && - nextPage.canGoForward === page.canGoForward && - nextPage.loadError === page.loadError + const pageStateUnchanged = browserPageEqual(nextPage, page) + if (updates.loading === undefined) { + preserveBrowserPageLoadingOwner(page, pageStateUnchanged ? page : nextPage) + } else { + assignBrowserPageLoadingOwner(page, pageStateUnchanged ? page : nextPage, loadingOwner) + } const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] const mirroredWorkspace = pageStateUnchanged ? mirrorWorkspaceFromActivePage(workspace, currentPages) @@ -158,7 +167,10 @@ export function createBrowserPageStateActions( delete nextFailures[pageId] return { browserCertificateFailuresByPageId: nextFailures } } - if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { + if ( + !findPage(s.browserPagesByWorkspace, pageId) || + browserCertificateFailureEqual(current, failure) + ) { return s } return { @@ -191,6 +203,7 @@ export function createBrowserPageStateActions( } // Keep document URL blank at navigation sink too; committing grants here leaks to persistence, publication and address bar. const nextPageUrl = page.docLocation ? ORCA_BROWSER_BLANK_URL : nextUrl + revokeBrowserPageLoadingOwner(page) const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => entry.id === pageId ? { @@ -217,7 +230,9 @@ export function createBrowserPageStateActions( } } }) - get().setBrowserPageCertificateFailure(pageId, null) + if (!options?.preserveLoadError) { + get().setBrowserPageCertificateFailure(pageId, null) + } } } } diff --git a/src/renderer/src/store/slices/browser/pages/browser-page-viewport-actions.ts b/src/renderer/src/store/slices/browser/pages/browser-page-viewport-actions.ts index 7c3076387aa..2117a92d616 100644 --- a/src/renderer/src/store/slices/browser/pages/browser-page-viewport-actions.ts +++ b/src/renderer/src/store/slices/browser/pages/browser-page-viewport-actions.ts @@ -1,5 +1,6 @@ import type { BrowserSlice, BrowserSliceSet } from '../browser-slice-contract' import { findPage, findWorkspace } from './browser-page-records' +import { preserveBrowserPageLoadingOwner } from '@/lib/browser/browser-page-loading-ownership' export function createBrowserPageViewportActions( set: BrowserSliceSet @@ -15,9 +16,14 @@ export function createBrowserPageViewportActions( if (!workspace) { return s } - const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => - entry.id === pageId ? { ...entry, viewportPresetId } : entry - ) + const nextPages = (s.browserPagesByWorkspace[workspace.id] ?? []).map((entry) => { + if (entry.id !== pageId) { + return entry + } + const nextPage = { ...entry, viewportPresetId } + preserveBrowserPageLoadingOwner(entry, nextPage) + return nextPage + }) return { browserPagesByWorkspace: { ...s.browserPagesByWorkspace,