@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
22import {
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} ) ;
0 commit comments