diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index cdab90892f..9deec3753a 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -28,6 +28,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel /// Used to ignore JS-driven pops when we're popping in response to a native gesture. private var ignoreRoutePopCount: Int = 0 + /// Suppresses JS callbacks for native pops that already originated from JS history. + private var suppressNextNativePopCallback: Bool = false + /// Temporary snapshot image for smooth pop transitions. private var popSnapshotView: UIView? @@ -245,8 +248,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel guard let nav = navController else { return } if nav.viewControllers.count > 1 { - _ = pathStack.popLast() - setPaths(pathStack, for: activeStackId) + suppressNextNativePopCallback = true nav.popViewController(animated: animated) } } @@ -310,6 +312,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel guard !ctx.isCancelled else { self.pathStack = previousStack self.setPaths(previousStack, for: self.activeStackId) + self.suppressNextNativePopCallback = false if let fromVC { SharedWebViewController.instance.attach(to: fromVC) @@ -320,11 +323,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel // 🔑 DO NOT call webView.goBack(). // Tell JS explicitly that native popped. - self.ignoreRoutePopCount += 1 + let shouldNotifyNativePop = !self.suppressNextNativePopCallback + self.suppressNextNativePopCallback = false - if let bridge = SharedWebViewController.instance.bridgeController.bridge { - let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();" - bridge.webView?.evaluateJavaScript(js, completionHandler: nil) + if shouldNotifyNativePop { + self.ignoreRoutePopCount += 1 + + 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( @@ -415,12 +423,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel } // ⚡️ Fast-path: cancel search → home root. - // We do NOT rebuild nav stack and we do NOT reattach the WebView. - // JS will just navigate the existing shared WKWebView to "/". if previousStackId == "search", stackId == "home"{ - // Just update bookkeeping so future home pushes/pop work correctly. self.setPaths(["/__stack__/search"], for: "search") self.activeStackId = "home" self.setPaths(["/"], for: "home") diff --git a/ios/App/App/LiquidTabsPlugin.swift b/ios/App/App/LiquidTabsPlugin.swift index 576cc8ecc6..696ca2417c 100644 --- a/ios/App/App/LiquidTabsPlugin.swift +++ b/ios/App/App/LiquidTabsPlugin.swift @@ -60,9 +60,13 @@ public class LiquidTabsPlugin: CAPPlugin, CAPBridgedPlugin { } DispatchQueue.main.async { + let selectedId = self.store.selectedId self.store.tabs = tabs - if let firstId = tabs.first?.id { - self.store.selectedId = firstId + if let selectedId = selectedId, + selectedId == "search" || tabs.contains(where: { $0.id == selectedId }) { + self.store.selectedId = selectedId + } else { + self.store.selectedId = tabs.first?.id } } diff --git a/ios/App/App/LiquidTabsRootView.swift b/ios/App/App/LiquidTabsRootView.swift index 1a038d4795..cc7f08f28a 100644 --- a/ios/App/App/LiquidTabsRootView.swift +++ b/ios/App/App/LiquidTabsRootView.swift @@ -117,6 +117,7 @@ private struct LiquidTabs26View: View { @State private var hackShowKeyboard: Bool = false @State private var selectedTab: LiquidTabsTabSelection = .content(0) + @State private var searchPath = NavigationPath() private let maxMainTabs = 6 @@ -143,6 +144,9 @@ private struct LiquidTabs26View: View { private func handleRetap(on selection: LiquidTabsTabSelection) { print("User re-tapped tab: \(selection)") + if case .search = selection { + searchPath = NavigationPath() + } navController.popToRootViewController(animated: true) if let id = store.tabId(for: selection) { @@ -220,6 +224,7 @@ private struct LiquidTabs26View: View { navController: navController, selectedTab: $selectedTab, firstTabId: store.tabs.first?.id, + searchPath: $searchPath, store: store ) .ignoresSafeArea() @@ -281,6 +286,7 @@ private struct LiquidTabs26View: View { focusSearchField() case .content: + searchPath = NavigationPath() isSearchFocused = false hackShowKeyboard = false } @@ -314,13 +320,14 @@ private struct SearchTabHost26: View { let navController: UINavigationController var selectedTab: Binding let firstTabId: String? + @Binding var searchPath: NavigationPath @ObservedObject var store: LiquidTabsStore @Environment(\.isSearching) private var isSearching @State private var wasSearching: Bool = false var body: some View { - NavigationStack { + NavigationStack(path: $searchPath) { ZStack { Color.logseqBackground .ignoresSafeArea() @@ -339,6 +346,7 @@ private struct SearchTabHost26: View { let firstId = firstTabId { wasSearching = false + searchPath = NavigationPath() selectedTab.wrappedValue = .content(0) store.selectedId = firstId } @@ -355,6 +363,7 @@ private struct LiquidTabs16View: View { let navController: UINavigationController @State private var hackShowKeyboard: Bool = false + @State private var searchPath = NavigationPath() private var searchTextBinding: Binding { Binding( @@ -385,9 +394,15 @@ private struct LiquidTabs16View: View { // Re-tap: pop to root if id == store.selectedId { + if id == "search" { + searchPath = NavigationPath() + } navController.popToRootViewController(animated: true) LiquidTabsPlugin.shared?.notifyTabSelected(id: id, reselected: true) } else { + if store.selectedId == "search" { + searchPath = NavigationPath() + } store.selectedId = id LiquidTabsPlugin.shared?.notifyTabSelected(id: id) } @@ -408,6 +423,7 @@ private struct LiquidTabs16View: View { SearchTab16Host( navController: navController, searchText: searchTextBinding, + searchPath: $searchPath, store: store ) .ignoresSafeArea() @@ -416,6 +432,11 @@ private struct LiquidTabs16View: View { } .tag("search" as String?) } + .onChange(of: store.selectedId) { newId in + if newId != "search" { + searchPath = NavigationPath() + } + } // Hidden UITextField that pre-invokes keyboard KeyboardHackField(shouldShow: $hackShowKeyboard) @@ -453,10 +474,11 @@ private struct LiquidTabs16View: View { private struct SearchTab16Host: View { let navController: UINavigationController @Binding var searchText: String + @Binding var searchPath: NavigationPath @ObservedObject var store: LiquidTabsStore var body: some View { - NavigationStack { + NavigationStack(path: $searchPath) { ZStack { Color.logseqBackground .ignoresSafeArea() diff --git a/src/main/mobile/bottom_tabs.cljs b/src/main/mobile/bottom_tabs.cljs index 2adf5c2824..9ae56ac131 100644 --- a/src/main/mobile/bottom_tabs.cljs +++ b/src/main/mobile/bottom_tabs.cljs @@ -134,8 +134,6 @@ (add-search-listener! (fn [q] - ;; wire up search handler - (js/console.log "Native search query" q) (reset! mobile-state/*search-input q) (p/let [result (mobile-search/search q)] (update-native-search-results! result)))) diff --git a/src/main/mobile/navigation.cljs b/src/main/mobile/navigation.cljs index d89fb6d849..39346e14b4 100644 --- a/src/main/mobile/navigation.cljs +++ b/src/main/mobile/navigation.cljs @@ -20,8 +20,9 @@ (defonce ^:private hooks-installed? (atom false)) ;; Track whether the latest change came from a native back gesture / popstate. -(.addEventListener js/window "popstate" (fn [_] - (reset! navigation-source :pop))) +(when (fn? (.-addEventListener js/window)) + (.addEventListener js/window "popstate" (fn [_] + (reset! navigation-source :pop)))) (defn current-stack [] @@ -220,8 +221,8 @@ (let [route-match (or route-match (:route-match (stack-defaults stack))) path (or path (current-path))] (route-handler/set-route-match! route-match) - (when (= current "search") - ;; reset to :home + (when (and (= current "search") + (= stack primary-stack)) (orig-replace-state :home nil nil)) (notify-route-change! {:route {:to (or (get-in route [:data :name]) @@ -308,7 +309,7 @@ (route-handler/set-route-match! route-match) (notify-route-change! {:route route - :route-match route-match + :route-match (assoc route-match :navigation-type "reset") :path path :stack stack :push false}))))) diff --git a/src/test/mobile/navigation_test.cljs b/src/test/mobile/navigation_test.cljs new file mode 100644 index 0000000000..335ceabd37 --- /dev/null +++ b/src/test/mobile/navigation_test.cljs @@ -0,0 +1,74 @@ +(ns mobile.navigation-test + (:require [cljs.test :refer [deftest is testing use-fixtures]] + [frontend.handler.route :as route-handler] + [frontend.mobile.util :as mobile-util] + [mobile.navigation :as mobile-nav])) + +(defn- route-match + [name] + {:data {:name name} + :parameters {:path {} :query {}}}) + +(defn- stack-entry + [name path] + {:path path + :route {:to name :path-params {} :query-params {}} + :route-match (route-match name)}) + +(defn- reset-navigation-state! [] + (reset! @#'mobile-nav/navigation-source nil) + (reset! @#'mobile-nav/initialised-stacks {}) + (reset! @#'mobile-nav/active-stack "home") + (reset! @#'mobile-nav/stack-history {}) + (reset! @#'mobile-nav/pending-navigation nil)) + +(use-fixtures :each + {:before reset-navigation-state! + :after reset-navigation-state!}) + +(deftest switch-stack-from-search-to-non-home-does-not-replace-browser-route-with-home + (testing "closing native search while selecting another tab should keep the selected stack route" + (let [replace-calls (atom []) + route-matches (atom [])] + (reset! @#'mobile-nav/active-stack "search") + (reset! @#'mobile-nav/stack-history + {"search" {:history [(stack-entry :search "/__stack__/search")]} + "graphs" {:history [(stack-entry :graphs "/__stack__/graphs")]}}) + (with-redefs [mobile-nav/orig-replace-state (fn [& args] (swap! replace-calls conj args)) + route-handler/set-route-match! (fn [match] (swap! route-matches conj match)) + mobile-util/native-platform? (constantly false)] + (mobile-nav/switch-stack! "graphs") + (is (empty? @replace-calls)) + (is (= :graphs (get-in (last @route-matches) [:data :name]))))))) + +(deftest switch-stack-from-search-to-home-closes-search-with-home-route + (testing "closing native search to Home still updates the browser route to Home" + (let [replace-calls (atom [])] + (reset! @#'mobile-nav/active-stack "search") + (reset! @#'mobile-nav/stack-history + {"search" {:history [(stack-entry :search "/__stack__/search")]} + "home" {:history [(stack-entry :home "/")]}}) + (with-redefs [mobile-nav/orig-replace-state (fn [& args] (swap! replace-calls conj args)) + route-handler/set-route-match! (constantly nil) + mobile-util/native-platform? (constantly false)] + (mobile-nav/switch-stack! "home") + (is (= [[:home nil nil]] @replace-calls)))))) + +(deftest pop-to-root-notifies-native-reset + (testing "collapsing a logical stack tells native to rebuild it as a single root view controller" + (let [payloads (atom [])] + (reset! @#'mobile-nav/active-stack "home") + (reset! @#'mobile-nav/stack-history + {"home" {:history [(stack-entry :home "/") + (stack-entry :page "/page/alpha")]}}) + (with-redefs [mobile-nav/orig-replace-state (constantly nil) + route-handler/set-route-match! (constantly nil) + mobile-util/native-platform? (constantly true) + mobile-util/ui-local #js {:routeDidChange + (fn [payload] + (swap! payloads conj + (js->clj payload :keywordize-keys true)) + (js/Promise.resolve nil))}] + (mobile-nav/pop-to-root! "home") + (is (= "reset" (:navigationType (last @payloads)))) + (is (= "/" (:path (last @payloads))))))))