Skip to content

Commit 20aaaae

Browse files
Keep completed WebView history updates from restarting loading
1 parent c7bd025 commit 20aaaae

2 files changed

Lines changed: 86 additions & 1 deletion

File tree

‎apps/mobile/src/screens/BrowserScreen.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
120120
ref={webRef}
121121
source={{ uri }}
122122
style={styles.web}
123-
onLoadStart={() => setLoading(true)}
123+
// Android also emits load-start for history updates after loading ends.
124+
onLoadStart={(event) => setLoading(event.nativeEvent.loading)}
124125
onLoadEnd={() => setLoading(false)}
125126
onNavigationStateChange={(state) => {
126127
setAddress(state.url);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Render the real BrowserScreen/App and drive public WebView callbacks.
3+
* Android RNCWebViewClient.doUpdateVisitedHistory emits a loading-start event
4+
* even when loading=false; WebViewShared forwards it to onLoadStart. Such a
5+
* history update need not have a later load-end event. Native dispatch itself
6+
* is not executed by these component tests.
7+
*/
8+
import { describe, expect, it } from 'vitest';
9+
import App from '../App';
10+
import { BrowserScreen } from '../src/screens/BrowserScreen';
11+
import { fire, hosts, hostWhere, renderScreen, switchTab } from './harness';
12+
import { Platform } from './mocks/react-native';
13+
import { theWebView, webViewRegistry } from './mocks/react-native-webview';
14+
15+
import type { ReactTestInstance } from 'react-test-renderer';
16+
17+
const webView = (root: ReactTestInstance) =>
18+
hostWhere(root, 'WebView', () => true, 'browser WebView');
19+
20+
const navigationEvent = (loading: boolean, url = 'https://example.test/page') => ({
21+
nativeEvent: { url, loading, title: '', canGoBack: true, canGoForward: false, target: 1 },
22+
});
23+
24+
describe('BrowserScreen loading state', () => {
25+
it.each(['android', 'ios'] as const)('shows a real pending load and clears it on %s', async (platform) => {
26+
Platform.OS = platform;
27+
const { root } = await renderScreen(<BrowserScreen />);
28+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
29+
await fire(webView(root), 'onLoadStart', navigationEvent(true));
30+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(1);
31+
await fire(webView(root), 'onLoadEnd', navigationEvent(false));
32+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
33+
});
34+
35+
it('does not start an endless spinner for an already-completed history update', async () => {
36+
const { root } = await renderScreen(<BrowserScreen />);
37+
await fire(webView(root), 'onLoadStart', navigationEvent(true));
38+
await fire(webView(root), 'onLoadEnd', navigationEvent(false));
39+
// A same-document history update can arrive without a network load/finish.
40+
await fire(webView(root), 'onLoadStart', navigationEvent(false, 'https://example.test/page#section'));
41+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
42+
});
43+
44+
it.each([
45+
{ code: -2, description: 'net::ERR_NAME_NOT_RESOLVED', url: 'https://offline.example.test/' },
46+
{ code: -1, description: 'net::ERR_CLEARTEXT_NOT_PERMITTED', url: 'http://example.test/' },
47+
{ code: -11, description: 'SSL error: The certificate authority is not trusted', url: 'https://tls.example.test/' },
48+
])('keeps a finished $description load stopped after a history callback', async ({ code, description, url }) => {
49+
const { root } = await renderScreen(<BrowserScreen />);
50+
await fire(webView(root), 'onLoadStart', navigationEvent(true, url));
51+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(1);
52+
const completed = navigationEvent(false, url);
53+
// The WebView library calls onLoadEnd for errors too, before any later
54+
// history callback. These are synthetic payloads, not real network errors.
55+
await fire(webView(root), 'onLoadEnd', {
56+
nativeEvent: { ...completed.nativeEvent, code, description },
57+
});
58+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
59+
await fire(webView(root), 'onLoadStart', completed);
60+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
61+
});
62+
63+
it('keeps hidden completion stopped on tab return and still shows a user-triggered retry', async () => {
64+
const { root } = await renderScreen(<App />);
65+
const id = theWebView().id;
66+
await fire(webView(root), 'onLoadStart', navigationEvent(true));
67+
await switchTab(root, 'Chat');
68+
await fire(webView(root), 'onLoadEnd', navigationEvent(false));
69+
await fire(webView(root), 'onLoadStart', navigationEvent(false));
70+
await switchTab(root, 'Browse');
71+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
72+
expect(theWebView().id).toBe(id);
73+
expect(webViewRegistry()).toHaveLength(1);
74+
75+
const reload = hostWhere(root, 'TouchableOpacity', (n) => n.props.accessibilityLabel === 'Reload', 'reload button');
76+
await fire(reload, 'onPress');
77+
expect(theWebView().calls.reload).toBe(1);
78+
await fire(webView(root), 'onLoadStart', navigationEvent(true));
79+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(1);
80+
await fire(webView(root), 'onLoadEnd', navigationEvent(false));
81+
expect(hosts(root, 'ActivityIndicator')).toHaveLength(0);
82+
expect(webViewRegistry()).toHaveLength(1);
83+
});
84+
});

0 commit comments

Comments
 (0)