Skip to content

Commit 2a2d149

Browse files
Keep browser address edits and recover failed navigation (#101)
* Keep browser address edits and recover failed navigation * Cover the full browser error area and isolate native page access * Ignore delayed callbacks from stopped pages after navigation
1 parent de49c0b commit 2a2d149

8 files changed

Lines changed: 890 additions & 40 deletions

File tree

‎apps/mobile/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,22 @@ eas submit --platform android
7070
**Monorepo note:** in the EAS GitHub integration
7171
(expo.dev → project → GitHub), set the **Base directory** to `apps/mobile` for
7272
both Android and iOS — that's where this Expo app lives.
73+
74+
## Browser recovery checks
75+
76+
The address field keeps edits during redirects. Leaving the field without
77+
submitting restores the current page address; submitting navigates or reloads
78+
without remounting the WebView. Stop cancels the current load. Network failures
79+
have a manual Retry action; certificate errors are never bypassed.
80+
Explicit recovery from a failed page recreates the WebView so an iOS provisional
81+
failure cannot reload the wrong document; this recovery discards native history.
82+
After 30 seconds, a dismissible notice flags a slow load without stopping it or
83+
covering usable content. Stop remains available; there is no automatic retry.
84+
85+
Component tests drive the native WebView boundary for edit/redirect races,
86+
same-source submission, error recovery, cancellation and stale completion events.
87+
They do not run Android WebView or iOS WKWebView. Before a native release, verify
88+
these flows on the target engine, including two requests to the same URL: native
89+
events do not expose a request ID, so URL-based stale-event filtering cannot
90+
distinguish every same-URL overlapping navigation. No paid build is required by
91+
the local component test suite.

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

Lines changed: 208 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
22
import {
33
ActivityIndicator,
44
BackHandler,
5+
Keyboard,
56
Platform,
67
StyleSheet,
78
Text,
@@ -32,6 +33,49 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
3233
const [loading, setLoading] = useState(false);
3334
const [canGoBack, setCanGoBack] = useState(false);
3435
const [canGoForward, setCanGoForward] = useState(false);
36+
const [failedUrl, setFailedUrl] = useState<string | null>(null);
37+
const hasFailed = failedUrl !== null;
38+
const [slowLoad, setSlowLoad] = useState(false);
39+
const editingRef = useRef(false);
40+
const currentUrlRef = useRef(HOME);
41+
const activeLoadRef = useRef(HOME);
42+
const cancelledUrlRef = useRef<string | null>(null);
43+
const pendingRef = useRef(false);
44+
const supersededUrlsRef = useRef(new Set<string>());
45+
const [viewKey, setViewKey] = useState(0);
46+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47+
48+
const clearLoadTimeout = () => {
49+
if (timeoutRef.current !== null) clearTimeout(timeoutRef.current);
50+
timeoutRef.current = null;
51+
};
52+
useEffect(() => () => clearLoadTimeout(), []);
53+
54+
const beginLoad = (url: string) => {
55+
if (pendingRef.current && activeLoadRef.current !== url) supersededUrlsRef.current.add(activeLoadRef.current);
56+
// Stop ends the pending state, but its native callbacks may still arrive
57+
// after the user starts another page.
58+
if (cancelledUrlRef.current !== null && cancelledUrlRef.current !== url) {
59+
supersededUrlsRef.current.add(cancelledUrlRef.current);
60+
}
61+
supersededUrlsRef.current.delete(url);
62+
// Native events lack request IDs; retain only a bounded recent history.
63+
if (supersededUrlsRef.current.size > 16) {
64+
supersededUrlsRef.current.delete(supersededUrlsRef.current.values().next().value!);
65+
}
66+
activeLoadRef.current = url;
67+
cancelledUrlRef.current = null;
68+
pendingRef.current = true;
69+
setFailedUrl(null);
70+
setSlowLoad(false);
71+
setLoading(true);
72+
clearLoadTimeout();
73+
timeoutRef.current = setTimeout(() => {
74+
timeoutRef.current = null;
75+
// A slow document may already be usable. Only the user may stop it.
76+
setSlowLoad(true);
77+
}, 30_000);
78+
};
3579

3680
// Android system Back pops WebView history. Subscribe only while this tab is
3781
// the visible one AND there is history to pop; otherwise no handler exists at
@@ -40,16 +84,68 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
4084
useEffect(() => {
4185
if (Platform.OS !== 'android' || !isActive || !canGoBack) return;
4286
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
87+
supersededUrlsRef.current.clear();
4388
webRef.current?.goBack();
4489
return true;
4590
});
4691
return () => subscription.remove();
4792
}, [isActive, canGoBack]);
4893

