Skip to content

Commit 901bb96

Browse files
committed
fix(browser): pause native previews while window is minimized
1 parent 03612ce commit 901bb96

5 files changed

Lines changed: 341 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Browser scene
2+
3+
Native child-view visibility combines the controller window state, active panel,
4+
toolbar transitions, and floating-surface occlusion. Keep native window event
5+
subscriptions in the infrastructure adapter and share them across cached panels.
6+
Do not use window focus alone as visibility: an unfocused window can remain visible.
7+
8+
For preview scheduling and native visibility changes, run:
9+
10+
```bash
11+
pnpm --dir src/web-ui run test:run src/app/scenes/browser src/infrastructure/runtime/windowVisibility.test.ts
12+
```
13+
14+
These tests cover event races, scheduling and DOM ownership. They do not establish
15+
native rendering behavior. For minimize/restore changes, also use a real desktop
16+
WebView: verify native minimized state, capture counts, child document visibility
17+
and animation progress while minimized and after restoration. Check that inactive
18+
or occluded panels remain hidden after restoring the window. Report the tested OS
19+
and build profile; local fixtures do not establish remote-scenario behavior.

‎src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ const mocks = vi.hoisted(() => ({
88
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => {}),
99
view: { label: 'test-browser', show: vi.fn(async () => {}), hide: vi.fn(async () => {}),
1010
close: vi.fn(async () => {}), setFocus: vi.fn(async () => {}) },
11+
hostVisible: true,
12+
hostListeners: new Set<(visible: boolean) => void>(),
13+
}));
14+
vi.mock('@/infrastructure/runtime/windowVisibility', () => ({
15+
observeCurrentWindowVisibility: (changed: (visible: boolean) => void) => {
16+
mocks.hostListeners.add(changed);
17+
changed(mocks.hostVisible);
18+
return () => mocks.hostListeners.delete(changed);
19+
},
1120
}));
1221
vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } }));
1322
vi.mock('@tauri-apps/api/webview', () => ({ Webview: { getByLabel: async () => mocks.view } }));
@@ -25,6 +34,8 @@ let container: HTMLDivElement;
2534
let viewportBounds: DOMRect;
2635
beforeEach(() => {
2736
vi.clearAllMocks();
37+
mocks.hostVisible = true;
38+
mocks.hostListeners.clear();
2839
mocks.invoke.mockImplementation(async () => {});
2940
vi.stubGlobal('__TAURI__', {});
3041
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
@@ -40,6 +51,7 @@ afterEach(async () => {
4051
document.body.replaceChildren();
4152
vi.restoreAllMocks();
4253
vi.unstubAllGlobals();
54+
vi.useRealTimers();
4355
});
4456

4557
async function render(active = true) {
@@ -55,6 +67,63 @@ function overlay() {
5567
return node;
5668
}
5769

70+
async function hostVisibility(visible: boolean) {
71+
await mutate(() => {
72+
mocks.hostVisible = visible;
73+
mocks.hostListeners.forEach(changed => changed(visible));
74+
});
75+
}
76+
77+
it('stops preview capture while minimized and resumes without changing the active interval', async () => {
78+
vi.useFakeTimers();
79+
vi.stubGlobal('Image', class { src = ''; decode = async () => {}; });
80+
let frame = 0;
81+
mocks.invoke.mockImplementation(async command => command === 'browser_webview_capture_preview'
82+
? { status: 'ready', dataUrl: `data:image/jpeg;base64,${frame++}` } : undefined);
83+
const captures = () => mocks.invoke.mock.calls.filter(call => call[0] === 'browser_webview_capture_preview').length;
84+
await render();
85+
await act(async () => vi.advanceTimersByTimeAsync(1));
86+
expect(captures()).toBe(1);
87+
await hostVisibility(false);
88+
expect(mocks.view.hide).toHaveBeenCalledOnce();
89+
await act(async () => vi.advanceTimersByTimeAsync(30_000));
90+
expect(captures()).toBe(1);
91+
await hostVisibility(true);
92+
expect(mocks.view.show).toHaveBeenCalledTimes(2);
93+
await act(async () => vi.advanceTimersByTimeAsync(1));
94+
expect(captures()).toBe(2);
95+
await act(async () => vi.advanceTimersByTimeAsync(5000));
96+
expect(captures()).toBe(3);
97+
});
98+
99+
it('restores only active, unoccluded views and does not steal focus on window restoration', async () => {
100+
await render();
101+
await hostVisibility(false);
102+
await render(false);
103+
await hostVisibility(true);
104+
expect(mocks.view.show).toHaveBeenCalledTimes(1);
105+
const panel = overlay();
106+
await mutate(() => document.body.append(panel));
107+
await render(true);
108+
await hostVisibility(false);
109+
await hostVisibility(true);
110+
expect(mocks.view.show).toHaveBeenCalledTimes(1);
111+
mocks.view.setFocus.mockClear();
112+
await mutate(() => panel.remove());
113+
expect(mocks.view.show).toHaveBeenCalledTimes(2);
114+
expect(mocks.view.setFocus).not.toHaveBeenCalled();
115+
});
116+
117+
it('keeps a newly created view hidden when the host is already minimized', async () => {
118+
mocks.hostVisible = false;
119+
await render();
120+
expect(mocks.view.show).not.toHaveBeenCalled();
121+
expect(mocks.view.setFocus).not.toHaveBeenCalled();
122+
await hostVisibility(true);
123+
expect(mocks.view.show).toHaveBeenCalledOnce();
124+
expect(mocks.view.setFocus).not.toHaveBeenCalled();
125+
});
126+
58127
it('keeps creation, navigation, resize and activation hidden while any floating panel overlaps', async () => {
59128
const first = overlay();
60129
const second = overlay();

‎src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { validateUrl } from './browserUrlCheck';
55
import { BrowserPreviewCache, prepareBrowserPreview, type BrowserPreviewResponse } from './browserPreviewCache';
66
import { alignBrowserViewport, type BrowserViewportBounds } from './browserViewportGeometry';
77
import { api } from '@/infrastructure/api/service-api/ApiClient';
8+
import { observeCurrentWindowVisibility } from '@/infrastructure/runtime/windowVisibility';
89
import { createNativeWebviewVisibility, hasNativeWebviewOccluder } from './nativeWebviewVisibility';
910
import { observeNativeWebviewOcclusion } from './nativeWebviewOcclusionObserver';
1011
export { NATIVE_WEBVIEW_OCCLUSION_SELECTOR, rectanglesIntersect } from './nativeWebviewVisibility';
@@ -183,6 +184,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti
183184
const mountedRef = useRef(true);
184185
const toolbarSuspendedRef = useRef(false);
185186
const nativeVisibleRef = useRef(false);
187+
const hostVisibleRef = useRef(false);
186188
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
187189
const [previewBounds, setPreviewBounds] = useState<BrowserViewportBounds | null>(null);
188190
const preview = useMemo(() => new BrowserPreviewCache({
@@ -193,7 +195,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti
193195
}), [log]);
194196
const visibility = useMemo(() => createNativeWebviewVisibility(target => {
195197
const viewport = viewportRef.current;
196-
if (!mountedRef.current || !activeRef.current || toolbarSuspendedRef.current
198+
if (!mountedRef.current || !hostVisibleRef.current || !activeRef.current || toolbarSuspendedRef.current
197199
|| target !== webviewRef.current || !viewport?.isConnected) return false;
198200
const rect = viewport.getBoundingClientRect();
199201
// Keep the last valid geometry during transient layout swaps, but still
@@ -210,7 +212,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti
210212
}, (target, visible) => {
211213
if (target !== webviewRef.current) return;
212214
nativeVisibleRef.current = visible;
213-
preview.setVisible(visible && document.visibilityState !== 'hidden');
215+
preview.setVisible(visible && hostVisibleRef.current);
214216
}), [preview]);
215217

216218
const syncVisibility = useCallback(async (focus = false) => {
@@ -501,12 +503,21 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti
501503
useEffect(() => {
502504
mountedRef.current = true;
503505
preview.resume();
504-
const handleDocumentVisibility = () => preview.setVisible(nativeVisibleRef.current && document.visibilityState !== 'hidden');
505-
document.addEventListener('visibilitychange', handleDocumentVisibility);
506+
const stopObservingWindow = observeCurrentWindowVisibility(visible => {
507+
hostVisibleRef.current = visible;
508+
// Stop scheduling immediately; native hide may wait for a pending show.
509+
if (!visible) preview.setVisible(false);
510+
const handle = webviewRef.current;
511+
if (handle) void visibility(handle).then(() => {
512+
preview.setVisible(hostVisibleRef.current && nativeVisibleRef.current);
513+
}).catch(syncError => {
514+
log.warn('Update browser webview host visibility failed', syncError);
515+
});
516+
}, windowError => log.warn('Read browser host window visibility failed', windowError));
506517
return () => {
507518
mountedRef.current = false;
508519
preview.dispose();
509-
document.removeEventListener('visibilitychange', handleDocumentVisibility);
520+
stopObservingWindow();
510521
pageLoadUnlistenRef.current?.();
511522
pageLoadUnlistenRef.current = null;
512523
if (resizeTimerRef.current !== null) {
@@ -519,7 +530,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti
519530
void visibility(handle).catch(() => {}).then(() => closeWebview(handle));
520531
}
521532
};
522-
}, [closeWebview, preview, visibility]);
533+
}, [closeWebview, log, preview, visibility]);
523534

524535
useEffect(() => {
525536
if (!isTauri || !isVisible) return;
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
3+
import { observeCurrentWindowVisibility } from './windowVisibility';
4+
5+
const mocks = vi.hoisted(() => ({
6+
native: true,
7+
isVisible: vi.fn(async () => true),
8+
isMinimized: vi.fn(async () => false),
9+
onResized: vi.fn(),
10+
onFocusChanged: vi.fn(),
11+
}));
12+
vi.mock('./environment', () => ({ supportsNativeWindowControls: () => mocks.native }));
13+
vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => mocks }));
14+
let resize: () => void;
15+
let focus: () => void;
16+
let cleanup: (() => void)[];
17+
let stopResize: ReturnType<typeof vi.fn>;
18+
let stopFocus: ReturnType<typeof vi.fn>;
19+
const error = vi.fn();
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
mocks.native = true;
23+
mocks.isVisible.mockResolvedValue(true);
24+
mocks.isMinimized.mockResolvedValue(false);
25+
stopResize = vi.fn();
26+
stopFocus = vi.fn();
27+
mocks.onResized.mockImplementation(async callback => { resize = callback; return stopResize; });
28+
mocks.onFocusChanged.mockImplementation(async callback => { focus = callback; return stopFocus; });
29+
cleanup = [];
30+
});
31+
afterEach(() => { cleanup.forEach(stop => stop()); vi.restoreAllMocks(); });
32+
function observe() {
33+
const changed = vi.fn();
34+
cleanup.push(observeCurrentWindowVisibility(changed, error));
35+
return changed;
36+
}
37+
async function settle() { for (let step = 0; step < 30; step++) await Promise.resolve(); }
38+
39+
it('pauses on native minimization even when the document remains visible and resumes on restore', async () => {
40+
const changed = observe();
41+
expect(changed).toHaveBeenLastCalledWith(false);
42+
await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(true));
43+
mocks.isMinimized.mockResolvedValue(true);
44+
resize();
45+
await settle();
46+
expect(document.visibilityState).toBe('visible');
47+
expect(changed).toHaveBeenLastCalledWith(false);
48+
mocks.isMinimized.mockResolvedValue(false);
49+
resize();
50+
await settle();
51+
expect(changed).toHaveBeenLastCalledWith(true);
52+
expect(changed).toHaveBeenCalledTimes(4);
53+
});
54+
55+
it('shares subscriptions and native reads across cached panels without treating blur as hidden', async () => {
56+
const first = observe();
57+
const second = observe();
58+
await vi.waitFor(() => expect(second).toHaveBeenLastCalledWith(true));
59+
expect(mocks.onResized).toHaveBeenCalledOnce();
60+
expect(mocks.onFocusChanged).toHaveBeenCalledOnce();
61+
expect(mocks.isMinimized).toHaveBeenCalledOnce();
62+
focus();
63+
await settle();
64+
expect(first).toHaveBeenCalledTimes(2);
65+
cleanup[0]();
66+
expect(stopResize).not.toHaveBeenCalled();
67+
cleanup[1]();
68+
expect(stopResize).toHaveBeenCalledOnce();
69+
expect(stopFocus).toHaveBeenCalledOnce();
70+
cleanup = [];
71+
});
72+
73+
it('discards stale snapshots and serializes a burst of native events', async () => {
74+
const changed = observe();
75+
await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(true));
76+
let finish!: (minimized: boolean) => void;
77+
mocks.isMinimized.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
78+
resize();
79+
resize();
80+
focus();
81+
expect(mocks.isMinimized).toHaveBeenCalledTimes(2);
82+
finish(true);
83+
await settle();
84+
expect(mocks.isMinimized).toHaveBeenCalledTimes(3);
85+
expect(changed).toHaveBeenCalledTimes(2);
86+
});
87+
88+
it('removes listeners that finish registering after the last subscriber leaves', async () => {
89+
let finish!: (stop: () => void) => void;
90+
mocks.onResized.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
91+
const changed = observe();
92+
await vi.waitFor(() => expect(mocks.onResized).toHaveBeenCalledOnce());
93+
cleanup[0]();
94+
cleanup = [];
95+
finish(stopResize);
96+
await settle();
97+
expect(stopResize).toHaveBeenCalledOnce();
98+
expect(mocks.onFocusChanged).not.toHaveBeenCalled();
99+
expect(changed).toHaveBeenCalledTimes(1);
100+
});
101+
102+
it('uses document visibility on web without invoking native APIs', async () => {
103+
mocks.native = false;
104+
const changed = observe();
105+
expect(changed).toHaveBeenLastCalledWith(true);
106+
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
107+
document.dispatchEvent(new Event('visibilitychange'));
108+
expect(changed).toHaveBeenLastCalledWith(false);
109+
visibility.mockReturnValue('visible');
110+
document.dispatchEvent(new Event('visibilitychange'));
111+
expect(changed).toHaveBeenLastCalledWith(true);
112+
expect(mocks.onResized).not.toHaveBeenCalled();
113+
});
114+
115+
it('keeps native hidden state through document visibility changes and recovers from read failure', async () => {
116+
mocks.isMinimized.mockResolvedValue(true);
117+
const changed = observe();
118+
await vi.waitFor(() => expect(mocks.isMinimized).toHaveBeenCalledOnce());
119+
await settle();
120+
document.dispatchEvent(new Event('visibilitychange'));
121+
await settle();
122+
expect(changed).toHaveBeenCalledTimes(1);
123+
mocks.isMinimized.mockRejectedValueOnce(new Error('Transient window query failure'));
124+
resize();
125+
await settle();
126+
expect(error).toHaveBeenCalledOnce();
127+
expect(changed).toHaveBeenLastCalledWith(false);
128+
mocks.isMinimized.mockResolvedValue(false);
129+
focus();
130+
await settle();
131+
expect(changed).toHaveBeenLastCalledWith(true);
132+
});

0 commit comments

Comments
 (0)