diff --git a/apps/mobile/src/screens/BrowserScreen.tsx b/apps/mobile/src/screens/BrowserScreen.tsx index 8615ec0..dec2448 100644 --- a/apps/mobile/src/screens/BrowserScreen.tsx +++ b/apps/mobile/src/screens/BrowserScreen.tsx @@ -120,7 +120,9 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { ref={webRef} source={{ uri }} style={styles.web} - onLoadStart={() => setLoading(true)} + // Android also emits load-start for history updates after loading ends. + // iOS emits start before allowing navigation, so retain its start flag. + onLoadStart={(event) => setLoading(Platform.OS === 'android' ? event.nativeEvent.loading : true)} onLoadEnd={() => setLoading(false)} onNavigationStateChange={(state) => { setAddress(state.url); diff --git a/apps/mobile/test/browser-loading.test.tsx b/apps/mobile/test/browser-loading.test.tsx new file mode 100644 index 0000000..6d51059 --- /dev/null +++ b/apps/mobile/test/browser-loading.test.tsx @@ -0,0 +1,94 @@ +/** + * Render the real BrowserScreen/App and drive public WebView callbacks. + * Android RNCWebViewClient.doUpdateVisitedHistory emits a loading-start event + * even when loading=false; WebViewShared forwards it to onLoadStart. Such a + * history update need not have a later load-end event. Native dispatch itself + * is not executed by these component tests. + */ +import { describe, expect, it } from 'vitest'; +import App from '../App'; +import { BrowserScreen } from '../src/screens/BrowserScreen'; +import { fire, hosts, hostWhere, renderScreen, switchTab } from './harness'; +import { Platform } from './mocks/react-native'; +import { theWebView, webViewRegistry } from './mocks/react-native-webview'; + +import type { ReactTestInstance } from 'react-test-renderer'; + +const webView = (root: ReactTestInstance) => + hostWhere(root, 'WebView', () => true, 'browser WebView'); + +const navigationEvent = (loading: boolean, url = 'https://example.test/page') => ({ + nativeEvent: { url, loading, title: '', canGoBack: true, canGoForward: false, target: 1 }, +}); + +describe('BrowserScreen loading state', () => { + it.each(['android', 'ios'] as const)('shows a real pending load and clears it on %s', async (platform) => { + Platform.OS = platform; + const { root } = await renderScreen(); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + await fire(webView(root), 'onLoadStart', navigationEvent(true)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it('does not start an endless spinner for an already-completed history update', async () => { + const { root } = await renderScreen(); + await fire(webView(root), 'onLoadStart', navigationEvent(true)); + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); + // A same-document history update can arrive without a network load/finish. + await fire(webView(root), 'onLoadStart', navigationEvent(false, 'https://example.test/page#section')); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it('preserves iOS start indication before navigation policy allows the load', async () => { + Platform.OS = 'ios'; + const { root } = await renderScreen(); + // iOS samples the current loading flag before allowing the new navigation. + await fire(webView(root), 'onLoadStart', navigationEvent(false)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it.each([ + { code: -2, description: 'net::ERR_NAME_NOT_RESOLVED', url: 'https://offline.example.test/' }, + { code: -1, description: 'net::ERR_CLEARTEXT_NOT_PERMITTED', url: 'http://example.test/' }, + { code: -11, description: 'SSL error: The certificate authority is not trusted', url: 'https://tls.example.test/' }, + ])('keeps a finished $description load stopped after a history callback', async ({ code, description, url }) => { + const { root } = await renderScreen(); + await fire(webView(root), 'onLoadStart', navigationEvent(true, url)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + const completed = navigationEvent(false, url); + // The WebView library calls onLoadEnd for errors too, before any later + // history callback. These are synthetic payloads, not real network errors. + await fire(webView(root), 'onLoadEnd', { + nativeEvent: { ...completed.nativeEvent, code, description }, + }); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + await fire(webView(root), 'onLoadStart', completed); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it('keeps hidden completion stopped on tab return and still shows a user-triggered retry', async () => { + const { root } = await renderScreen(); + const id = theWebView().id; + await fire(webView(root), 'onLoadStart', navigationEvent(true)); + await switchTab(root, 'Chat'); + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); + await fire(webView(root), 'onLoadStart', navigationEvent(false)); + await switchTab(root, 'Browse'); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(theWebView().id).toBe(id); + expect(webViewRegistry()).toHaveLength(1); + + const reload = hostWhere(root, 'TouchableOpacity', (n) => n.props.accessibilityLabel === 'Reload', 'reload button'); + await fire(reload, 'onPress'); + expect(theWebView().calls.reload).toBe(1); + await fire(webView(root), 'onLoadStart', navigationEvent(true)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(webViewRegistry()).toHaveLength(1); + }); +});