94+
const navigateTo = (next: string) => {
95+
const recovering = hasFailed;
96+
const reloadCurrent = next === currentUrlRef.current &&
97+
(!pendingRef.current || activeLoadRef.current === next);
98+
beginLoad(next);
99+
if (recovering) {
100+
// An error/interstitial may have no executable document. On iOS reload
101+
// after a provisional failure can reload the previous committed page.
102+
// Recreate only on explicit error recovery, accepting history loss here.
103+
setUri(next);
104+
setCanGoBack(false);
105+
setCanGoForward(false);
106+
supersededUrlsRef.current.clear();
107+
setViewKey(key => key + 1);
108+
} else if (reloadCurrent) {
109+
webRef.current?.reload();
110+
} else if (next === uri) {
111+
// The source prop can lag behind in-page navigation. Reassigning an
112+
// unchanged source does nothing; keep the native view and its history.
113+
webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`);
114+
} else {
115+
setUri(next);
116+
}
117+
if (!editingRef.current) setAddress(next);
118+
};
119+
49120
const go = () => {
50121
const next = normalizeUrl(address);
51-
setUri(next);
52-
setAddress(next);
122+
editingRef.current = false;
123+
navigateTo(next);
124+
Keyboard.dismiss();
125+
};
126+
127+
const reload = () => {
128+
if (failedUrl !== null) {
129+
navigateTo(failedUrl);
130+
return;
131+
}
132+
beginLoad(currentUrlRef.current);
133+
webRef.current?.reload();
134+
};
135+
136+
const stop = () => {
137+
cancelledUrlRef.current = activeLoadRef.current;
138+
webRef.current?.stopLoading();
139+
pendingRef.current = false;
140+
clearLoadTimeout();
141+
setLoading(false);
142+
setSlowLoad(false);
143+
};
144+
145+
const discardEdit = () => {
146+
editingRef.current = false;
147+
// Leaving the field without submitting cancels the draft, not navigation.
148+
setAddress(pendingRef.current ? activeLoadRef.current : currentUrlRef.current);
53149
};
54150

55151
// Android hands `window.open` / `target="_blank"` to a detached WebView the
@@ -59,21 +155,15 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
59155
const openWindowInThisTab = (targetUrl: string) => {
60156
const next = navigableHttpUrl(targetUrl);
61157
if (!next) return;
62-
// In-page navigation can leave `uri` unchanged. Updating the same source
63-
// would do nothing; navigate the existing WebView without discarding history.
64-
if (next === uri) {
65-
webRef.current?.injectJavaScript(`window.location.assign(${JSON.stringify(next)});true;`);
66-
}
67-
setUri(next);
68-
setAddress(next);
158+
navigateTo(next);
69159
};
70160

71161
return (
72162
<View style={styles.container}>
73163
<View style={styles.bar}>
74164
<TouchableOpacity
75165
style={[styles.navBtn, !canGoBack && styles.navBtnDisabled]}
76-
onPress={() => webRef.current?.goBack()}
166+
onPress={() => { supersededUrlsRef.current.clear(); webRef.current?.goBack(); }}
77167
disabled={!canGoBack}
78168
accessibilityRole="button"
79169
accessibilityLabel="Back"
@@ -83,7 +173,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
83173
</TouchableOpacity>
84174
<TouchableOpacity
85175
style={[styles.navBtn, !canGoForward && styles.navBtnDisabled]}
86-
onPress={() => webRef.current?.goForward()}
176+
onPress={() => { supersededUrlsRef.current.clear(); webRef.current?.goForward(); }}
87177
disabled={!canGoForward}
88178
accessibilityRole="button"
89179
accessibilityLabel="Forward"
@@ -94,8 +184,14 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
94184
<TextInput
95185
style={styles.input}
96186
value={address}
97-
onChangeText={setAddress}
187+
onFocus={() => { editingRef.current = true; }}
188+
onBlur={discardEdit}
189+
onChangeText={(text) => {
190+
editingRef.current = true;
191+
setAddress(text);
192+
}}
98193
onSubmitEditing={go}
194+
submitBehavior="submit"
99195
autoCapitalize="none"
100196
autoCorrect={false}
101197
keyboardType="url"
@@ -106,35 +202,101 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
106202
/>
107203
<TouchableOpacity
108204
style={styles.navBtn}
109-
onPress={() => webRef.current?.reload()}
205+
onPress={loading ? stop : reload}
110206
accessibilityRole="button"
111-
accessibilityLabel="Reload"
207+
accessibilityLabel={loading ? 'Stop loading' : 'Reload'}
112208
>
113-
<Text style={styles.navBtnText}>⟳</Text>
209+
<Text style={styles.navBtnText}>{loading ? '×' : '⟳'}</Text>
114210
</TouchableOpacity>
115211
</View>
116-
{loading && (
117-
<ActivityIndicator style={styles.spinner} color={theme.accent} size="small" />
212+
{slowLoad && (
213+
<View style={styles.slowLoad} accessibilityRole="alert">
214+
<Text style={styles.slowText}>This page is taking longer to load.</Text>
215+
<TouchableOpacity style={styles.navBtn} onPress={() => setSlowLoad(false)}
216+
accessibilityRole="button" accessibilityLabel="Dismiss slow loading notice">
217+
<Text style={styles.navBtnText}>×</Text>
218+
</TouchableOpacity>
219+
</View>
118220
)}
119-
<WebView
120-
ref={webRef}
121-
source={{ uri }}
122-
style={styles.web}
123-
// Android also emits load-start for history updates after loading ends.
124-
// iOS emits start before allowing navigation, so retain its start flag.
125-
onLoadStart={(event) => setLoading(Platform.OS === 'android' ? event.nativeEvent.loading : true)}
126-
onLoadEnd={() => setLoading(false)}
127-
onNavigationStateChange={(state) => {
128-
setAddress(state.url);
129-
setCanGoBack(state.canGoBack);
130-
setCanGoForward(state.canGoForward);
131-
}}
132-
onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)}
133-
// Privacy-leaning defaults consistent with the desktop ethos.
134-
thirdPartyCookiesEnabled={false}
135-
allowsInlineMediaPlayback
136-
pullToRefreshEnabled={Platform.OS === 'ios'}
137-
/>
221+
<View style={styles.web}>
222+
{/* Keep the native page behind a real accessibility boundary on errors. */}
223+
<View style={styles.web} collapsable={false}
224+
accessibilityElementsHidden={hasFailed}
225+
importantForAccessibility={hasFailed ? 'no-hide-descendants' : 'auto'}
226+
pointerEvents={hasFailed ? 'none' : 'auto'}>
227+
<WebView
228+
key={viewKey}
229+
ref={webRef}
230+
source={{ uri }}
231+
style={styles.web}
232+
// Android also emits load-start for history updates after loading ends.
233+
// iOS emits start before allowing navigation, so retain its start flag.
234+
onLoadStart={({ nativeEvent }) => {
235+
// Completed Android history callbacks are not new network loads.
236+
if (Platform.OS === 'android' && !nativeEvent.loading) {
237+
return;
238+
}
239+
beginLoad(nativeEvent.url);
240+
}}
241+
onLoadEnd={({ nativeEvent }) => {
242+
if (supersededUrlsRef.current.has(nativeEvent.url)) return;
243+
pendingRef.current = false;
244+
clearLoadTimeout();
245+
setLoading(false);
246+
setSlowLoad(false);
247+
if (!('code' in nativeEvent) && nativeEvent.url !== cancelledUrlRef.current) {
248+
activeLoadRef.current = nativeEvent.url;
249+
currentUrlRef.current = nativeEvent.url;
250+
if (!editingRef.current) setAddress(nativeEvent.url);
251+
}
252+
}}
253+
onError={(event) => {
254+
// Own the error UI: the library's default ERROR overlay otherwise
255+
// hides the native view, including when a stale failure is ignored.
256+
event.preventDefault();
257+
const { url, code, description } = event.nativeEvent;
258+
if (supersededUrlsRef.current.has(url) || url === cancelledUrlRef.current) return;
259+
clearLoadTimeout();
260+
setSlowLoad(false);
261+
if ((Platform.OS === 'ios' && (code === -999 || code === 102)) || description?.includes('ERR_ABORTED')) {
262+
pendingRef.current = false;
263+
setLoading(false);
264+
return;
265+
}
266+
setLoading(false);
267+
pendingRef.current = false;
268+
setFailedUrl(url || activeLoadRef.current);
269+
}}
270+
onNavigationStateChange={(state) => {
271+
if (supersededUrlsRef.current.has(state.url)) return;
272+
if (!pendingRef.current && (state.loading === false || state.loading === undefined)) {
273+
currentUrlRef.current = state.url;
274+
}
275+
if (pendingRef.current) activeLoadRef.current = state.url;
276+
if (!editingRef.current) setAddress(state.url);
277+
setCanGoBack(state.canGoBack);
278+
setCanGoForward(state.canGoForward);
279+
}}
280+
onOpenWindow={(event) => openWindowInThisTab(event.nativeEvent.targetUrl)}
281+
// Privacy-leaning defaults consistent with the desktop ethos.
282+
thirdPartyCookiesEnabled={false}
283+
allowsInlineMediaPlayback
284+
pullToRefreshEnabled={Platform.OS === 'ios'}
285+
/>
286+
</View>
287+
{loading && (
288+
<ActivityIndicator style={styles.spinner} color={theme.accent} size="small" />
289+
)}
290+
{hasFailed && (
291+
<View style={styles.error} accessibilityRole="alert" accessibilityViewIsModal>
292+
<Text style={styles.errorText}>This page did not finish loading.</Text>
293+
<TouchableOpacity style={styles.retry} onPress={reload}
294+
accessibilityRole="button" accessibilityLabel="Retry page">
295+
<Text style={styles.errorText}>Retry</Text>
296+
</TouchableOpacity>
297+
</View>
298+
)}
299+
</View>
138300
</View>
139301
);
140302
}
@@ -169,6 +331,15 @@ const styles = StyleSheet.create({
169331
color: theme.text,
170332
backgroundColor: theme.surfaceAlt,
171333
},
172-
spinner: { position: 'absolute', top: 56, alignSelf: 'center', zIndex: 2 },
334+
spinner: { position: 'absolute', top: 8, alignSelf: 'center', zIndex: 2 },
173335
web: { flex: 1, backgroundColor: theme.bg },
336+
slowLoad: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 8, backgroundColor: theme.surface },
337+
slowText: { flex: 1, color: theme.text, fontSize: 14 },
338+
error: {
339+
position: 'absolute', top: 0, bottom: 0, left: 0, right: 0,
340+
alignItems: 'center', justifyContent: 'center', gap: 16,
341+
padding: 24, backgroundColor: theme.bg,
342+
},
343+
errorText: { color: theme.text, fontSize: 16, textAlign: 'center' },
344+
retry: { paddingHorizontal: 24, paddingVertical: 12, backgroundColor: theme.surfaceAlt, borderRadius: 8 },
174345
});

‎apps/mobile/test/app-tabs.test.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,27 @@ describe('App tab shell', () => {
129129
expect(backButton.props.accessibilityState).toEqual({ disabled: false });
130130
});
131131

132+
it('keeps a failed browser and its retry UI inside the inactive tab boundary', async () => {
133+
const { root } = await renderScreen(<App />);
134+
const webview = hostWhere(root, 'WebView', () => true, 'browser');
135+
await fire(webview, 'onError', {
136+
nativeEvent: { url: 'https://example.test/', code: -2, description: 'network failure' },
137+
preventDefault: vi.fn(),
138+
});
139+
const browser = scenes(root)[0];
140+
expect(hostWhere(browser, 'TouchableOpacity', n => n.props.accessibilityLabel === 'Retry page', 'retry')).toBeDefined();
141+
await switchTab(root, 'Chat');
142+
expect(scenes(root)).toHaveLength(4);
143+
expect(browser.props.accessibilityElementsHidden).toBe(true);
144+
expect(browser.props.importantForAccessibility).toBe('no-hide-descendants');
145+
expect(browser.props.pointerEvents).toBe('none');
146+
await switchTab(root, 'Browse');
147+
expect(browser.props.accessibilityElementsHidden).toBe(false);
148+
const page = hosts(browser, 'View').find(n => n.props.pointerEvents === 'none');
149+
expect(page?.props.importantForAccessibility).toBe('no-hide-descendants');
150+
expect(webViewRegistry()).toHaveLength(1);
151+
});
152+
132153
it('keeps chat history and the unsent draft across tab switches', async () => {
133154
vi.useFakeTimers();
134155
try {

0 commit comments

Comments
 (0)