Explorar o código

enhance: mobile multi navigation stacks

Tienson Qin hai 5 días
pai
achega
cbb7556178

+ 172 - 20
ios/App/App/AppDelegate.swift

@@ -8,15 +8,70 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
 
     var window: UIWindow?
     var navController: UINavigationController?
+
+    // ---------------------------------------------------------
+    // MARK: Multi-stack routing state
+    // ---------------------------------------------------------
+
+    /// Currently active logical stack id (must match CLJS :stack, e.g. "home", "capture", "goto").
+    private var activeStackId: String = "home"
+
+    /// Per-stack path stacks, including the active one.
+    /// Example: ["home": ["/", "/page/A"], "capture": ["/__stack__/capture"]]
+    private var stackPathStacks: [String: [String]] = [
+        "home": ["/"]
+    ]
+
+    /// Mirror of the active stack's paths.
     private var pathStack: [String] = ["/"]
-    private var ignoreRoutePopCount = 0
+
+    /// Used to ignore JS-driven pops when we're popping in response to a native gesture.
+    private var ignoreRoutePopCount: Int = 0
+
+    /// Temporary snapshot image for smooth pop transitions.
     private var popSnapshotView: UIView?
 
+    // ---------------------------------------------------------
+    // MARK: Helpers
+    // ---------------------------------------------------------
+
     private func normalizedPath(_ raw: String?) -> String {
         guard let raw = raw, !raw.isEmpty else { return "/" }
         return raw
     }
 
+    private func debugLogStacks(_ label: String) {
+        #if DEBUG
+        print("🧭 [\(label)] activeStackId=\(activeStackId)")
+        print("   pathStack=\(pathStack)")
+        print("   stackPathStacks=\(stackPathStacks)")
+        #endif
+    }
+
+    /// Returns the current native path stack for a given logical stack id,
+    /// or initialises a sensible default if none exists yet.
+    private func paths(for stackId: String) -> [String] {
+        if let existing = stackPathStacks[stackId], !existing.isEmpty {
+            return existing
+        }
+
+        if stackId == "home" {
+            return ["/"]
+        } else {
+            // Virtual stacks (e.g. capture, search, goto) default to a stack-root path.
+            return ["/__stack__/\(stackId)"]
+        }
+    }
+
+    /// Updates the stored paths for a given stack id and keeps `pathStack`
+    /// consistent if this is the active stack.
+    private func setPaths(_ paths: [String], for stackId: String) {
+        stackPathStacks[stackId] = paths
+        if stackId == activeStackId {
+            pathStack = paths
+        }
+    }
+
     // ---------------------------------------------------------
     // MARK: UIApplication lifecycle
     // ---------------------------------------------------------
@@ -124,7 +179,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
     }
 
     // ---------------------------------------------------------
-    // MARK: Navigation operations
+    // MARK: Navigation operations (within active stack)
     // ---------------------------------------------------------
 
     private func emptyNavStack(path: String) {
@@ -137,10 +192,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
 
         let vc = NativePageViewController(path: path, push: false)
         pathStack = [path]
+        setPaths(pathStack, for: activeStackId)
 
         nav.setViewControllers([vc], animated: false)
         SharedWebViewController.instance.clearPlaceholder()
         SharedWebViewController.instance.attach(to: vc)
+
+        debugLogStacks("emptyNavStack")
     }
 
     private func pushIfNeeded(path: String, animated: Bool) {
@@ -154,7 +212,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
 
         let vc = NativePageViewController(path: path, push: true)
         pathStack.append(path)
+        setPaths(pathStack, for: activeStackId)
+
         nav.pushViewController(vc, animated: animated)
+
+        debugLogStacks("pushIfNeeded")
     }
 
     private func replaceTop(path: String) {
@@ -162,9 +224,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
         guard let nav = navController else { return }
 
         _ = pathStack.popLast()
-        let vc = NativePageViewController(path: path, push: false)
         pathStack.append(path)
+        setPaths(pathStack, for: activeStackId)
 
+        let vc = NativePageViewController(path: path, push: false)
         var stack = nav.viewControllers
         if stack.isEmpty {
             stack = [vc]
@@ -172,6 +235,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             stack[stack.count - 1] = vc
         }
         nav.setViewControllers(stack, animated: false)
+
+        debugLogStacks("replaceTop")
     }
 
     private func popIfNeeded(animated: Bool) {
@@ -179,7 +244,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
 
         if nav.viewControllers.count > 1 {
             _ = pathStack.popLast()
+            setPaths(pathStack, for: activeStackId)
             nav.popViewController(animated: animated)
+
+            debugLogStacks("popIfNeeded")
         }
     }
 
