From 0170827f8d4e568a3329a6c0048f8f76f1a96221 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 14 Sep 2026 12:30:19 +0700 Subject: [PATCH 1/3] Keep browser address edits and recover failed navigation --- apps/mobile/README.md | 19 ++ apps/mobile/src/screens/BrowserScreen.tsx | 195 +++++++++++-- apps/mobile/test/browser-recovery.test.tsx | 265 ++++++++++++++++++ .../test/mocks/react-native-webview.tsx | 7 +- .../test/webview-error-contract.test.tsx | 44 +++ 5 files changed, 510 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/test/browser-recovery.test.tsx create mode 100644 apps/mobile/test/webview-error-contract.test.tsx diff --git a/apps/mobile/README.md b/apps/mobile/README.md index 9bce8ac..830d3d0 100644 --- a/apps/mobile/README.md +++ b/apps/mobile/README.md @@ -70,3 +70,22 @@ eas submit --platform android **Monorepo note:** in the EAS GitHub integration (expo.dev → project → GitHub), set the **Base directory** to `apps/mobile` for both Android and iOS — that's where this Expo app lives. + +## Browser recovery checks + +The address field keeps edits during redirects. Leaving the field without +submitting restores the current page address; submitting navigates or reloads +without remounting the WebView. Stop cancels the current load. Network failures +have a manual Retry action; certificate errors are never bypassed. +Explicit recovery from a failed page recreates the WebView so an iOS provisional +failure cannot reload the wrong document; this recovery discards native history. +After 30 seconds, a dismissible notice flags a slow load without stopping it or +covering usable content. Stop remains available; there is no automatic retry. + +Component tests drive the native WebView boundary for edit/redirect races, +same-source submission, error recovery, cancellation and stale completion events. +They do not run Android WebView or iOS WKWebView. Before a native release, verify +these flows on the target engine, including two requests to the same URL: native +events do not expose a request ID, so URL-based stale-event filtering cannot +distinguish every same-URL overlapping navigation. No paid build is required by +the local component test suite. diff --git a/apps/mobile/src/screens/BrowserScreen.tsx b/apps/mobile/src/screens/BrowserScreen.tsx index dec2448..59c8c90 100644 --- a/apps/mobile/src/screens/BrowserScreen.tsx +++ b/apps/mobile/src/screens/BrowserScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, BackHandler, + Keyboard, Platform, StyleSheet, Text, @@ -32,6 +33,43 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { const [loading, setLoading] = useState(false); const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); + const [failedUrl, setFailedUrl] = useState(null); + const [slowLoad, setSlowLoad] = useState(false); + const editingRef = useRef(false); + const currentUrlRef = useRef(HOME); + const activeLoadRef = useRef(HOME); + const cancelledUrlRef = useRef(null); + const pendingRef = useRef(false); + const supersededUrlsRef = useRef(new Set()); + const [viewKey, setViewKey] = useState(0); + const timeoutRef = useRef | null>(null); + + const clearLoadTimeout = () => { + if (timeoutRef.current !== null) clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + useEffect(() => () => clearLoadTimeout(), []); + + const beginLoad = (url: string) => { + if (pendingRef.current && activeLoadRef.current !== url) supersededUrlsRef.current.add(activeLoadRef.current); + supersededUrlsRef.current.delete(url); + // Native events lack request IDs; retain only a bounded recent history. + if (supersededUrlsRef.current.size > 16) { + supersededUrlsRef.current.delete(supersededUrlsRef.current.values().next().value!); + } + activeLoadRef.current = url; + cancelledUrlRef.current = null; + pendingRef.current = true; + setFailedUrl(null); + setSlowLoad(false); + setLoading(true); + clearLoadTimeout(); + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + // A slow document may already be usable. Only the user may stop it. + setSlowLoad(true); + }, 30_000); + }; // Android system Back pops WebView history. Subscribe only while this tab is // the visible one AND there is history to pop; otherwise no handler exists at @@ -40,16 +78,68 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { useEffect(() => { if (Platform.OS !== 'android' || !isActive || !canGoBack) return; const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + supersededUrlsRef.current.clear(); webRef.current?.goBack(); return true; }); return () => subscription.remove(); }, [isActive, canGoBack]); + const navigateTo = (next: string) => { + const recovering = failedUrl !== null; + const reloadCurrent = next === currentUrlRef.current && + (!pendingRef.current || activeLoadRef.current === next); + beginLoad(next); + if (recovering) { + // An error/interstitial may have no executable document. On iOS reload + // after a provisional failure can reload the previous committed page. + // Recreate only on explicit error recovery, accepting history loss here. + setUri(next); + setCanGoBack(false); + setCanGoForward(false); + supersededUrlsRef.current.clear(); + setViewKey(key => key + 1); + } else if (reloadCurrent) { + webRef.current?.reload(); + } else if (next === uri) { + // The source prop can lag behind in-page navigation. Reassigning an + // unchanged source does nothing; keep the native view and its history. + webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`); + } else { + setUri(next); + } + if (!editingRef.current) setAddress(next); + }; + const go = () => { const next = normalizeUrl(address); - setUri(next); - setAddress(next); + editingRef.current = false; + navigateTo(next); + Keyboard.dismiss(); + }; + + const reload = () => { + if (failedUrl) { + navigateTo(failedUrl); + return; + } + beginLoad(currentUrlRef.current); + webRef.current?.reload(); + }; + + const stop = () => { + cancelledUrlRef.current = activeLoadRef.current; + webRef.current?.stopLoading(); + pendingRef.current = false; + clearLoadTimeout(); + setLoading(false); + setSlowLoad(false); + }; + + const discardEdit = () => { + editingRef.current = false; + // Leaving the field without submitting cancels the draft, not navigation. + setAddress(pendingRef.current ? activeLoadRef.current : currentUrlRef.current); }; // Android hands `window.open` / `target="_blank"` to a detached WebView the @@ -59,13 +149,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { const openWindowInThisTab = (targetUrl: string) => { const next = navigableHttpUrl(targetUrl); if (!next) return; - // In-page navigation can leave `uri` unchanged. Updating the same source - // would do nothing; navigate the existing WebView without discarding history. - if (next === uri) { - webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`); - } - setUri(next); - setAddress(next); + navigateTo(next); }; return ( @@ -73,7 +157,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { webRef.current?.goBack()} + onPress={() => { supersededUrlsRef.current.clear(); webRef.current?.goBack(); }} disabled={!canGoBack} accessibilityRole="button" accessibilityLabel="Back" @@ -83,7 +167,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { webRef.current?.goForward()} + onPress={() => { supersededUrlsRef.current.clear(); webRef.current?.goForward(); }} disabled={!canGoForward} accessibilityRole="button" accessibilityLabel="Forward" @@ -94,8 +178,14 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { { editingRef.current = true; }} + onBlur={discardEdit} + onChangeText={(text) => { + editingRef.current = true; + setAddress(text); + }} onSubmitEditing={go} + submitBehavior="submit" autoCapitalize="none" autoCorrect={false} keyboardType="url" @@ -106,26 +196,77 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { /> webRef.current?.reload()} + onPress={loading ? stop : reload} accessibilityRole="button" - accessibilityLabel="Reload" + accessibilityLabel={loading ? 'Stop loading' : 'Reload'} > - + {loading ? '×' : '⟳'} + {slowLoad && ( + + This page is taking longer to load. + setSlowLoad(false)} + accessibilityRole="button" accessibilityLabel="Dismiss slow loading notice"> + × + + + )} {loading && ( )} setLoading(Platform.OS === 'android' ? event.nativeEvent.loading : true)} - onLoadEnd={() => setLoading(false)} + onLoadStart={({ nativeEvent }) => { + // Completed Android history callbacks are not new network loads. + if (Platform.OS === 'android' && !nativeEvent.loading) { + return; + } + beginLoad(nativeEvent.url); + }} + onLoadEnd={({ nativeEvent }) => { + if (supersededUrlsRef.current.has(nativeEvent.url)) return; + pendingRef.current = false; + clearLoadTimeout(); + setLoading(false); + setSlowLoad(false); + if (!('code' in nativeEvent) && nativeEvent.url !== cancelledUrlRef.current) { + activeLoadRef.current = nativeEvent.url; + currentUrlRef.current = nativeEvent.url; + if (!editingRef.current) setAddress(nativeEvent.url); + } + }} + onError={(event) => { + // Own the error UI: the library's default ERROR overlay otherwise + // hides the native view, including when a stale failure is ignored. + event.preventDefault(); + const { url, code, description } = event.nativeEvent; + if (supersededUrlsRef.current.has(url) || url === cancelledUrlRef.current) return; + clearLoadTimeout(); + setSlowLoad(false); + if ((Platform.OS === 'ios' && (code === -999 || code === 102)) || description?.includes('ERR_ABORTED')) { + pendingRef.current = false; + setLoading(false); + return; + } + setLoading(false); + pendingRef.current = false; + setFailedUrl(url); + }} onNavigationStateChange={(state) => { - setAddress(state.url); + if (supersededUrlsRef.current.has(state.url)) return; + if (!pendingRef.current && (state.loading === false || state.loading === undefined)) { + currentUrlRef.current = state.url; + } + if (pendingRef.current) activeLoadRef.current = state.url; + if (!editingRef.current) setAddress(state.url); setCanGoBack(state.canGoBack); setCanGoForward(state.canGoForward); }} @@ -135,6 +276,15 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { allowsInlineMediaPlayback pullToRefreshEnabled={Platform.OS === 'ios'} /> + {failedUrl && ( + + This page did not finish loading. + + Retry + + + )} ); } @@ -171,4 +321,13 @@ const styles = StyleSheet.create({ }, spinner: { position: 'absolute', top: 56, alignSelf: 'center', zIndex: 2 }, web: { flex: 1, backgroundColor: theme.bg }, + slowLoad: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 8, backgroundColor: theme.surface }, + slowText: { flex: 1, color: theme.text, fontSize: 14 }, + error: { + position: 'absolute', top: 56, bottom: 0, left: 0, right: 0, + alignItems: 'center', justifyContent: 'center', gap: 16, + padding: 24, backgroundColor: theme.bg, + }, + errorText: { color: theme.text, fontSize: 16, textAlign: 'center' }, + retry: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: theme.surfaceAlt, borderRadius: 8 }, }); diff --git a/apps/mobile/test/browser-recovery.test.tsx b/apps/mobile/test/browser-recovery.test.tsx new file mode 100644 index 0000000..0939dd8 --- /dev/null +++ b/apps/mobile/test/browser-recovery.test.tsx @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserScreen } from '../src/screens/BrowserScreen'; +import { actAsync, fire, hosts, hostWhere, renderScreen, textContents } from './harness'; +import { Platform, emitHardwareBackPress } from './mocks/react-native'; +import { theWebView, webViewRegistry } from './mocks/react-native-webview'; +import type { ReactTestInstance } from 'react-test-renderer'; + +const FIRST = 'https://example.test/first'; +const SECOND = 'https://example.test/second'; +const input = (root: ReactTestInstance) => hostWhere(root, 'TextInput', () => true, 'address'); +const web = (root: ReactTestInstance) => hostWhere(root, 'WebView', () => true, 'webview'); +const button = (root: ReactTestInstance, label: string) => hostWhere(root, 'TouchableOpacity', + n => n.props.accessibilityLabel === label, label); +const event = (url: string, loading = true) => ({ + nativeEvent: { url, loading, canGoBack: true, canGoForward: false, title: '', target: 1 }, +}); +const errorEvent = (url: string, code = -2, description = 'net::ERR_NAME_NOT_RESOLVED') => ({ + ...event(url, false), + nativeEvent: { ...event(url, false).nativeEvent, code, description }, + preventDefault: vi.fn(), +}); +const navigate = (url: string) => actAsync(() => theWebView().emitNavigationState({ + url, canGoBack: true, canGoForward: false, +})); +async function submit(root: ReactTestInstance, url: string) { + await fire(input(root), 'onChangeText', url); + await fire(input(root), 'onSubmitEditing'); +} + +describe.each(['android', 'ios'] as const)('browser recovery on %s', platform => { + beforeEach(() => { Platform.OS = platform; }); + afterEach(() => { vi.useRealTimers(); }); + it('does not let a redirect replace an unfinished address edit', async () => { + Platform.OS = platform; + const { root } = await renderScreen(); + await fire(input(root), 'onFocus'); + await fire(input(root), 'onChangeText', 'my unfinished search'); + await navigate(SECOND); + expect(input(root).props.value).toBe('my unfinished search'); + await fire(input(root), 'onSubmitEditing'); + expect(theWebView().props.source?.uri).toBe('https://duckduckgo.com/?q=my%20unfinished%20search'); + }); + + it('restores the current page address when an edit is dismissed without submitting', async () => { + const { root } = await renderScreen(); + await fire(input(root), 'onFocus'); + await fire(input(root), 'onChangeText', 'not submitted'); + await navigate(SECOND); + await fire(input(root), 'onBlur'); + expect(input(root).props.value).toBe(SECOND); + }); + + it('resubmits the source URL after an in-page navigation without remounting', async () => { + const { root } = await renderScreen(); + await submit(root, FIRST); + await navigate(SECOND); + await submit(root, FIRST); + expect(theWebView().calls.injected).toEqual([ + `window.location.assign(${JSON.stringify(FIRST)});true;`, + ]); + expect(webViewRegistry()).toHaveLength(1); + }); + + it('reloads rather than navigating again when submitting the page already displayed', async () => { + const { root } = await renderScreen(); + await submit(root, FIRST); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + await navigate(FIRST); + await submit(root, FIRST); + expect(theWebView().calls.reload).toBe(1); + expect(webViewRegistry()).toHaveLength(1); + }); + + it('does not reload the wrong in-flight page when submitting the previous committed URL', async () => { + const { root } = await renderScreen(); + await submit(root, FIRST); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + await submit(root, SECOND); + await fire(web(root), 'onLoadStart', event(SECOND)); + await submit(root, FIRST); + expect(theWebView().calls.reload).toBe(0); + expect(theWebView().props.source?.uri).toBe(FIRST); + }); + + it('offers manual retry after a network error without exposing raw native details', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + const failure = errorEvent(FIRST, -2, 'private native failure data'); + await fire(web(root), 'onError', failure); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + expect(failure.preventDefault).toHaveBeenCalledOnce(); + expect(textContents(root)).toContain('This page did not finish loading.'); + expect(textContents(root)).not.toContain('private native failure data'); + expect(theWebView().calls.reload).toBe(0); + const failedView = theWebView(); + await fire(button(root, 'Retry page'), 'onPress'); + expect(failedView.mounted).toBe(false); + expect(theWebView().props.source?.uri).toBe(FIRST); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + expect(textContents(root)).not.toContain('This page did not finish loading.'); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it('ignores an old page failure/finish after another page starts loading', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadStart', event(SECOND)); + await fire(web(root), 'onError', errorEvent(FIRST)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + expect(textContents(root)).not.toContain('This page did not finish loading.'); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + }); + + it('stops manually and does not surface cancellation as a page failure', async () => { + Platform.OS = platform; + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(button(root, 'Stop loading'), 'onPress'); + expect(theWebView().calls.stop).toBe(1); + const cancel = platform === 'ios' ? errorEvent(FIRST, -999, 'cancelled') + : errorEvent(FIRST, -1, 'net::ERR_ABORTED'); + await fire(web(root), 'onError', cancel); + expect(textContents(root)).not.toContain('This page did not finish loading.'); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(button(root, 'Reload')).toBeDefined(); + }); + + it('keeps TLS failures visible without adding any certificate bypass', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onError', errorEvent(FIRST, -11, 'SSL error')); + expect(button(root, 'Retry page')).toBeDefined(); + expect(theWebView().props.onHttpError).toBeUndefined(); + expect(theWebView().props.onLoadSubResourceError).toBeUndefined(); + expect(theWebView().calls.injected).toEqual([]); + expect(theWebView().props.mixedContentMode).toBeUndefined(); + }); + + it('ignores stale completed navigation metadata while a later page is pending', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await submit(root, SECOND); + await fire(web(root), 'onLoadStart', event(SECOND)); + await fire(web(root), 'onNavigationStateChange', event(FIRST, false).nativeEvent); + expect(input(root).props.value).toBe(SECOND); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + }); + + it('does not overwrite an edit when a popup navigates the background document', async () => { + const { root } = await renderScreen(); + await fire(input(root), 'onFocus'); + await fire(input(root), 'onChangeText', 'keep this draft'); + await actAsync(() => theWebView().emitOpenWindow(SECOND)); + expect(input(root).props.value).toBe('keep this draft'); + }); + + it('finishes a redirect that does not emit a second load-start event', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadEnd', event(SECOND, false)); + await fire(web(root), 'onNavigationStateChange', event(SECOND, false).nativeEvent); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(input(root).props.value).toBe(SECOND); + }); + + it('accepts a previously visited URL after explicit Back', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + await fire(web(root), 'onLoadStart', event(SECOND)); + await fire(web(root), 'onLoadEnd', event(SECOND, false)); + await navigate(SECOND); + await fire(button(root, 'Back'), 'onPress'); + await fire(web(root), 'onNavigationStateChange', event(FIRST, false).nativeEvent); + expect(input(root).props.value).toBe(FIRST); + expect(theWebView().calls.goBack).toBe(1); + }); + + it('accepts in-page history changes to a previously completed URL without native start', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + await fire(web(root), 'onLoadStart', event(SECOND)); + await fire(web(root), 'onLoadEnd', event(SECOND, false)); + await fire(web(root), 'onNavigationStateChange', event(FIRST, false).nativeEvent); + expect(input(root).props.value).toBe(FIRST); + }); + + it('finishes a redirect back to an already completed URL without another start', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + await submit(root, SECOND); + await fire(web(root), 'onLoadStart', event(SECOND)); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + expect(input(root).props.value).toBe(FIRST); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it.runIf(platform === 'android')('keeps a genuine load pending across an Android history-only callback', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onLoadStart', event(FIRST, false)); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + }); + + it('shows a dismissible slow-load notice without stopping or hiding usable content', async () => { + vi.useFakeTimers(); + const { root } = await renderScreen(); + await submit(root, FIRST); + await actAsync(() => { vi.advanceTimersByTime(30_000); }); + expect(theWebView().calls.stop).toBe(0); + expect(textContents(root)).toContain('This page is taking longer to load.'); + expect(textContents(root)).not.toContain('This page did not finish loading.'); + expect(button(root, 'Stop loading')).toBeDefined(); + await fire(button(root, 'Dismiss slow loading notice'), 'onPress'); + expect(textContents(root)).not.toContain('This page is taking longer to load.'); + await fire(button(root, 'Stop loading'), 'onPress'); + expect(theWebView().calls.stop).toBe(1); + }); + + it('resets navigation history after a failed-page remount, including hardware Back', async () => { + const { root } = await renderScreen(); + await navigate(FIRST); + await fire(web(root), 'onLoadStart', event(SECOND)); + await fire(web(root), 'onError', errorEvent(SECOND)); + await fire(button(root, 'Retry page'), 'onPress'); + expect(button(root, 'Back').props.disabled).toBe(true); + expect(button(root, 'Forward').props.disabled).toBe(true); + expect(emitHardwareBackPress()).toBe(false); + expect(theWebView().calls.goBack).toBe(0); + }); + + it('does not treat a provisional navigation callback as a committed document', async () => { + const { root } = await renderScreen(); + await submit(root, FIRST); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onNavigationStateChange', event(FIRST, false).nativeEvent); + await submit(root, FIRST); + expect(theWebView().calls.reload).toBe(0); + expect(theWebView().calls.injected).toEqual([`window.location.assign(${JSON.stringify(FIRST)});true;`]); + }); + + it('clears the slow-load timer when loading finishes', async () => { + vi.useFakeTimers(); + const { root } = await renderScreen(); + await submit(root, FIRST); + await fire(web(root), 'onLoadEnd', event(FIRST, false)); + expect(vi.getTimerCount()).toBe(0); + await actAsync(() => { vi.advanceTimersByTime(30_000); }); + expect(textContents(root)).not.toContain('This page is taking longer to load.'); + expect(theWebView().calls.stop).toBe(0); + }); + + it('clears the slow-load timer on unmount', async () => { + vi.useFakeTimers(); + const renderer = await renderScreen(); + await submit(renderer.root, FIRST); + expect(vi.getTimerCount()).toBe(1); + await actAsync(() => { renderer.unmount(); }); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/mobile/test/mocks/react-native-webview.tsx b/apps/mobile/test/mocks/react-native-webview.tsx index f44990d..0bf4930 100644 --- a/apps/mobile/test/mocks/react-native-webview.tsx +++ b/apps/mobile/test/mocks/react-native-webview.tsx @@ -29,7 +29,7 @@ export interface MockWebViewHandle { mounted: boolean; /** Props from the most recent render. */ props: MockWebViewProps; - calls: { goBack: number; goForward: number; reload: number; injected: string[] }; + calls: { goBack: number; goForward: number; reload: number; stop: number; injected: string[] }; emitNavigationState(state: MockNavigationState): void; emitOpenWindow(targetUrl: string): void; } @@ -63,7 +63,7 @@ export const WebView = forwardRef(function WebView(pr id: nextId++, mounted: true, props, - calls: { goBack: 0, goForward: 0, reload: 0, injected: [] }, + calls: { goBack: 0, goForward: 0, reload: 0, stop: 0, injected: [] }, emitNavigationState(state) { handle.props.onNavigationStateChange?.(state); }, @@ -97,6 +97,9 @@ export const WebView = forwardRef(function WebView(pr reload: () => { handleRef.current!.calls.reload += 1; }, + stopLoading: () => { + handleRef.current!.calls.stop += 1; + }, })); return createElement('WebView', props); diff --git a/apps/mobile/test/webview-error-contract.test.tsx b/apps/mobile/test/webview-error-contract.test.tsx new file mode 100644 index 0000000..6612b1d --- /dev/null +++ b/apps/mobile/test/webview-error-contract.test.tsx @@ -0,0 +1,44 @@ +import { expect, it, vi } from 'vitest'; +import type { WebViewErrorEvent } from 'react-native-webview/lib/WebViewTypes'; +import { actAsync, renderScreen } from './harness'; + +// Execute the dependency's hook, not our native-boundary double. Loading it +// at runtime avoids typechecking the package's unpublished source dependencies. +const { useWebViewLogic } = await vi.importActual<{ + useWebViewLogic: (options: { + originWhitelist: string[]; + onShouldStartLoadWithRequestCallback: () => void; + onError: (event: WebViewErrorEvent) => void; + onLoadEnd: () => void; + }) => { + onLoadingError: (event: WebViewErrorEvent) => void; + viewState: string; + lastErrorEvent: unknown; + }; +}>('../node_modules/react-native-webview/src/WebViewShared'); + +it('the pinned WebView honors preventDefault after forwarding the error and load end', async () => { + let logic!: ReturnType; + const order: string[] = []; + function Probe() { + logic = useWebViewLogic({ + originWhitelist: ['http://*', 'https://*'], + onShouldStartLoadWithRequestCallback: vi.fn(), + onError: event => { order.push('error'); event.preventDefault(); }, + onLoadEnd: () => { order.push('end'); }, + }); + return null; + } + await renderScreen(); + let prevented = false; + const event = { + nativeEvent: { url: 'https://example.test', code: -2, description: 'network failure' }, + persist: vi.fn(), + preventDefault: () => { prevented = true; }, + isDefaultPrevented: () => prevented, + } as unknown as WebViewErrorEvent; + await actAsync(() => { logic.onLoadingError(event); }); + expect(order).toEqual(['error', 'end']); + expect(logic.viewState).toBe('IDLE'); + expect(logic.lastErrorEvent).toBeNull(); +}); From da3ff4daf1c5aaf8561723a957cf7329e2f09019 Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 14 Sep 2026 14:56:05 +0700 Subject: [PATCH 2/3] Cover the full browser error area and isolate native page access --- apps/mobile/src/screens/BrowserScreen.tsx | 159 +++++++++++---------- apps/mobile/test/app-tabs.test.tsx | 21 +++ apps/mobile/test/browser-recovery.test.tsx | 48 ++++++- apps/mobile/test/harness.tsx | 3 +- 4 files changed, 153 insertions(+), 78 deletions(-) diff --git a/apps/mobile/src/screens/BrowserScreen.tsx b/apps/mobile/src/screens/BrowserScreen.tsx index 59c8c90..fef34a1 100644 --- a/apps/mobile/src/screens/BrowserScreen.tsx +++ b/apps/mobile/src/screens/BrowserScreen.tsx @@ -34,6 +34,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { const [canGoBack, setCanGoBack] = useState(false); const [canGoForward, setCanGoForward] = useState(false); const [failedUrl, setFailedUrl] = useState(null); + const hasFailed = failedUrl !== null; const [slowLoad, setSlowLoad] = useState(false); const editingRef = useRef(false); const currentUrlRef = useRef(HOME); @@ -86,7 +87,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { }, [isActive, canGoBack]); const navigateTo = (next: string) => { - const recovering = failedUrl !== null; + const recovering = hasFailed; const reloadCurrent = next === currentUrlRef.current && (!pendingRef.current || activeLoadRef.current === next); beginLoad(next); @@ -119,7 +120,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { }; const reload = () => { - if (failedUrl) { + if (failedUrl !== null) { navigateTo(failedUrl); return; } @@ -212,79 +213,85 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { )} - {loading && ( - - )} - { - // Completed Android history callbacks are not new network loads. - if (Platform.OS === 'android' && !nativeEvent.loading) { - return; - } - beginLoad(nativeEvent.url); - }} - onLoadEnd={({ nativeEvent }) => { - if (supersededUrlsRef.current.has(nativeEvent.url)) return; - pendingRef.current = false; - clearLoadTimeout(); - setLoading(false); - setSlowLoad(false); - if (!('code' in nativeEvent) && nativeEvent.url !== cancelledUrlRef.current) { - activeLoadRef.current = nativeEvent.url; - currentUrlRef.current = nativeEvent.url; - if (!editingRef.current) setAddress(nativeEvent.url); - } - }} - onError={(event) => { - // Own the error UI: the library's default ERROR overlay otherwise - // hides the native view, including when a stale failure is ignored. - event.preventDefault(); - const { url, code, description } = event.nativeEvent; - if (supersededUrlsRef.current.has(url) || url === cancelledUrlRef.current) return; - clearLoadTimeout(); - setSlowLoad(false); - if ((Platform.OS === 'ios' && (code === -999 || code === 102)) || description?.includes('ERR_ABORTED')) { - pendingRef.current = false; - setLoading(false); - return; - } - setLoading(false); - pendingRef.current = false; - setFailedUrl(url); - }} - onNavigationStateChange={(state) => { - if (supersededUrlsRef.current.has(state.url)) return; - if (!pendingRef.current && (state.loading === false || state.loading === undefined)) { - currentUrlRef.current = state.url; - } - if (pendingRef.current) activeLoadRef.current = state.url; - if (!editingRef.current) setAddress(state.url); - setCanGoBack(state.canGoBack); - setCanGoForward(state.canGoForward); - }} - onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)} - // Privacy-leaning defaults consistent with the desktop ethos. - thirdPartyCookiesEnabled={false} - allowsInlineMediaPlayback - pullToRefreshEnabled={Platform.OS === 'ios'} - /> - {failedUrl && ( - - This page did not finish loading. - - Retry - + + {/* Keep the native page behind a real accessibility boundary on errors. */} + + { + // Completed Android history callbacks are not new network loads. + if (Platform.OS === 'android' && !nativeEvent.loading) { + return; + } + beginLoad(nativeEvent.url); + }} + onLoadEnd={({ nativeEvent }) => { + if (supersededUrlsRef.current.has(nativeEvent.url)) return; + pendingRef.current = false; + clearLoadTimeout(); + setLoading(false); + setSlowLoad(false); + if (!('code' in nativeEvent) && nativeEvent.url !== cancelledUrlRef.current) { + activeLoadRef.current = nativeEvent.url; + currentUrlRef.current = nativeEvent.url; + if (!editingRef.current) setAddress(nativeEvent.url); + } + }} + onError={(event) => { + // Own the error UI: the library's default ERROR overlay otherwise + // hides the native view, including when a stale failure is ignored. + event.preventDefault(); + const { url, code, description } = event.nativeEvent; + if (supersededUrlsRef.current.has(url) || url === cancelledUrlRef.current) return; + clearLoadTimeout(); + setSlowLoad(false); + if ((Platform.OS === 'ios' && (code === -999 || code === 102)) || description?.includes('ERR_ABORTED')) { + pendingRef.current = false; + setLoading(false); + return; + } + setLoading(false); + pendingRef.current = false; + setFailedUrl(url || activeLoadRef.current); + }} + onNavigationStateChange={(state) => { + if (supersededUrlsRef.current.has(state.url)) return; + if (!pendingRef.current && (state.loading === false || state.loading === undefined)) { + currentUrlRef.current = state.url; + } + if (pendingRef.current) activeLoadRef.current = state.url; + if (!editingRef.current) setAddress(state.url); + setCanGoBack(state.canGoBack); + setCanGoForward(state.canGoForward); + }} + onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)} + // Privacy-leaning defaults consistent with the desktop ethos. + thirdPartyCookiesEnabled={false} + allowsInlineMediaPlayback + pullToRefreshEnabled={Platform.OS === 'ios'} + /> - )} + {loading && ( + + )} + {hasFailed && ( + + This page did not finish loading. + + Retry + + + )} + ); } @@ -319,12 +326,12 @@ const styles = StyleSheet.create({ color: theme.text, backgroundColor: theme.surfaceAlt, }, - spinner: { position: 'absolute', top: 56, alignSelf: 'center', zIndex: 2 }, + spinner: { position: 'absolute', top: 8, alignSelf: 'center', zIndex: 2 }, web: { flex: 1, backgroundColor: theme.bg }, slowLoad: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 8, backgroundColor: theme.surface }, slowText: { flex: 1, color: theme.text, fontSize: 14 }, error: { - position: 'absolute', top: 56, bottom: 0, left: 0, right: 0, + position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, alignItems: 'center', justifyContent: 'center', gap: 16, padding: 24, backgroundColor: theme.bg, }, diff --git a/apps/mobile/test/app-tabs.test.tsx b/apps/mobile/test/app-tabs.test.tsx index 7b483ea..e32a07b 100644 --- a/apps/mobile/test/app-tabs.test.tsx +++ b/apps/mobile/test/app-tabs.test.tsx @@ -129,6 +129,27 @@ describe('App tab shell', () => { expect(backButton.props.accessibilityState).toEqual({ disabled: false }); }); + it('keeps a failed browser and its retry UI inside the inactive tab boundary', async () => { + const { root } = await renderScreen(); + const webview = hostWhere(root, 'WebView', () => true, 'browser'); + await fire(webview, 'onError', { + nativeEvent: { url: 'https://example.test/', code: -2, description: 'network failure' }, + preventDefault: vi.fn(), + }); + const browser = scenes(root)[0]; + expect(hostWhere(browser, 'TouchableOpacity', n => n.props.accessibilityLabel === 'Retry page', 'retry')).toBeDefined(); + await switchTab(root, 'Chat'); + expect(scenes(root)).toHaveLength(4); + expect(browser.props.accessibilityElementsHidden).toBe(true); + expect(browser.props.importantForAccessibility).toBe('no-hide-descendants'); + expect(browser.props.pointerEvents).toBe('none'); + await switchTab(root, 'Browse'); + expect(browser.props.accessibilityElementsHidden).toBe(false); + const page = hosts(browser, 'View').find(n => n.props.pointerEvents === 'none'); + expect(page?.props.importantForAccessibility).toBe('no-hide-descendants'); + expect(webViewRegistry()).toHaveLength(1); + }); + it('keeps chat history and the unsent draft across tab switches', async () => { vi.useFakeTimers(); try { diff --git a/apps/mobile/test/browser-recovery.test.tsx b/apps/mobile/test/browser-recovery.test.tsx index 0939dd8..0a1c455 100644 --- a/apps/mobile/test/browser-recovery.test.tsx +++ b/apps/mobile/test/browser-recovery.test.tsx @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserScreen } from '../src/screens/BrowserScreen'; import { actAsync, fire, hosts, hostWhere, renderScreen, textContents } from './harness'; -import { Platform, emitHardwareBackPress } from './mocks/react-native'; +import { Platform, StyleSheet, emitHardwareBackPress } from './mocks/react-native'; import { theWebView, webViewRegistry } from './mocks/react-native-webview'; import type { ReactTestInstance } from 'react-test-renderer'; @@ -113,6 +113,52 @@ describe.each(['android', 'ios'] as const)('browser recovery on %s', platform => expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); }); + it('uses the pending address for manual retry if the native failure omits its URL', async () => { + const { root } = await renderScreen(); + await submit(root, FIRST); + await fire(web(root), 'onError', errorEvent('')); + expect(textContents(root)).toContain('This page did not finish loading.'); + const hidden = hostWhere(root, 'View', n => n.props.pointerEvents === 'none', 'failed page'); + expect(hidden.props.accessibilityElementsHidden).toBe(true); + expect(hidden.props.importantForAccessibility).toBe('no-hide-descendants'); + await fire(button(root, 'Retry page'), 'onPress'); + expect(theWebView().props.source?.uri).toBe(FIRST); + expect(textContents(root)).not.toContain('This page did not finish loading.'); + }); + + it('covers the entire content area on failure without covering the address bar', async () => { + const { root } = await renderScreen(); + await fire(web(root), 'onLoadStart', event(FIRST)); + await fire(web(root), 'onError', errorEvent(FIRST)); + const overlay = hostWhere(root, 'View', n => n.props.accessibilityRole === 'alert', 'page error'); + expect(StyleSheet.flatten(overlay.props.style)).toMatchObject(StyleSheet.absoluteFillObject); + const content = overlay.parent!; + expect(hosts(content, 'WebView')).toHaveLength(1); + expect(hosts(content, 'TextInput')).toHaveLength(0); + expect(StyleSheet.flatten(content.props.style)).toMatchObject({ flex: 1 }); + expect(button(root, 'Reload')).toBeDefined(); + expect(webViewRegistry()).toHaveLength(1); + }); + + it('hides only the failed native page from touch and accessibility, then restores it on retry', async () => { + const { root } = await renderScreen(); + const nativePage = () => hostWhere(root, 'View', + n => n.props.collapsable === false && hosts(n, 'WebView').length === 1, + 'native page accessibility boundary'); + expect(nativePage().props.importantForAccessibility).toBe('auto'); + expect(nativePage().props.pointerEvents).toBe('auto'); + await fire(web(root), 'onError', errorEvent(FIRST)); + expect(nativePage().props.importantForAccessibility).toBe('no-hide-descendants'); + expect(nativePage().props.accessibilityElementsHidden).toBe(true); + expect(nativePage().props.pointerEvents).toBe('none'); + expect(nativePage().findAll(n => n.props.accessibilityLabel === 'Retry page')).toHaveLength(0); + await fire(button(root, 'Retry page'), 'onPress'); + expect(nativePage().props.importantForAccessibility).toBe('auto'); + expect(nativePage().props.accessibilityElementsHidden).toBe(false); + expect(nativePage().props.pointerEvents).toBe('auto'); + expect(textContents(root)).not.toContain('This page did not finish loading.'); + }); + it('stops manually and does not surface cancellation as a page failure', async () => { Platform.OS = platform; const { root } = await renderScreen(); diff --git a/apps/mobile/test/harness.tsx b/apps/mobile/test/harness.tsx index 80caf32..eb1d745 100644 --- a/apps/mobile/test/harness.tsx +++ b/apps/mobile/test/harness.tsx @@ -108,5 +108,6 @@ export async function switchTab(root: ReactTestInstance, label: string): Promise /** The four keep-mounted TabScene host views, in App.tsx tab order. */ export function scenes(root: ReactTestInstance): ReactTestInstance[] { - return hosts(root, 'View').filter((node) => 'accessibilityElementsHidden' in node.props); + return hosts(root, 'View').filter((node) => + 'accessibilityElementsHidden' in node.props && flat(node).position === 'absolute'); } From a4059944ca6a4993595388790eec4c2e4fcb70df Mon Sep 17 00:00:00 2001 From: Phuc Nguyen Date: Mon, 14 Sep 2026 16:13:43 +0700 Subject: [PATCH 3/3] Ignore delayed callbacks from stopped pages after navigation --- apps/mobile/src/screens/BrowserScreen.tsx | 5 + apps/mobile/test/browser-event-order.test.tsx | 280 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 apps/mobile/test/browser-event-order.test.tsx diff --git a/apps/mobile/src/screens/BrowserScreen.tsx b/apps/mobile/src/screens/BrowserScreen.tsx index fef34a1..d9ec642 100644 --- a/apps/mobile/src/screens/BrowserScreen.tsx +++ b/apps/mobile/src/screens/BrowserScreen.tsx @@ -53,6 +53,11 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) { const beginLoad = (url: string) => { if (pendingRef.current && activeLoadRef.current !== url) supersededUrlsRef.current.add(activeLoadRef.current); + // Stop ends the pending state, but its native callbacks may still arrive + // after the user starts another page. + if (cancelledUrlRef.current !== null && cancelledUrlRef.current !== url) { + supersededUrlsRef.current.add(cancelledUrlRef.current); + } supersededUrlsRef.current.delete(url); // Native events lack request IDs; retain only a bounded recent history. if (supersededUrlsRef.current.size > 16) { diff --git a/apps/mobile/test/browser-event-order.test.tsx b/apps/mobile/test/browser-event-order.test.tsx new file mode 100644 index 0000000..cf9a158 --- /dev/null +++ b/apps/mobile/test/browser-event-order.test.tsx @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactTestInstance } from 'react-test-renderer'; +import type { WebViewErrorEvent, WebViewNavigation, WebViewNavigationEvent } from 'react-native-webview/lib/WebViewTypes'; +import App from '../App'; +import { BrowserScreen } from '../src/screens/BrowserScreen'; +import { actAsync, fire, hosts, hostWhere, renderScreen, scenes, switchTab, textContents } from './harness'; +import { emitHardwareBackPress, Platform } from './mocks/react-native'; +import { theWebView, webViewRegistry } from './mocks/react-native-webview'; + +interface Callbacks { + onLoadStart: (event: WebViewNavigationEvent) => void; + onLoadEnd: (event: WebViewNavigationEvent | WebViewErrorEvent) => void; + onError: (event: WebViewErrorEvent) => void; + onNavigationStateChange: (state: WebViewNavigation) => void; +} + +// Run the pinned dependency's JS dispatch order against the real screen. +// Native WebView/network delivery is still a controlled boundary, not executed. +// Each driver keeps one hook probe across WebView remounts and forwards events +// to the currently mounted view. It cannot deliver events from an unmounted +// instance; viewState/lastErrorEvent describe the probe, not a fresh native-view +// hook after Retry. Native focus/blur and per-mount wrapper state are not tested. +const { useWebViewLogic } = await vi.importActual<{ + useWebViewLogic: (options: Callbacks & { + originWhitelist: string[]; + onShouldStartLoadWithRequestCallback: () => void; + }) => { + onLoadingStart: (event: WebViewNavigationEvent) => void; + onLoadingFinish: (event: WebViewNavigationEvent) => void; + onLoadingError: (event: WebViewErrorEvent) => void; + viewState: string; + lastErrorEvent: unknown; + }; +}>('../node_modules/react-native-webview/src/WebViewShared'); + +const FIRST = 'https://example.test/first'; +const SECOND = 'https://example.test/second'; +const ERROR = 'This page did not finish loading.'; +const SLOW = 'This page is taking longer to load.'; +const input = (root: ReactTestInstance) => hostWhere(root, 'TextInput', + n => n.props.placeholder === 'Search or enter address', 'address'); +const button = (root: ReactTestInstance, label: string) => hostWhere(root, 'TouchableOpacity', + n => n.props.accessibilityLabel === label, label); +const event = (url: string, loading: boolean) => ({ + nativeEvent: { url, loading, canGoBack: true, canGoForward: false, title: '', target: 1 }, +}) as unknown as WebViewNavigationEvent; +const failure = (url: string, code = -2, description = 'network failure') => { + let prevented = false; + return { + nativeEvent: { ...event(url, false).nativeEvent, code, description }, + persist: vi.fn(), + preventDefault: () => { prevented = true; }, + isDefaultPrevented: () => prevented, + } as unknown as WebViewErrorEvent; +}; + +async function submit(root: ReactTestInstance, url: string) { + await fire(input(root), 'onChangeText', url); + await fire(input(root), 'onSubmitEditing'); +} + +async function driver() { + let logic!: ReturnType; + const callbacks = () => theWebView().props as unknown as Callbacks; + function Probe() { + logic = useWebViewLogic({ + originWhitelist: ['http://*', 'https://*'], + onShouldStartLoadWithRequestCallback: vi.fn(), + onLoadStart: event => callbacks().onLoadStart(event), + onLoadEnd: event => callbacks().onLoadEnd(event), + onError: event => callbacks().onError(event), + onNavigationStateChange: state => callbacks().onNavigationStateChange(state), + }); + return null; + } + await renderScreen(); + return { + start: (url: string) => actAsync(() => logic.onLoadingStart(event(url, true))), + finish: (url: string) => actAsync(() => logic.onLoadingFinish(event(url, false))), + error: (event: WebViewErrorEvent) => actAsync(() => logic.onLoadingError(event)), + state: () => logic, + }; +} + +describe.each(['android', 'ios'] as const)('browser event ordering on %s', platform => { + beforeEach(() => { Platform.OS = platform; vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it.each(['finish', 'failure', 'cancellation'] as const)( + 'keeps the replacement load pending after Stop and a delayed %s', async late => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await fire(button(root, 'Stop loading'), 'onPress'); + expect(vi.getTimerCount()).toBe(0); + await submit(root, SECOND); + await native.start(SECOND); + if (late === 'finish') await native.finish(FIRST); + else { + // RNCWebViewClient.onReceivedError emits a finish before its error. + if (platform === 'android') await native.finish(FIRST); + const error = late === 'failure' ? failure(FIRST) + : failure(FIRST, platform === 'ios' ? -999 : -1, 'net::ERR_ABORTED'); + await native.error(error); + expect(error.isDefaultPrevented()).toBe(true); + } + expect(textContents(root)).not.toContain(ERROR); + expect(input(root).props.value).toBe(SECOND); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + expect(vi.getTimerCount()).toBe(1); + expect(native.state().viewState).toBe('IDLE'); + expect(native.state().lastErrorEvent).toBeNull(); + await actAsync(() => { vi.advanceTimersByTime(30_000); }); + expect(textContents(root)).toContain(SLOW); + await native.finish(SECOND); + expect(textContents(root)).not.toContain(SLOW); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(theWebView().calls.stop).toBe(1); + expect(webViewRegistry()).toHaveLength(1); + }, + ); + + it('allows an explicitly resubmitted stopped URL to fail and be retried', async () => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await fire(button(root, 'Stop loading'), 'onPress'); + await submit(root, SECOND); + await native.start(SECOND); + await submit(root, FIRST); + await native.start(FIRST); + await native.error(failure(FIRST)); + expect(textContents(root)).toContain(ERROR); + expect(vi.getTimerCount()).toBe(0); + const previous = theWebView(); + await fire(button(root, 'Retry page'), 'onPress'); + expect(previous.mounted).toBe(false); + expect(theWebView().props.source?.uri).toBe(FIRST); + await native.start(FIRST); + await native.finish(FIRST); + expect(textContents(root)).not.toContain(ERROR); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + }); + + it.each(['finish', 'failure', 'cancellation'] as const)('ignores delayed %s before the replacement native start arrives', async late => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await fire(button(root, 'Stop loading'), 'onPress'); + await submit(root, SECOND); + if (late === 'finish') await native.finish(FIRST); + else { + if (platform === 'android') await native.finish(FIRST); + const error = late === 'failure' ? failure(FIRST) + : failure(FIRST, platform === 'ios' ? -999 : -1, 'net::ERR_ABORTED'); + await native.error(error); + expect(error.isDefaultPrevented()).toBe(true); + } + expect(textContents(root)).not.toContain(ERROR); + expect(input(root).props.value).toBe(SECOND); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + expect(button(root, 'Stop loading')).toBeDefined(); + await actAsync(() => { vi.advanceTimersByTime(29_999); }); + expect(textContents(root)).not.toContain(SLOW); + expect(vi.getTimerCount()).toBe(1); + await actAsync(() => { vi.advanceTimersByTime(1); }); + expect(textContents(root)).toContain(SLOW); + await native.start(SECOND); + expect(textContents(root)).not.toContain(SLOW); + await native.finish(SECOND); + expect(input(root).props.value).toBe(SECOND); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(textContents(root)).not.toContain(ERROR); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + { direction: 'Back', traversal: 'same-document' }, + { direction: 'Forward', traversal: 'same-document' }, + { direction: 'Back', traversal: 'cross-document' }, + { direction: 'Forward', traversal: 'cross-document' }, + ])('accepts stopped URLs again after explicit $traversal $direction', async ({ direction, traversal }) => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await fire(button(root, 'Stop loading'), 'onPress'); + await submit(root, SECOND); + await native.start(SECOND); + await native.finish(SECOND); + const web = hostWhere(root, 'WebView', () => true, 'webview'); + await fire(web, 'onNavigationStateChange', { + ...event(SECOND, false).nativeEvent, canGoForward: true, + }); + await fire(button(root, direction), 'onPress'); + if (traversal === 'cross-document') { + // The real hook forwards start + navigation, then end + navigation. + await native.start(FIRST); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); + await native.finish(FIRST); + } else { + // A same-document history traversal need not emit a load-start callback. + await fire(web, 'onNavigationStateChange', event(FIRST, false).nativeEvent); + } + expect(input(root).props.value).toBe(FIRST); + expect(theWebView().calls[direction === 'Back' ? 'goBack' : 'goForward']).toBe(1); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(textContents(root)).not.toContain(ERROR); + expect(vi.getTimerCount()).toBe(0); + expect(webViewRegistry()).toHaveLength(1); + }); + + it.each([-999, 102])('treats error code %s as cancellation only on iOS', async code => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await native.error(failure(FIRST, code)); + expect(textContents(root).includes(ERROR)).toBe(platform !== 'ios'); + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + expect(native.state().lastErrorEvent).toBeNull(); + }); + + it.each(['finish', 'failure'] as const)('preserves an address draft across background %s', async outcome => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await fire(input(root), 'onFocus'); + await fire(input(root), 'onChangeText', 'unfinished query'); + await switchTab(root, 'Chat'); + // Keyboard dismissal is mocked; no native focus/blur behavior is claimed. + if (outcome === 'finish') await native.finish(SECOND); + else await native.error(failure(FIRST)); + expect(emitHardwareBackPress()).toBe(false); + expect(scenes(root)[0].props.pointerEvents).toBe('none'); + expect(scenes(root)[0].props.accessibilityElementsHidden).toBe(true); + await switchTab(root, 'Browse'); + expect(input(root).props.value).toBe('unfinished query'); + expect(webViewRegistry()).toHaveLength(1); + expect(vi.getTimerCount()).toBe(0); + if (outcome === 'failure') { + const page = hostWhere(root, 'View', n => n.props.collapsable === false && + n.props.importantForAccessibility === 'no-hide-descendants' && hosts(n, 'WebView').length === 1 && + n.props.pointerEvents === 'none', 'failed native page'); + expect(page.props.accessibilityElementsHidden).toBe(true); + await fire(input(root), 'onSubmitEditing'); + expect(theWebView().props.source?.uri).toBe('https://duckduckgo.com/?q=unfinished%20query'); + expect(textContents(root)).not.toContain(ERROR); + expect(button(root, 'Back').props.disabled).toBe(true); + expect(webViewRegistry()).toHaveLength(2); + } else { + await fire(input(root), 'onBlur'); + expect(input(root).props.value).toBe(SECOND); + } + }); + + it('keeps a slow background document mounted and lets Stop clear its notice and timer', async () => { + const { root } = await renderScreen(); + const native = await driver(); + await submit(root, FIRST); + await native.start(FIRST); + await switchTab(root, 'Settings'); + await actAsync(() => { vi.advanceTimersByTime(30_000); }); + expect(scenes(root)[0].props.pointerEvents).toBe('none'); + expect(textContents(root)).toContain(SLOW); + await switchTab(root, 'Browse'); + await fire(button(root, 'Stop loading'), 'onPress'); + await native.error(failure(FIRST, -1, 'net::ERR_ABORTED')); + expect(textContents(root)).not.toContain(SLOW); + expect(textContents(root)).not.toContain(ERROR); + expect(vi.getTimerCount()).toBe(0); + expect(theWebView().calls.stop).toBe(1); + expect(webViewRegistry()).toHaveLength(1); + }); +});