From 803a12adf7fbf31e404dab14f2a09e3a097643dd Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Thu, 21 Aug 2025 14:41:53 -0400 Subject: [PATCH 1/2] Backport the ability to fix same page restores from hotwire-native-android --- turbo/src/main/assets/js/turbo_bridge.js | 17 ++++++- .../delegates/TurboWebFragmentDelegate.kt | 31 ++++++++---- .../TurboWebBottomSheetDialogFragment.kt | 5 ++ .../turbo/fragments/TurboWebFragment.kt | 7 ++- .../dev/hotwire/turbo/session/TurboSession.kt | 47 ++++++++++--------- .../dev/hotwire/turbo/views/TurboWebView.kt | 4 ++ .../hotwire/turbo/session/TurboSessionTest.kt | 40 ---------------- 7 files changed, 77 insertions(+), 74 deletions(-) diff --git a/turbo/src/main/assets/js/turbo_bridge.js b/turbo/src/main/assets/js/turbo_bridge.js index ff439f61..bd036498 100644 --- a/turbo/src/main/assets/js/turbo_bridge.js +++ b/turbo/src/main/assets/js/turbo_bridge.js @@ -39,7 +39,16 @@ let action = options.action if (window.Turbo) { - if (Turbo.navigator.locationWithActionIsSamePage(new URL(location), action)) { + if (Turbo.navigator.location?.href === location && action === "restore") { + // A "restore" visit to the currently rendered location can occur when visiting + // a web -> native -> back to web screen. In this situation, the connect() + // callback (from Stimulus) in bridge component controllers will not be called, + // since they are already connected. We need to notify the web bridge library + // that a "restore" visit has occurred to manually trigger connect() and notify + // the native app so the native bridge component view state can be restored. + Turbo.navigator.startVisit(location, restorationIdentifier, options) + document.dispatchEvent(new Event("native:restore")) + } else if (Turbo.navigator.locationWithActionIsSamePage(new URL(location), action)) { // Skip the same-page anchor scrolling behavior for visits initiated from the native // side. The page content may be stale and we want a fresh request from the network. Turbo.navigator.startVisit(location, restorationIdentifier, { "action": "replace" }) @@ -57,6 +66,12 @@ } } + cacheSnapshot() { + if (window.Turbo) { + Turbo.session.view.cacheSnapshot() + } + } + // Current visit issueRequestForVisitWithIdentifier(identifier) { diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt index b60c8fa1..3a0b1625 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt @@ -18,6 +18,7 @@ import dev.hotwire.turbo.session.TurboSession import dev.hotwire.turbo.session.TurboSessionCallback import dev.hotwire.turbo.session.TurboSessionModalResult import dev.hotwire.turbo.util.dispatcherProvider +import dev.hotwire.turbo.util.location import dev.hotwire.turbo.views.TurboView import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisit @@ -132,6 +133,24 @@ internal class TurboWebFragmentDelegate( } } + /** + * Should be called by the implementing Fragment during + * [androidx.fragment.app.Fragment.onDestroyView]. + */ + fun onDestroyView() { + // Manually cache a snapshot of the WebView when navigating from a + // web screen to a native screen. This allows a "restore" visit when + // revisiting this location again. + + val navHost = navDestination.sessionNavHostFragment + val currentBackStackEntry = navHost.navController.currentBackStackEntry + val currentLocation = currentBackStackEntry?.location + + if (session().currentVisit?.location != currentLocation) { + session().cacheSnapshot() + } + } + /** * Should be called by the implementing Fragment during * [dev.hotwire.turbo.nav.TurboNavDestination.refresh] @@ -322,15 +341,9 @@ internal class TurboWebFragmentDelegate( // Visit every time the WebView is reattached to the current Fragment. if (isWebViewAttachedToNewDestination) { - val currentSessionVisitRestored = !isInitialVisit && - session().currentVisit?.destinationIdentifier == identifier && - session().restoreCurrentVisit(this) - - if (!currentSessionVisitRestored) { - showProgressView(location) - visit(location, restoreWithCachedSnapshot = !isInitialVisit, reload = false) - isInitialVisit = false - } + showProgressView(location) + visit(location, restoreWithCachedSnapshot = !isInitialVisit, reload = false) + isInitialVisit = false } } } diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt index d7c37811..2abf9479 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebBottomSheetDialogFragment.kt @@ -40,6 +40,11 @@ abstract class TurboWebBottomSheetDialogFragment : TurboBottomSheetDialogFragmen webDelegate.onViewCreated() } + override fun onDestroyView() { + super.onDestroyView() + webDelegate.onDestroyView() + } + override fun activityResultLauncher(requestCode: Int): ActivityResultLauncher? { return when (requestCode) { TURBO_REQUEST_CODE_FILES -> webDelegate.fileChooserResultLauncher diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt index 28403616..ed2233f1 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/fragments/TurboWebFragment.kt @@ -9,11 +9,11 @@ import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import dev.hotwire.turbo.R import dev.hotwire.turbo.delegates.TurboWebFragmentDelegate +import dev.hotwire.turbo.errors.TurboVisitError import dev.hotwire.turbo.session.TurboSessionModalResult import dev.hotwire.turbo.util.TURBO_REQUEST_CODE_FILES import dev.hotwire.turbo.views.TurboView import dev.hotwire.turbo.views.TurboWebChromeClient -import dev.hotwire.turbo.errors.TurboVisitError /** * The base class from which all web "standard" fragments (non-dialogs) in a @@ -38,6 +38,11 @@ abstract class TurboWebFragment : TurboFragment(), TurboWebFragmentCallback { webDelegate.onViewCreated() } + override fun onDestroyView() { + super.onDestroyView() + webDelegate.onDestroyView() + } + override fun onStart() { super.onStart() diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt index 42144997..02467f95 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt @@ -5,7 +5,14 @@ import android.content.Context import android.graphics.Bitmap import android.net.http.SslError import android.util.SparseArray -import android.webkit.* +import android.webkit.HttpAuthHandler +import android.webkit.JavascriptInterface +import android.webkit.RenderProcessGoneDetail +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import androidx.webkit.WebResourceErrorCompat @@ -20,7 +27,11 @@ import dev.hotwire.turbo.errors.HttpError import dev.hotwire.turbo.errors.LoadError import dev.hotwire.turbo.errors.WebError import dev.hotwire.turbo.errors.WebSslError -import dev.hotwire.turbo.http.* +import dev.hotwire.turbo.http.TurboHttpClient +import dev.hotwire.turbo.http.TurboHttpRepository +import dev.hotwire.turbo.http.TurboOfflineRequestHandler +import dev.hotwire.turbo.http.TurboPreCacheRequest +import dev.hotwire.turbo.http.TurboWebViewRequestInterceptor import dev.hotwire.turbo.nav.TurboNavDestination import dev.hotwire.turbo.util.isHttpGetRequest import dev.hotwire.turbo.util.logEvent @@ -30,7 +41,7 @@ import dev.hotwire.turbo.views.TurboWebView import dev.hotwire.turbo.visit.TurboVisit import dev.hotwire.turbo.visit.TurboVisitAction import dev.hotwire.turbo.visit.TurboVisitOptions -import java.util.* +import java.util.Date /** * This class is primarily responsible for managing an instance of an Android WebView that will @@ -148,29 +159,19 @@ class TurboSession internal constructor( } /** - * Synthetically restore the WebView's current visit without using a cached snapshot or a - * visit request. This is used when restoring a Fragment destination from the backstack, - * but the WebView's current location hasn't changed from the destination's location. + * Cache a snapshot of the current visit. */ - internal fun restoreCurrentVisit(callback: TurboSessionCallback): Boolean { - val visit = currentVisit ?: return false - val restorationIdentifier = restorationIdentifiers[visit.destinationIdentifier] + fun cacheSnapshot() { + if (!isReady) return - if (!isReady || restorationIdentifier == null) { - return false - } - - logEvent("restoreCurrentVisit", - "location" to visit.location, - "visitIdentifier" to visit.identifier, - "restorationIdentifier" to restorationIdentifier - ) - - visit.callback = callback - visitRendered(visit.identifier) - visitCompleted(visit.identifier, restorationIdentifier) + currentVisit?.let { + logEvent("cacheSnapshot", + "location" to it.location, + "visitIdentifier" to it.identifier + ) - return true + webView.cacheSnapshot() + } } internal fun removeCallback(callback: TurboSessionCallback) { diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt index 9cf41e2e..96485821 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt @@ -60,6 +60,10 @@ open class TurboWebView @JvmOverloads constructor(context: Context, attrs: Attri runJavascript("turboNative.visitRenderedForColdBoot('$coldBootVisitIdentifier')") } + internal fun cacheSnapshot() { + runJavascript("turboNative.cacheSnapshot()") + } + internal fun installBridge(onBridgeInstalled: () -> Unit) { val script = "window.turboNative == null" val bridge = context.contentFromAsset("js/turbo_bridge.js") diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt index 47221757..4ed269dd 100644 --- a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt @@ -2,8 +2,6 @@ package dev.hotwire.turbo.session import android.os.Build import androidx.appcompat.app.AppCompatActivity -import com.nhaarman.mockito_kotlin.never -import com.nhaarman.mockito_kotlin.times import com.nhaarman.mockito_kotlin.whenever import dev.hotwire.turbo.errors.HttpError.ServerError import dev.hotwire.turbo.errors.LoadError @@ -205,44 +203,6 @@ class TurboSessionTest { assertThat(session.currentVisit?.identifier).isEmpty() } - @Test - fun restoreCurrentVisit() { - val visitIdentifier = "12345" - val restorationIdentifier = "67890" - - session.currentVisit = visit.copy(identifier = visitIdentifier) - session.turboIsReady(true) - session.pageLoaded(restorationIdentifier) - - assertThat(session.restoreCurrentVisit(callback)).isTrue() - verify(callback, times(2)).visitCompleted(false) - } - - @Test - fun restoreCurrentVisitFailsWithNoRestorationIdentifier() { - val visitIdentifier = "12345" - - session.currentVisit = visit.copy(identifier = visitIdentifier) - session.turboIsReady(true) - - assertThat(session.restoreCurrentVisit(callback)).isFalse() - verify(callback, times(1)).visitCompleted(false) - } - - @Test - fun restoreCurrentVisitFailsWithSessionNotReady() { - val visitIdentifier = "12345" - val restorationIdentifier = "67890" - - session.currentVisit = visit.copy(identifier = visitIdentifier) - session.pageLoaded(restorationIdentifier) - session.turboIsReady(false) - - assertThat(session.restoreCurrentVisit(callback)).isFalse() - verify(callback, never()).visitCompleted(false) - verify(callback).requestFailedWithError(false, LoadError.NotReady) - } - @Test fun webViewIsNotNull() { assertThat(session.webView).isNotNull From f68c6d026e55a0e5d7f3adbe59b05e5615d85758 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Mon, 25 Aug 2025 17:00:45 -0400 Subject: [PATCH 2/2] Revert back to the synthetic restore approach after more thorough testing, but retain the native:restore event dispatching, so the hotwire-native-bridge can trigger a "connect" on bridge components --- turbo/src/main/assets/js/turbo_bridge.js | 21 +++++----- .../delegates/TurboWebFragmentDelegate.kt | 12 ++++-- .../dev/hotwire/turbo/session/TurboSession.kt | 28 +++++++++++++ .../dev/hotwire/turbo/views/TurboWebView.kt | 4 ++ .../hotwire/turbo/session/TurboSessionTest.kt | 41 +++++++++++++++++++ 5 files changed, 93 insertions(+), 13 deletions(-) diff --git a/turbo/src/main/assets/js/turbo_bridge.js b/turbo/src/main/assets/js/turbo_bridge.js index bd036498..977ca7a5 100644 --- a/turbo/src/main/assets/js/turbo_bridge.js +++ b/turbo/src/main/assets/js/turbo_bridge.js @@ -39,16 +39,7 @@ let action = options.action if (window.Turbo) { - if (Turbo.navigator.location?.href === location && action === "restore") { - // A "restore" visit to the currently rendered location can occur when visiting - // a web -> native -> back to web screen. In this situation, the connect() - // callback (from Stimulus) in bridge component controllers will not be called, - // since they are already connected. We need to notify the web bridge library - // that a "restore" visit has occurred to manually trigger connect() and notify - // the native app so the native bridge component view state can be restored. - Turbo.navigator.startVisit(location, restorationIdentifier, options) - document.dispatchEvent(new Event("native:restore")) - } else if (Turbo.navigator.locationWithActionIsSamePage(new URL(location), action)) { + if (Turbo.navigator.locationWithActionIsSamePage(new URL(location), action)) { // Skip the same-page anchor scrolling behavior for visits initiated from the native // side. The page content may be stale and we want a fresh request from the network. Turbo.navigator.startVisit(location, restorationIdentifier, { "action": "replace" }) @@ -66,6 +57,16 @@ } } + restoreCurrentVisit() { + // A synthetic "restore" visit to the currently rendered location can occur when + // visiting a web -> native -> back to web screen. In this situation, the connect() + // callback (from Stimulus) in bridge component controllers will not be called, + // since they are already connected. We need to notify the web bridge library + // that the webview has been reattached to manually trigger connect() and notify + // the native app so the native bridge component view state can be restored. + document.dispatchEvent(new Event("native:restore")) + } + cacheSnapshot() { if (window.Turbo) { Turbo.session.view.cacheSnapshot() diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt index 3a0b1625..d34c8c48 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/delegates/TurboWebFragmentDelegate.kt @@ -341,9 +341,15 @@ internal class TurboWebFragmentDelegate( // Visit every time the WebView is reattached to the current Fragment. if (isWebViewAttachedToNewDestination) { - showProgressView(location) - visit(location, restoreWithCachedSnapshot = !isInitialVisit, reload = false) - isInitialVisit = false + val currentSessionVisitRestored = !isInitialVisit && + session().currentVisit?.destinationIdentifier == identifier && + session().restoreCurrentVisit(this) + + if (!currentSessionVisitRestored) { + showProgressView(location) + visit(location, restoreWithCachedSnapshot = !isInitialVisit, reload = false) + isInitialVisit = false + } } } } diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt index 02467f95..39543ce1 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt @@ -158,6 +158,34 @@ class TurboSession internal constructor( } } + /** + * Synthetically restore the WebView's current visit without using a cached snapshot or a + * visit request. This is used when restoring a Fragment destination from the backstack, + * but the WebView's current location hasn't changed from the destination's location. + */ + internal fun restoreCurrentVisit(callback: TurboSessionCallback): Boolean { + val visit = currentVisit ?: return false + val restorationIdentifier = restorationIdentifiers[visit.destinationIdentifier] + + if (!isReady || restorationIdentifier == null) { + return false + } + + logEvent("restoreCurrentVisit", + "location" to visit.location, + "visitIdentifier" to visit.identifier, + "restorationIdentifier" to restorationIdentifier + ) + + visit.callback = callback + visitRendered(visit.identifier) + visitCompleted(visit.identifier, restorationIdentifier) + + webView.restoreCurrentVisit() + + return true + } + /** * Cache a snapshot of the current visit. */ diff --git a/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt b/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt index 96485821..083af0ee 100644 --- a/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt +++ b/turbo/src/main/kotlin/dev/hotwire/turbo/views/TurboWebView.kt @@ -64,6 +64,10 @@ open class TurboWebView @JvmOverloads constructor(context: Context, attrs: Attri runJavascript("turboNative.cacheSnapshot()") } + internal fun restoreCurrentVisit() { + runJavascript("turboNative.restoreCurrentVisit()") + } + internal fun installBridge(onBridgeInstalled: () -> Unit) { val script = "window.turboNative == null" val bridge = context.contentFromAsset("js/turbo_bridge.js") diff --git a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt index 4ed269dd..58d10cd0 100644 --- a/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt +++ b/turbo/src/test/kotlin/dev/hotwire/turbo/session/TurboSessionTest.kt @@ -2,6 +2,8 @@ package dev.hotwire.turbo.session import android.os.Build import androidx.appcompat.app.AppCompatActivity +import com.nhaarman.mockito_kotlin.never +import com.nhaarman.mockito_kotlin.times import com.nhaarman.mockito_kotlin.whenever import dev.hotwire.turbo.errors.HttpError.ServerError import dev.hotwire.turbo.errors.LoadError @@ -203,6 +205,45 @@ class TurboSessionTest { assertThat(session.currentVisit?.identifier).isEmpty() } + @Test + fun restoreCurrentVisit() { + val visitIdentifier = "12345" + val restorationIdentifier = "67890" + + session.currentVisit = visit.copy(identifier = visitIdentifier) + session.turboIsReady(true) + session.pageLoaded(restorationIdentifier) + + assertThat(session.restoreCurrentVisit(callback)).isTrue() + verify(callback, times(2)).visitCompleted(false) + verify(webView, times(1)).restoreCurrentVisit() + } + + @Test + fun restoreCurrentVisitFailsWithNoRestorationIdentifier() { + val visitIdentifier = "12345" + + session.currentVisit = visit.copy(identifier = visitIdentifier) + session.turboIsReady(true) + + assertThat(session.restoreCurrentVisit(callback)).isFalse() + verify(callback, times(1)).visitCompleted(false) + } + + @Test + fun restoreCurrentVisitFailsWithSessionNotReady() { + val visitIdentifier = "12345" + val restorationIdentifier = "67890" + + session.currentVisit = visit.copy(identifier = visitIdentifier) + session.pageLoaded(restorationIdentifier) + session.turboIsReady(false) + + assertThat(session.restoreCurrentVisit(callback)).isFalse() + verify(callback, never()).visitCompleted(false) + verify(callback).requestFailedWithError(false, LoadError.NotReady) + } + @Test fun webViewIsNotNull() { assertThat(session.webView).isNotNull