@@ -206,15 +274,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             vcs.count < pathStack.count
         }
 
+        #if DEBUG
+        print("🧭 willShow — isPop=\(isPop)")
+        print("   toVC=\(toVC.targetPath) fromVC=\(String(describing: fromVC?.targetPath))")
+        debugLogStacks("willShow")
+        #endif
+
         if isPop {
             // -----------------------------
-            // POP — keep your existing logic
+            // POP — update per-stack pathStack, then notify JS.
             // -----------------------------
             let previousStack = pathStack
-            if pathStack.count > 1 { _ = pathStack.popLast() }
+
+            if pathStack.count > 1 {
+                _ = pathStack.popLast()
+            }
             if let last = pathStack.last, last != toVC.targetPath {
                 pathStack[pathStack.count - 1] = toVC.targetPath
             }
+            setPaths(pathStack, for: activeStackId)
 
             popSnapshotView?.removeFromSuperview()
             popSnapshotView = nil
@@ -227,9 +305,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
                 popSnapshotView = iv
             }
 
-            coordinator.animate(alongsideTransition: nil) { ctx in
+            coordinator.animate(alongsideTransition: nil) { [weak self] ctx in
+                guard let self else { return }
+
                 guard !ctx.isCancelled else {
                     self.pathStack = previousStack
+                    self.setPaths(previousStack, for: self.activeStackId)
+
                     if let fromVC {
                         SharedWebViewController.instance.attach(to: fromVC)
                     }
@@ -237,12 +319,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
                     return
                 }
 
-                if let webView = SharedWebViewController.instance.bridgeController.bridge?.webView,
-                   webView.canGoBack {
-                    self.ignoreRoutePopCount += 1
-                    webView.goBack()
-                } else {
-                    self.ignoreRoutePopCount += 1
+                // 🔑 DO NOT call webView.goBack().
+                // Tell JS explicitly that native popped.
+                self.ignoreRoutePopCount += 1
+                #if DEBUG
+                print("⬅️ Native POP completed, notifying JS via onNativePop(), ignoreRoutePopCount=\(self.ignoreRoutePopCount)")
+                debugLogStacks("after native-pop pathStack update")
+                #endif
+
+                if let bridge = SharedWebViewController.instance.bridgeController.bridge {
+                    let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();"
+                    bridge.webView?.evaluateJavaScript(js, completionHandler: nil)
                 }
 
                 SharedWebViewController.instance.attach(
@@ -264,8 +351,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             // -----------------------------
             // PUSH / RESET
             // -----------------------------
-            // Attach the shared webview to the *destination* page
-            // before/during the animation so it can start rendering immediately.
             SharedWebViewController.instance.attach(
               to: toVC,
               leavePlaceholderInPreviousParent: fromVC != nil
@@ -273,10 +358,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
 
             coordinator.animate(alongsideTransition: nil) { ctx in
                 if ctx.isCancelled, let fromVC {
-                    // If the push is cancelled (interactive back), put the webview back.
                     SharedWebViewController.instance.attach(to: fromVC)
                 } else {
-                    // Transition completed → clear any placeholders.
                     SharedWebViewController.instance.clearPlaceholder()
                 }
             }
@@ -294,7 +377,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             SharedWebViewController.instance.clearPlaceholder()
             SharedWebViewController.instance.attach(to: current)
         }
-
     }
 
     func navigationController(
@@ -308,7 +390,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
     }
 
     // ---------------------------------------------------------
-    // MARK: Route Observation
+    // MARK: Route Observation (JS -> Native)
     // ---------------------------------------------------------
 
     private func observeRouteChanges() {
@@ -318,10 +400,69 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             queue: .main
         ) { [weak self] notification in
             guard let self else { return }
+            guard let nav = self.navController else { return }
 
-            let path = self.normalizedPath(notification.userInfo?["path"] as? String)
+            let rawPath = notification.userInfo?["path"] as? String
+            let path = self.normalizedPath(rawPath)
             let navigationType = (notification.userInfo?["navigationType"] as? String) ?? "push"
+            let stackId = (notification.userInfo?["stack"] as? String) ?? "home"
+
+            #if DEBUG
+            print("📡 routeDidChange from JS → native")
+            print("   stackId=\(stackId) navigationType=\(navigationType) path=\(path)")
+            debugLogStacks("before observeRouteChanges")
+            #endif
+
+            // ============================================
+            // 1️⃣ Stack switch: home ↔ capture ↔ goto ...
+            // ============================================
+            if stackId != self.activeStackId {
+                // Save current native stack paths
+                self.setPaths(self.pathStack, for: self.activeStackId)
+
+                // Load (or create) new stack's paths
+                var newPaths = self.paths(for: stackId)
+
+                // Ensure the top of the stack matches the path sent by JS
+                if let last = newPaths.last, last != path {
+                    if newPaths.isEmpty {
+                        newPaths = [path]
+                    } else {
+                        newPaths[newPaths.count - 1] = path
+                    }
+                }
+
+                self.activeStackId = stackId
+                self.pathStack = newPaths
+                self.setPaths(newPaths, for: stackId)
 
+                // Rebuild the UINavigationController's stack from these paths
+                var vcs: [UIViewController] = []
+                for (idx, p) in newPaths.enumerated() {
+                    let vc = NativePageViewController(path: p, push: idx > 0)
+                    vcs.append(vc)
+                }
+
+                nav.setViewControllers(vcs, animated: false)
+
+                if let lastVC = vcs.last as? NativePageViewController {
+                    SharedWebViewController.instance.attach(to: lastVC)
+                    SharedWebViewController.instance.clearPlaceholder()
+                }
+
+                #if DEBUG
+                print("🔀 STACK SWITCH to \(stackId)")
+                debugLogStacks("after stack switch")
+                #endif
+
+                // For stacks like "capture", default paths are ["__/stack__/capture"],
+                // so they get a single VC and no back button.
+                return
+            }
+
+            // ============================================
+            // 2️⃣ Navigation *within* the active stack
+            // ============================================
             switch navigationType {
             case "reset":
                 self.emptyNavStack(path: path)
@@ -332,9 +473,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             case "pop":
                 if self.ignoreRoutePopCount > 0 {
                     self.ignoreRoutePopCount -= 1
+                    #if DEBUG
+                    print("🙈 ignoring JS pop (ignoreRoutePopCount→\(self.ignoreRoutePopCount))")
+                    debugLogStacks("after ignore JS pop")
+                    #endif
                     return
                 }
-
                 if self.pathStack.count > 1 {
                     self.popIfNeeded(animated: true)
                 }
@@ -342,6 +486,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
             default:
                 self.pushIfNeeded(path: path, animated: true)
             }
+
+            #if DEBUG
+            debugLogStacks("after observeRouteChanges switch")
+            #endif
         }
     }
 }
@@ -372,6 +520,10 @@ extension NSUserActivity {
     }
 }
 
