Skip to content

Commit

Permalink
Merge pull request #187 from hotwired/blank-webview
Browse files Browse the repository at this point in the history
Reload or recreate web view when its process is terminated
  • Loading branch information
svara authored Mar 5, 2024
2 parents c908899 + f6e0be6 commit 6590418
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 24 deletions.
8 changes: 8 additions & 0 deletions Demo/SceneController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ extension SceneController: UIWindowSceneDelegate {

navigator.route(rootURL)
}

func sceneDidBecomeActive(_ scene: UIScene) {
navigator.appDidBecomeActive()
}

func sceneDidEnterBackground(_ scene: UIScene) {
navigator.appDidEnterBackground()
}
}

extension SceneController: TurboNavigatorDelegate {
Expand Down
28 changes: 28 additions & 0 deletions Source/Turbo Navigator/Extensions/WKWebView+ebContentProcess.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation
import WebKit

enum WebContentProcessState {
case active
case terminated
}

extension WKWebView {
/// Queries the state of the web content process asynchronously.
///
/// This method evaluates a simple JavaScript function in the web view to determine if the web content process is active.
///
/// - Parameter completionHandler: A closure to be called when the query completes. The closure takes a single argument representing the state of the web content process.
///
/// - Note: The web content process is considered active if the JavaScript evaluation succeeds without error.
/// If an error occurs during evaluation, the process is considered terminated.
func queryWebContentProcessState(completionHandler: @escaping (WebContentProcessState) -> Void) {
evaluateJavaScript("(function() { return '1'; })();") { _, error in
if let _ = error {
completionHandler(.terminated)
return
}

completionHandler(.active)
}
}
}
148 changes: 124 additions & 24 deletions Source/Turbo Navigator/TurboNavigator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,6 @@ public class TurboNavigator {
}
}

/// Default initializer requiring preconfigured `Session` instances.
///
/// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`.
/// - Parameters:
/// - session: the main `Session`
/// - modalSession: the `Session` used for the modal navigation controller
/// - delegate: _optional:_ delegate to handle custom view controllers
public init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) {
self.session = session
self.modalSession = modalSession

self.delegate = delegate ?? navigatorDelegate

self.session.delegate = self
self.modalSession.delegate = self

self.webkitUIDelegate = TurboWKUIController(delegate: self)
session.webView.uiDelegate = webkitUIDelegate
modalSession.webView.uiDelegate = webkitUIDelegate
}

/// Convenience initializer that doesn't require manually creating `Session` instances.
/// - Parameters:
/// - pathConfiguration: _optional:_ remote configuration reference
Expand Down Expand Up @@ -105,15 +84,50 @@ public class TurboNavigator {
return
}
}

public func appDidBecomeActive() {
appInBackground = false
inspectAllSessions()
}

public func appDidEnterBackground() {
appInBackground = true
}

let session: Session
let modalSession: Session
// MARK: Internal

var session: Session
var modalSession: Session
/// Modifies a UINavigationController according to visit proposals.
lazy var hierarchyController = TurboNavigationHierarchyController(delegate: self)

/// Internal initializer requiring preconfigured `Session` instances.
///
/// User `init(pathConfiguration:delegate:)` to only provide a `PathConfiguration`.
/// - Parameters:
/// - session: the main `Session`
/// - modalSession: the `Session` used for the modal navigation controller
/// - delegate: _optional:_ delegate to handle custom view controllers
init(session: Session, modalSession: Session, delegate: TurboNavigatorDelegate? = nil) {
self.session = session
self.modalSession = modalSession

self.delegate = delegate ?? navigatorDelegate

self.session.delegate = self
self.modalSession.delegate = self

self.webkitUIDelegate = TurboWKUIController(delegate: self)
session.webView.uiDelegate = webkitUIDelegate
modalSession.webView.uiDelegate = webkitUIDelegate
}

// MARK: Private

/// A default delegate implementation if none is provided.
private let navigatorDelegate = DefaultTurboNavigatorDelegate()
private var backgroundTerminatedWebViewSessions = [Session]()
private var appInBackground = false

private func controller(for proposal: VisitProposal) -> UIViewController? {
switch delegate.handle(proposal: proposal) {
Expand Down Expand Up @@ -162,7 +176,7 @@ extension TurboNavigator: SessionDelegate {
}

public func sessionWebViewProcessDidTerminate(_ session: Session) {
session.reload()
reloadIfPermitted(session)
}

public func session(_ session: Session, didReceiveAuthenticationChallenge challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
Expand Down Expand Up @@ -205,3 +219,89 @@ extension TurboNavigator: TurboWKUIDelegate {
hierarchyController.activeNavigationController.present(alert, animated: animated)
}
}

// MARK: - Session and web view reloading

extension TurboNavigator {
private func inspectAllSessions() {
[session, modalSession].forEach { inspect($0) }
}

private func reloadIfPermitted(_ session: Session) {
/// If the web view process is terminated, it leaves the web view with a white screen, so we need to reload it.
/// However, if the web view is no longer onscreen, such as after visiting a page and going back to a native view,
/// then reloading will unnecessarily fetch all the content, and on next visit,
/// it will trigger various bridge messages since the web view will be added to the window and call all the connect() methods.
///
/// We don't want to reload a view controller not on screen, since that can have unwanted
/// side-effects for the next visit (like showing the wrong bridge components). We can't just
/// check if the view controller is visible, since it may be further back in the stack of a navigation controller.
/// Seeing if there is a parent was the best solution I could find.
guard let viewController = session.activeVisitable?.visitableViewController,
viewController.parent != nil else {
return
}

if appInBackground {
/// Don't reload the web view if the app is in the background.
/// Instead, save the session in `backgroundTerminatedWebViewSessions`
/// and reload it when the app is back in foreground.
backgroundTerminatedWebViewSessions.append(session)
return
}

reload(session)
}

private func reload(_ session: Session) {
session.reload()
}

/// Inspects the provided session to handle terminated web view process and reloads or recreates the web view accordingly.
///
/// - Parameter session: The session to inspect.
///
/// This method checks if the web view associated with the session has terminated in the background.
/// If so, it removes the session from the list of background terminated web view processes, reloads the session, and returns.
/// If the session's topmost visitable URL is not available, the method returns without further action.
/// If the web view's content process state is non-recoverable/terminated, it recreates the web view for the session.
private func inspect(_ session: Session) {
if let index = backgroundTerminatedWebViewSessions.firstIndex(where: { $0 === session }) {
backgroundTerminatedWebViewSessions.remove(at: index)
reload(session)
return
}

guard let _ = session.topmostVisitable?.visitableURL else {
return
}

session.webView.queryWebContentProcessState { [weak self] state in
guard case .terminated = state else { return }
self?.recreateWebView(for: session)
}
}

/// Recreates the web view and session for the given session and performs a `replace` visit.
///
/// - Parameter session: The session to recreate.
private func recreateWebView(for session: Session) {
guard let _ = session.activeVisitable?.visitableViewController,
let url = session.activeVisitable?.visitableURL else { return }

let newSession = Session(webView: Turbo.config.makeWebView())
newSession.pathConfiguration = session.pathConfiguration
newSession.delegate = self
newSession.webView.uiDelegate = webkitUIDelegate

if session == self.session {
self.session = newSession
} else {
self.modalSession = newSession
}

let options = VisitOptions(action: .replace, response: nil)
let properties = session.pathConfiguration?.properties(for: url) ?? PathProperties()
route(VisitProposal(url: url, options: options, properties: properties))
}
}

0 comments on commit 6590418

Please sign in to comment.