Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
245 changes: 208 additions & 37 deletions apps/mobile/src/screens/BrowserScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
BackHandler,
Keyboard,
Platform,
StyleSheet,
Text,
Expand Down Expand Up @@ -32,6 +33,49 @@ 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<string | null>(null);
const hasFailed = failedUrl !== null;
const [slowLoad, setSlowLoad] = useState(false);
const editingRef = useRef(false);
const currentUrlRef = useRef(HOME);
const activeLoadRef = useRef(HOME);
const cancelledUrlRef = useRef<string | null>(null);
const pendingRef = useRef(false);
const supersededUrlsRef = useRef(new Set<string>());
const [viewKey, setViewKey] = useState(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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);
// 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) {
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
Expand All @@ -40,16 +84,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 = hasFailed;
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 !== null) {
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
Expand All @@ -59,21 +155,15 @@ 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 (
<View style={styles.container}>
<View style={styles.bar}>
<TouchableOpacity
style={[styles.navBtn, !canGoBack && styles.navBtnDisabled]}
onPress={() => webRef.current?.goBack()}
onPress={() => { supersededUrlsRef.current.clear(); webRef.current?.goBack(); }}
disabled={!canGoBack}
accessibilityRole="button"
accessibilityLabel="Back"
Expand All @@ -83,7 +173,7 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
</TouchableOpacity>
<TouchableOpacity
style={[styles.navBtn, !canGoForward && styles.navBtnDisabled]}
onPress={() => webRef.current?.goForward()}
onPress={() => { supersededUrlsRef.current.clear(); webRef.current?.goForward(); }}
disabled={!canGoForward}
accessibilityRole="button"
accessibilityLabel="Forward"
Expand All @@ -94,8 +184,14 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
<TextInput
style={styles.input}
value={address}
onChangeText={setAddress}
onFocus={() => { editingRef.current = true; }}
onBlur={discardEdit}
onChangeText={(text) => {
editingRef.current = true;
setAddress(text);
}}
onSubmitEditing={go}
submitBehavior="submit"
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
Expand All @@ -106,35 +202,101 @@ export function BrowserScreen({ isActive = true }: { isActive?: boolean }) {
/>
<TouchableOpacity
style={styles.navBtn}
onPress={() => webRef.current?.reload()}
onPress={loading ? stop : reload}
accessibilityRole="button"
accessibilityLabel="Reload"
accessibilityLabel={loading ? 'Stop loading' : 'Reload'}
>
<Text style={styles.navBtnText}></Text>
<Text style={styles.navBtnText}>{loading ? '×' : '⟳'}</Text>
</TouchableOpacity>
</View>
{loading && (
<ActivityIndicator style={styles.spinner} color={theme.accent} size="small" />
{slowLoad && (
<View style={styles.slowLoad} accessibilityRole="alert">
<Text style={styles.slowText}>This page is taking longer to load.</Text>
<TouchableOpacity style={styles.navBtn} onPress={() => setSlowLoad(false)}
accessibilityRole="button" accessibilityLabel="Dismiss slow loading notice">
<Text style={styles.navBtnText}>×</Text>
</TouchableOpacity>
</View>
)}
<WebView
ref={webRef}
source={{ uri }}
style={styles.web}
// 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);
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'}
/>
<View style={styles.web}>
{/* Keep the native page behind a real accessibility boundary on errors. */}
<View style={styles.web} collapsable={false}
accessibilityElementsHidden={hasFailed}
importantForAccessibility={hasFailed ? 'no-hide-descendants' : 'auto'}
pointerEvents={hasFailed ? 'none' : 'auto'}>
<WebView
key={viewKey}
ref={webRef}
source={{ uri }}
style={styles.web}
// Android also emits load-start for history updates after loading ends.
// iOS emits start before allowing navigation, so retain its start flag.
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 || 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'}
/>
</View>
{loading && (
<ActivityIndicator style={styles.spinner} color={theme.accent} size="small" />
)}
{hasFailed && (
<View style={styles.error} accessibilityRole="alert" accessibilityViewIsModal>
<Text style={styles.errorText}>This page did not finish loading.</Text>
<TouchableOpacity style={styles.retry} onPress={reload}
accessibilityRole="button" accessibilityLabel="Retry page">
<Text style={styles.errorText}>Retry</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
}
Expand Down Expand Up @@ -169,6 +331,15 @@ 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: 0, 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 },
});
21 changes: 21 additions & 0 deletions apps/mobile/test/app-tabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);
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 {
Expand Down
Loading
Loading