+// ---------------------------------------------------------
+// MARK: Convenience
+// ---------------------------------------------------------
+
 extension AppDelegate {
     func donateQuickAddShortcut() {
         let a = NSUserActivity.quickAdd

+ 23 - 19
ios/App/App/UILocalPlugin.swift

@@ -644,26 +644,30 @@ private func scoreTranscript(_ text: String, locale: Locale) -> Int {
     }
 
     @objc func routeDidChange(_ call: CAPPluginCall) {
-        let route = call.getObject("route") as? [String: Any]
-        let path = call.getString("path")
-        let push = call.getBool("push") ?? true
-        let navigationType = call.getString("navigationType") ?? (push ? "push" : "replace")
-
-        var entry: [String: Any] = [:]
-        if let path = path {
-            entry["path"] = path
-        }
-        if let route = route {
-            entry["route"] = route
-        }
-        entry["push"] = push
-        entry["navigationType"] = navigationType
+        let navigationType = call.getString("navigationType") ?? "push"
+        let push = call.getBool("push") ?? (navigationType == "push")
+        let path = call.getString("path") ?? "/"
 
-        NotificationCenter.default.post(
-            name: UILocalPlugin.routeChangeNotification,
-            object: nil,
-            userInfo: entry
-        )
+        // ✅ read stack from JS, default to "home" only if missing
+        let stack = call.getString("stack") ?? "home"
+
+        #if DEBUG
+        print("📬 UILocal.routeDidChange call from JS")
+        print("   navigationType=\(navigationType) push=\(push) stack=\(stack) path=\(path)")
+        #endif
+
+        DispatchQueue.main.async {
+            NotificationCenter.default.post(
+              name: UILocalPlugin.routeChangeNotification,
+              object: nil,
+              userInfo: [
+                "navigationType": navigationType,
+                "push": push,
+                "stack": stack,     // 👈 forward it
+                "path": path
+              ]
+            )
+        }
 
         call.resolve()
     }

+ 3 - 2
src/main/mobile/bottom_tabs.cljs

@@ -98,8 +98,9 @@
        (js/console.log "Native search query" q)
        (reset! mobile-state/*search-input q)
        (reset! mobile-state/*search-last-input-at (common-util/time-ms))
-       (when (= :page (state/get-current-route))
-         (mobile-nav/reset-route!))))
+       (comment
+         (when (= :page (state/get-current-route))
+           (mobile-nav/reset-route!)))))
     (add-keyboard-hack-listener!)))
 
 (defn configure

+ 1 - 0
src/main/mobile/core.cljs

@@ -90,6 +90,7 @@
   ;; so it is available even in :advanced release builds
   (prn "[Mobile] init!")
   (log/add-handler mobile-state/log-append!)
+  (mobile-nav/install-native-bridge!)
   (set-router!)
   (init/init!)
   (fhandler/start! render!))

+ 132 - 25
src/main/mobile/navigation.cljs

@@ -7,6 +7,7 @@
             [promesa.core :as p]
             [reitit.frontend.easy :as rfe]))
 
+;; Each tab owns a navigation stack
 (defonce navigation-source (atom nil))
 (defonce ^:private initialised-stacks (atom {}))
 (def ^:private primary-stack "home")
@@ -15,8 +16,30 @@
 (defonce ^:private pending-navigation (atom nil))
 (defonce ^:private hooks-installed? (atom false))
 
+;; --- DEBUG toggle ---
+(def ^:private debug-nav? true)
+
+(defn- dbg [tag & args]
+  (when debug-nav?
+    (let [payload (cond
+                    ;; one map argument → use it directly
+                    (and (= 1 (count args))
+                         (map? (first args)))
+                    (first args)
+
+                    ;; even number of args → treat as k/v pairs
+                    (even? (count args))
+                    (apply hash-map args)
+
+                    ;; odd / weird → just log the raw args
+                    :else
+                    {:args args})]
+      (log/info tag payload))))
+
 ;; Track whether the latest change came from a native back gesture / popstate.
-(.addEventListener js/window "popstate" (fn [_] (reset! navigation-source :pop)))
+(.addEventListener js/window "popstate" (fn [_]
+                                          (reset! navigation-source :pop)
+                                          (dbg :nav/popstate {:source :popstate})))
 
 (defn current-stack
   []
@@ -25,6 +48,7 @@
 (defn set-current-stack!
   [stack]
   (when (some? stack)
+    (dbg :nav/set-current-stack {:from @active-stack :to stack})
     (reset! active-stack stack)))
 
 (defn- strip-fragment [href]
@@ -38,10 +62,6 @@
   (let [p (strip-fragment (.-hash js/location))]
     (if (string/blank? p) "/" p)))
 
-(defn- virtual-path?
-  [path]
-  (and (string? path) (string/starts-with? path "/__stack__/")))
-
 (defn- stack-defaults
   [stack]
   (let [name (keyword stack)
@@ -57,6 +77,7 @@
 (defn- record-navigation-intent!
   [{:keys [type stack]}]
   (let [stack (or stack @active-stack primary-stack)]
+    (dbg :nav/record-intent {:type type :stack stack})
     (reset! pending-navigation {:type type
                                 :stack stack})))
 
@@ -70,6 +91,7 @@
   ([k params query]
    (record-navigation-intent! {:type :push
                                :stack @active-stack})
+   (dbg :nav/push-state {:name k :params params :query query :stack @active-stack})
    (orig-push-state k params query)))
 
 (defonce orig-replace-state rfe/replace-state)
@@ -81,6 +103,7 @@
   ([k params query]
    (record-navigation-intent! {:type :replace
                                :stack @active-stack})
+   (dbg :nav/replace-state {:name k :params params :query query :stack @active-stack})
    (orig-replace-state k params query)))
 
 (defn install-navigation-hooks!
@@ -88,6 +111,7 @@
    Also tags navigation with the active stack so native can keep per-stack history."
   []
   (when (compare-and-set! hooks-installed? false true)
+    (dbg :nav/hooks-installed {})
     (set! rfe/push-state push-state)
     (set! rfe/replace-state replace-state)))
 
@@ -108,6 +132,14 @@
   [stack]
   (-> @stack-history (get stack) :history last))
 
+;; --- DEBUG: watch stack-history changes ---
+(add-watch stack-history ::stack-history-debug
+           (fn [_ _ old new]
+             (when debug-nav?
+               (dbg :nav/stack-history
+                    :old (into {} (for [[k v] old] [k (mapv :path (:history v))]))
+                    :new (into {} (for [[k v] new] [k (mapv :path (:history v))]))))))
+
 (defn- remember-route!
   [stack nav-type route path route-match]
   (when stack
@@ -128,6 +160,12 @@
                          (conj history entry))
                 history)))]
       (when entry
+        (dbg :nav/remember-route
+             :stack stack
+             :nav-type nav-type
+             :path path
+             :route-to (or (get-in route [:to])
+                           (get-in route-match [:data :name])))
         (swap! stack-history update stack (fn [{:keys [history] :as st}]
                                             {:history (update-history history)}))
         (swap! initialised-stacks assoc stack true)))))
@@ -135,6 +173,7 @@
 (defn reset-stack-history!
   [stack]
   (when stack
+    (dbg :nav/reset-stack-history {:stack stack})
     (swap! stack-history assoc stack {:history [(stack-defaults stack)]})
     (swap! initialised-stacks dissoc stack)))
 
@@ -154,6 +193,12 @@
                        (true? push) "push"
                        :else "push"))]
     (reset! navigation-source nil)
+    (dbg :nav/next-navigation
+         :src src
+         :intent intent
+         :stack stack
+         :first? first?
+         :nav-type nav-type)
     (when first?
       (swap! initialised-stacks assoc stack true))
     {:navigation-type nav-type
@@ -162,6 +207,7 @@
 
 (defn- notify-route-payload!
   [payload]
+  (dbg :nav/notify-native payload)
   (-> (.routeDidChange mobile-util/ui-local (clj->js payload))
       (p/catch (fn [err]
                  (log/warn :mobile-native-navigation/route-report-failed
@@ -169,17 +215,19 @@
                             :payload payload})))))
 
 (defn notify-route-change!
-  "Inform native iOS layer of a route change to keep native stack in sync.
-   {route {to keyword, path-params map, query-params map}
-    route-match map   ;; optional full route match for fast restoration
-    path  string      ;; optional, e.g. \"/page/Today\"
-    push  boolean?    ;; optional, explicit push vs replace hint}"
+  "Inform native iOS layer of a route change to keep native stack in sync."
   [{:keys [route route-match path push stack]}]
   (let [{:keys [navigation-type push? stack]} (next-navigation! {:push push
                                                                  :nav-type (:navigation-type route-match)
                                                                  :stack (or stack (current-stack))})
         stack (or stack (current-stack))
         path (or path (current-path))]
+    (dbg :nav/notify-route-change
+         :stack stack
+         :navigation-type navigation-type
+         :path path
+         :route-to (or (:to route)
+                       (get-in route-match [:data :name])))
     (set-current-stack! stack)
     (remember-route! stack navigation-type route path route-match)
     (when (and (mobile-util/native-ios?)
@@ -191,27 +239,86 @@
                       path (assoc :path (strip-fragment path)))]
         (notify-route-payload! payload)))))
 
-(defn reset-route!
-  []
-  (route-handler/redirect-to-home! false)
-  (let [stack (current-stack)]
-    (reset-stack-history! stack)
-    (notify-route-payload!
-     {:navigationType "reset"
-      :push false
-      :stack stack})))
+(comment
+  (defn reset-route!
+    []
+    (route-handler/redirect-to-home! false)
+    (let [stack (current-stack)]
+      (reset-stack-history! stack)
+      (notify-route-payload!
+       {:navigationType "reset"
+        :push false
+        :stack stack}))))
 
 (defn switch-stack!
-  "Activate a stack and restore its last known route if different from current location."
+  "Activate a stack and restore its last known route."
   [stack]
   (when stack
     (let [stack (ensure-stack stack)]
+      (dbg :nav/switch-stack {:to stack
+                              :current @active-stack
+                              :stack-top (select-keys (stack-top stack) [:path])})
       (set-current-stack! stack)
       (when-let [{:keys [path route route-match]} (stack-top stack)]
         (let [route-match (or route-match (:route-match (stack-defaults stack)))
-              path (or path (current-path))]
-          ;; Update local route state immediately for UI (header, page context) without full router churn.
+              path        (or path (current-path))]
+          (dbg :nav/switch-stack-apply
+               :stack stack
+               :path path
+               :route-name (or (get-in route [:data :name])
+                               (get-in route-match [:data :name])))
           (route-handler/set-route-match! route-match)
-          ;; Avoid triggering native navigation on stack switches; we rely on per-stack
-          ;; history and UI updates handled in JS for snappy tab changes.
-          )))))
+          (notify-route-change!
+           {:route {:to          (or (get-in route [:data :name])
+                                     (get-in route-match [:data :name]))
+                    :path-params (or (:path-params route)
+                                     (get-in route-match [:parameters :path]))
+                    :query-params (or (:query-params route)
+                                      (get-in route-match [:parameters :query]))}
+            :path  path
+            :stack stack
+            :push  false}))))))
+
+(defn pop-stack!
+  "Pop one route from the current stack, update router via replace-state.
+   Called when native UINavigationController pops (back gesture / back button)."
+  []
+  (let [stack (current-stack)
+        {:keys [history]} (get @stack-history stack)
+        history (vec history)]
+    (if (<= (count history) 1)
+      (dbg :nav/pop-stack-root {:stack stack
+                                :history (mapv :path history)})
+      (let [new-history (subvec history 0 (dec (count history)))
+            {:keys [route route-match path]} (peek new-history)
+            route-match   (or route-match (:route-match (stack-defaults stack)))
+            route-name    (get-in route-match [:data :name])
+            path-params   (get-in route-match [:parameters :path])
+            query-params  (get-in route-match [:parameters :query])]
+
+        (dbg :nav/pop-stack
+             :stack stack
+             :old-history (mapv :path history)
+             :new-history (mapv :path new-history)
+             :target-path path
+             :route-name route-name)
+
+        (swap! stack-history assoc stack {:history new-history})
+
+        ;; Pretend this came from a pop for next-navigation!
+        (reset! navigation-source :pop)
+
+        ;; Use *original* replace-state to avoid recording a :replace intent.
+        (orig-replace-state route-name path-params query-params)
+
+        (route-handler/set-route-match! route-match)))))
+
+(defn ^:export install-native-bridge!
+  []
+  (dbg :nav/install-native-bridge {})
+  (set! (.-LogseqNative js/window)
+        (clj->js
+         {:onNativePop (fn []
+                         (dbg :nav/on-native-pop {:stack (current-stack)
+                                                  :path (current-path)})
+                         (pop-stack!))})))