From 982b84e6e1b7c276a0aecdc175bf7354fdabc45e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 25 Oct 2026 15:51:14 +0800 Subject: [PATCH] Synchronize search dismissal with native search transitions --- .../Logseq/LGNavigationExtension.swift | 128 +++++++++++++++--- apple/Tests/UI/check-search-presentation.sh | 2 + .../Tests/UI/search-dismiss-transition.swift | 76 +++++++---- apple/Tests/UI/search-editor-toolbar.swift | 6 +- apple/Tests/UI/search-presentation.swift | 9 +- apple/Tests/UI/search-result-navigation.swift | 15 +- shared/src/logseq/view_base.mli | 1 + shared/src/logseq/view_screens.ml | 15 +- shared/src/logseq/view_screens.mli | 1 + shared/test/logseq/app_test.ml | 14 ++ shared/test/logseq/mobile_feedback_test.ml | 18 ++- 11 files changed, 222 insertions(+), 63 deletions(-) diff --git a/apple/Sources/Logseq/LGNavigationExtension.swift b/apple/Sources/Logseq/LGNavigationExtension.swift index d1ff3852..92534b5e 100644 --- a/apple/Sources/Logseq/LGNavigationExtension.swift +++ b/apple/Sources/Logseq/LGNavigationExtension.swift @@ -304,20 +304,25 @@ private struct LGSearchPresentation: View { @State private var nativePresented = false var body: some View { - baseContent - .fullScreenCover(isPresented: $nativePresented, onDismiss: didDismiss) { + ZStack { + baseContent + .ignoresSafeArea(.keyboard, edges: nativePresented ? .bottom : []) + .allowsHitTesting(!nativePresented) + .accessibilityHidden(nativePresented) + if nativePresented { LGSearchSession(context: context, close: closeSearch) - .presentationBackground(.background) + .zIndex(1) } - .onChange(of: presented, initial: true) { - if presented { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { nativePresented = true } - } else { - closeSearch() - } + } + .onChange(of: presented, initial: true) { + if presented { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { nativePresented = true } + } else { + closeSearch() } + } } private var baseContent: AnyView { @@ -331,9 +336,13 @@ private struct LGSearchPresentation: View { } private func closeSearch() { - var transaction = Transaction(animation: .default) - transaction.disablesAnimations = false + guard nativePresented else { return } + // The system search control already animates cancellation and the keyboard. + // Retire this layer without adding another presentation transition. + var transaction = Transaction() + transaction.disablesAnimations = true withTransaction(transaction) { nativePresented = false } + didDismiss() } private func didDismiss() { @@ -356,6 +365,8 @@ private struct LGSearchSession: View { if pendingQuery == query { pendingQuery = nil } } #if os(iOS) + .opacity(closing ? 0 : 1) + .animation(.easeOut(duration: 0.2), value: closing) .onChange(of: depth) { if depth == 0 && !closing { nativeSearchPresented = true } } @@ -378,10 +389,14 @@ private struct LGSearchSession: View { private func searchRootTransform(_ content: AnyView) -> AnyView { let content = AnyView(content.environment(\.luiTextHighlightQuery, query)) #if os(iOS) + let observedContent = AnyView(content.background(LGSearchDismissalObserver( + willDismiss: { if depth == 0 { exitSearch() } }, + didDismiss: { if depth == 0 && closing { close() } } + ))) if #available(iOS 26.0, *) { - return AnyView(nativeBottomSearch(content)) + return AnyView(nativeBottomSearch(observedContent)) } - return AnyView(nativeLegacySearch(content)) + return AnyView(nativeLegacySearch(observedContent)) #else return AnyView(nativeAutomaticSearch(content)) #endif @@ -417,7 +432,7 @@ private struct LGSearchSession: View { content .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .scrollDismissesKeyboard(.interactively) - .navigationTitle(searchTitle) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .searchable( text: queryBinding, @@ -428,13 +443,13 @@ private struct LGSearchSession: View { .toolbar { DefaultToolbarItem(kind: .search, placement: .bottomBar) } + .toolbar(depth == 0 ? .hidden : .visible, for: .navigationBar) } private func exitSearch() { guard !closing else { return } closing = true pendingQuery = query - close() } private var nativeSearchPresentedBinding: Binding { @@ -851,6 +866,85 @@ private struct LGNavigationContent: View { } #if os(iOS) +@MainActor +private struct LGSearchDismissalObserver: UIViewControllerRepresentable { + let willDismiss: () -> Void + let didDismiss: () -> Void + + func makeUIViewController(context: Context) -> Observer { + Observer(willDismiss: willDismiss, didDismiss: didDismiss) + } + + func updateUIViewController(_ controller: Observer, context: Context) { + controller.proxy.willDismiss = willDismiss + controller.proxy.didDismiss = didDismiss + controller.resolve() + } + + static func dismantleUIViewController(_ controller: Observer, coordinator: ()) { + controller.uninstall() + } + + final class Observer: UIViewController { + let proxy: Delegate + weak var search: UISearchController? + + init(willDismiss: @escaping () -> Void, didDismiss: @escaping () -> Void) { + proxy = Delegate(willDismiss: willDismiss, didDismiss: didDismiss) + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") } + override func loadView() { view = UIView() } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); resolve() } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated); resolve() } + + func resolve() { + guard let search = navigationController?.viewControllers.compactMap({ $0.navigationItem.searchController }).first, + search.delegate !== proxy else { return } + uninstall() + self.search = search + proxy.forwarded = search.delegate + search.delegate = proxy + } + + func uninstall() { + if let search, search.delegate === proxy { search.delegate = proxy.forwarded } + search = nil + } + } + + final class Delegate: NSObject, UISearchControllerDelegate { + // UIKit's optional-method lookup is nonisolated; installation stays on the main actor. + nonisolated(unsafe) weak var forwarded: UISearchControllerDelegate? + var willDismiss: () -> Void + var didDismiss: () -> Void + + init(willDismiss: @escaping () -> Void, didDismiss: @escaping () -> Void) { + self.willDismiss = willDismiss + self.didDismiss = didDismiss + } + + func willDismissSearchController(_ searchController: UISearchController) { + willDismiss() + forwarded?.willDismissSearchController?(searchController) + } + + func didDismissSearchController(_ searchController: UISearchController) { + forwarded?.didDismissSearchController?(searchController) + didDismiss() + } + + nonisolated override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || forwarded?.responds(to: selector) == true + } + + nonisolated override func forwardingTarget(for selector: Selector!) -> Any? { + if let forwarded, forwarded.responds(to: selector) { return forwarded } + return super.forwardingTarget(for: selector) + } + } +} + @MainActor private final class LGNavigationTransitions { weak var controller: UINavigationController? diff --git a/apple/Tests/UI/check-search-presentation.sh b/apple/Tests/UI/check-search-presentation.sh index ccc7ac90..57236320 100755 --- a/apple/Tests/UI/check-search-presentation.sh +++ b/apple/Tests/UI/check-search-presentation.sh @@ -11,6 +11,8 @@ mkdir -p "$check_dir/SearchCheck.app" xcrun --sdk iphonesimulator swiftc -parse-as-library \ -target arm64-apple-ios17.0-simulator -I "$build_dir/Modules" \ Sources/Logseq/LGNavigationExtension.swift \ + Sources/Logseq/LGAdaptiveContentInsets.swift \ + Sources/Logseq/LGInteractionDiagnostics.swift \ Sources/Logseq/LGLiquidGlassTweak.swift \ Sources/Logseq/LogseqThemeSettings.swift \ "$build_dir/Logseq.build/DerivedSources/resource_bundle_accessor.swift" \ diff --git a/apple/Tests/UI/search-dismiss-transition.swift b/apple/Tests/UI/search-dismiss-transition.swift index cc505adb..f9834fa8 100644 --- a/apple/Tests/UI/search-dismiss-transition.swift +++ b/apple/Tests/UI/search-dismiss-transition.swift @@ -3,8 +3,27 @@ import SwiftUI @testable import LUIAppleBackend @testable import Logseq +@MainActor private final class SearchKeyboardDismissal: NSObject { + private(set) var completed = false + override init() { + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(didHide), + name: UIResponder.keyboardDidHideNotification, object: nil) + } + @objc private func didHide(_ notification: Notification) { + completed = true + } + deinit { NotificationCenter.default.removeObserver(self) } +} + @main struct SearchDismissTransitionCheck { @MainActor static func main() async throws { + try await check("") + try await check("Needle") + print("PASS: empty and populated search close with the native keyboard, preserve outgoing content, and keep the toolbar at the bottom") + } + + @MainActor static func check(_ initialQuery: String) async throws { var query = "" func snapshot() -> String { let result: [String: Any] = ["graphName": "Work", "selectedGraphId": "work", @@ -49,10 +68,15 @@ import SwiftUI views(window).contains { ($0 as? UISearchTextField)?.isFirstResponder == true } } let field = views(window).compactMap { $0 as? UISearchTextField }.first { $0.isFirstResponder }! - field.insertText("Needle") - try await wait("Search results must be visible before closing") { find("search.result.found") != nil } + if !initialQuery.isEmpty { + field.insertText(initialQuery) + try await wait("Search results must be visible before closing") { find("search.result.found") != nil } + } try await Task.sleep(for: .milliseconds(250)) - guard let presented = window.rootViewController?.presentedViewController else { throw failure("Missing search presentation") } + guard let root = window.rootViewController else { throw failure("Missing app controller") } + if let modal = root.presentedViewController, !(modal is UISearchController) { + throw failure("Search must close in place instead of adding a full-screen modal transition") + } func searchController(_ controller: UIViewController) -> UISearchController? { if let search = controller.navigationItem.searchController, search.isActive { return search } for child in controller.children { @@ -60,41 +84,45 @@ import SwiftUI } return controller.presentedViewController.flatMap(searchController) } - guard let search = searchController(presented), let delegate = search.searchBar.delegate else { + guard let search = searchController(root), let delegate = search.searchBar.delegate else { throw failure("Missing active native search controller") } + let fieldTop = field.convert(field.bounds, to: window).minY + let keyboard = SearchKeyboardDismissal() delegate.searchBarCancelButtonClicked?(search.searchBar) func retainsQuery() -> Bool { let node = backend.rootIDs.flatMap(descendants).first { backend.extensionModel(id: $0)?.identifier == "native-search-presentation" } - guard let node, case .string("Needle")? = backend.extensionModel(id: node)?.properties["query"] else { return false } - return find("search.result.found") != nil + guard let node, case .string(let value)? = backend.extensionModel(id: node)?.properties["query"], + value == initialQuery else { return false } + return initialQuery.isEmpty || find("search.result.found") != nil } - var animated = false - for _ in 0..<100 { - guard retainsQuery() else { - throw failure("Closing search must preserve the outgoing query and results until dismissal starts") - } - if presented.isBeingDismissed || presented.transitionCoordinator != nil { - animated = true - break + try await Task.sleep(for: .milliseconds(30)) + guard field.window == nil || retainsQuery() else { + throw failure("The outgoing results must remain mounted during dismissal") + } + for _ in 0..<6 { + guard field.window == nil || field.convert(field.bounds, to: window).minY >= fieldTop - 2 else { + throw failure("The outgoing search toolbar must not jump to the top during dismissal") } - guard window.rootViewController?.presentedViewController != nil else { - throw failure("Search disappeared without a dismissal animation") + try await Task.sleep(for: .milliseconds(20)) + } + try await wait("The search session must be cleared after dismissal") { + let node = backend.rootIDs.flatMap(descendants).first { + backend.extensionModel(id: $0)?.identifier == "native-search-presentation" } - try await Task.sleep(for: .milliseconds(10)) + guard let node, case .bool(false)? = backend.extensionModel(id: node)?.properties["presented"] else { return false } + return find("search.result.found") == nil } - guard animated else { throw failure("Closing search must start its dismissal animation") } - try await Task.sleep(for: .milliseconds(30)) - guard retainsQuery() else { - throw failure("The outgoing results must remain mounted during dismissal") + guard keyboard.completed else { + throw failure("Search must preserve its native hierarchy until the keyboard finishes dismissing") } - try await wait("Search must finish dismissing") { window.rootViewController?.presentedViewController == nil } - try await wait("The search session must be cleared after dismissal") { find("search.result.found") == nil } + try await wait("Search must release its input") { !field.isFirstResponder } + guard window.rootViewController === root else { throw failure("Search must retain the app controller") } guard runtime.lastError == nil else { throw failure(runtime.lastError!) } - print("PASS: native search dismisses with its query and results intact, then clears the closed session") } + static func failure(_ message: String) -> NSError { NSError(domain: "SearchDismissTransition", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) } diff --git a/apple/Tests/UI/search-editor-toolbar.swift b/apple/Tests/UI/search-editor-toolbar.swift index bc5661f6..cf240f0a 100644 --- a/apple/Tests/UI/search-editor-toolbar.swift +++ b/apple/Tests/UI/search-editor-toolbar.swift @@ -69,9 +69,6 @@ import SwiftUI defer { window.isHidden = true; runtime.stop() } try await Task.sleep(for: .milliseconds(300)) try press("button.search") - try await wait("Search must present its own navigation controller") { - window.rootViewController?.presentedViewController != nil - } func views(_ view: UIView) -> [UIView] { [view] + view.subviews.flatMap(views) } try await wait("Native search must become ready for typing") { views(window).compactMap { $0 as? UISearchTextField }.contains { $0.isFirstResponder } @@ -101,8 +98,7 @@ import SwiftUI guard find("toolbar.outliner.editor", in: chrome) != nil else { throw failure("Missing editor toolbar") } var measuredFrame: CGRect? try await wait("The search editor toolbar must be visible above the keyboard") { - guard let presented = window.rootViewController?.presentedViewController else { return false } - measuredFrame = views(presented.view).compactMap { $0 as? UIScrollView }.compactMap { scroll in + measuredFrame = views(window).compactMap { $0 as? UIScrollView }.compactMap { scroll in let rect = scroll.convert(scroll.bounds, to: window) return scroll.window != nil && rect.height >= 42 && rect.height <= 60 && rect.minY > window.bounds.height / 2 diff --git a/apple/Tests/UI/search-presentation.swift b/apple/Tests/UI/search-presentation.swift index 65bf3d4d..e98dc885 100644 --- a/apple/Tests/UI/search-presentation.swift +++ b/apple/Tests/UI/search-presentation.swift @@ -89,10 +89,7 @@ let logger = Logger(subsystem: "com.logseq.logseq.tests", category: "Search") if delayedQueries != nil { delayedQueries?.append(query) } else { applyQuery(query) } } - let host = SearchCheckHost(rootView: NavigationStack { - LUISwiftUIRoot(backend: backend, rootID: 1) - .toolbar { ToolbarItem(placement: .topBarLeading) { Text("Journals") } } - }) + let host = SearchCheckHost(rootView: LUISwiftUIRoot(backend: backend, rootID: 1)) let window = UIWindow(frame: CGRect(x:0,y:0,width:402,height:874)) self.window = window window.rootViewController = host; window.makeKeyAndVisible() @@ -147,8 +144,8 @@ let logger = Logger(subsystem: "com.logseq.logseq.tests", category: "Search") record("FAIL: search should accept typing while its presentation animates") failures += 1 } - if window.rootViewController?.presentedViewController == nil { - record("FAIL: search navigation must be isolated from the app navigation") + if let modal = window.rootViewController?.presentedViewController, !(modal is UISearchController) { + record("FAIL: search must not add a full-screen modal over the app navigation") failures += 1 } input()?.insertText("Fixture") diff --git a/apple/Tests/UI/search-result-navigation.swift b/apple/Tests/UI/search-result-navigation.swift index de6ecb63..8179cd4f 100644 --- a/apple/Tests/UI/search-result-navigation.swift +++ b/apple/Tests/UI/search-result-navigation.swift @@ -103,11 +103,16 @@ import UIKit query == "Search" && find("search.result.result-0") != nil && find("search.result.result-40") != nil } try await Task.sleep(for: .milliseconds(200)) - let presented = window.rootViewController!.presentedViewController! + let root = window.rootViewController! + func searchNavigation() -> UINavigationController? { + controllers(root).compactMap { $0 as? UINavigationController }.first { + $0.viewControllers.contains { $0.navigationItem.searchController != nil } + } + } try await wait("Search must own a navigation controller") { - controllers(presented).contains { $0 is UINavigationController } + searchNavigation() != nil } - let navigation = controllers(presented).compactMap { $0 as? UINavigationController }.first! + let navigation = searchNavigation()! func screenshot(_ name: String) throws { let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) @@ -126,7 +131,7 @@ import UIKit try await wait("Result content must mount at its page or revealed block") { find("outliner.block.\(target)") != nil } - guard window.rootViewController?.presentedViewController === presented, + guard window.rootViewController === root, searchNavigation() === navigation, find("outliner.block.\(target)") != nil else { throw failure("Opening a result must retain the search session and its loaded content") } @@ -135,7 +140,7 @@ import UIKit guard let item = navigation.topViewController?.navigationItem else { return false } let hasNativeItems = !(item.rightBarButtonItems ?? []).isEmpty || item.trailingItemGroups.contains { !$0.barButtonItems.isEmpty } - return hasNativeItems && find("button.connection") != nil + return !navigation.isNavigationBarHidden && hasNativeItems && find("button.connection") != nil } window.layoutIfNeeded() if cycle == 11 { try screenshot("search-result-destination.png") } diff --git a/shared/src/logseq/view_base.mli b/shared/src/logseq/view_base.mli index 841e8915..d56f825a 100644 --- a/shared/src/logseq/view_base.mli +++ b/shared/src/logseq/view_base.mli @@ -356,6 +356,7 @@ val sync_error_message : Model.logseq_model -> string val selected_page_content_title_visible_ : Model.logseq_model -> bool val selected_page_title : Model.logseq_model -> string val navigation_root_model : Model.logseq_model -> Model.logseq_model +val without_outliner_interaction : Model.logseq_model -> Model.logseq_model val journal_navigation_model : Model.logseq_model -> Model.logseq_model val node_route_model : active:bool -> Model.logseq_model -> Model.node_projection -> Model.logseq_model diff --git a/shared/src/logseq/view_screens.ml b/shared/src/logseq/view_screens.ml index 88203e14..781ebe1d 100644 --- a/shared/src/logseq/view_screens.ml +++ b/shared/src/logseq/view_screens.ml @@ -718,7 +718,7 @@ let native_node_stack context model_source ~source:routes_source send = ~source:(Signal.map2 (fun routes (index, route) -> (route, index = List.length routes - 1)) routes_source visit_source) send) -let native_search_view (_context : Lui_ui.ui_context) model_source send : +let native_search_view ?base (_context : Lui_ui.ui_context) model_source send : t = fun context parent -> let node = Lui_ui.extension context "native-search-presentation" in @@ -780,7 +780,10 @@ let native_search_view (_context : Lui_ui.ui_context) model_source send : node else ( ignore - ((logseq_main_view context (Signal.map View_base.navigation_root_model model_source) send) context (Some node)); + ((match base with + | Some view -> box ~grow:1.0 [view] + | None -> logseq_main_view context (Signal.map View_base.navigation_root_model model_source) send) + context (Some node)); ignore ((column ~grow:1.0 [ search_screen model_source send ]) context (Some node)); @@ -885,7 +888,7 @@ let native_navigation_view (_context : Lui_ui.ui_context) model_source send ignore ((column [ - native_search_view context model_source send; + logseq_main_view context (Signal.map View_base.navigation_root_model model_source) send; if_ ~test:(Signal.map View_base.bottom_chrome_selection_ model_source) @@ -1015,7 +1018,11 @@ let application_main_content (context : Lui_ui.ui_context) model_source [ if_ ~test:(Signal.map View_base.main_screen_visible_ model_source) - (native_navigation_view context model_source send); + (native_search_view ~base:(native_navigation_view context + (Signal.map (fun (current : Model.logseq_model) -> + let base = if current.search_open then View_base.without_outliner_interaction current else current in + { base with search_open = false; search_navigation_path = [] }) model_source) send) + context model_source send); if_ ~test: (Signal.map View_base.graph_picker_screen_visible_ diff --git a/shared/src/logseq/view_screens.mli b/shared/src/logseq/view_screens.mli index 2d6d4e71..bd18147d 100644 --- a/shared/src/logseq/view_screens.mli +++ b/shared/src/logseq/view_screens.mli @@ -54,6 +54,7 @@ val native_node_screen : source:(Model.node_projection * bool) Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t val native_search_view : + ?base:Lui_elements.t -> Lui_ui.ui_context -> Model.logseq_model Signal.signal -> (Model.logseq_action -> bool) -> Lui_elements.t diff --git a/shared/test/logseq/app_test.ml b/shared/test/logseq/app_test.ml index 63740f7a..5354b2d5 100644 --- a/shared/test/logseq/app_test.ml +++ b/shared/test/logseq/app_test.ml @@ -277,6 +277,15 @@ let main_root runtime application = (Lui_app.runtime application).Lui_runtime.runtime_extension_nodes in if picker_parent <> -1 then picker_parent + else if Hashtbl.find_opt extensions container = Some "native-search-presentation" + then + if extension_property application container "presented" = Some (Lui_protocol.BoolValue true) + then List.nth (children runtime container) 1 + else + let base = List.hd (children runtime container) in + let navigation = List.hd (children runtime base) in + let wrapper = List.hd (children runtime navigation) in + List.hd (children runtime wrapper) else if Hashtbl.find_opt extensions container = Some "native-navigation-stack" then begin let wrapper = List.nth (children runtime container) 0 in @@ -4640,6 +4649,11 @@ let search_lifecycle_keeps_query_owned_by_the_lg_model () = let search_extension = extension_node application "native-search-presentation" in + check_eq ~msg:"search wraps app navigation so both native stacks are independent" + (List.hd (children renderer (List.hd (children renderer search_extension)))) + (extension_node application "native-navigation-stack"); + check ~msg:"the underlying page keeps its capture and search chrome ready during dismissal" + (descendant_with_identifier renderer (native_bottom_chrome renderer application) "button.search" <> -1); let search_panel = child_with_identifier renderer search_root "screen.search" in diff --git a/shared/test/logseq/mobile_feedback_test.ml b/shared/test/logseq/mobile_feedback_test.ml index eea18da6..4fd8626a 100644 --- a/shared/test/logseq/mobile_feedback_test.ml +++ b/shared/test/logseq/mobile_feedback_test.ml @@ -180,17 +180,31 @@ let date_editors_offer_navigation_to_the_journal () = R.with_runtime (fun sessio (App.model app).pending_effects))) let search_property_navigation_stays_in_search () = R.with_runtime (fun session _ _ -> - with_ui session Q.alpha (fun app find click -> + with_ui session Q.alpha (fun app _ _ -> App_test.send app Model.OpenSearch; App_test.send app (Model.RequestSearchNode Q.alpha); + open_node session Q.alpha; + let snapshot = match Response_snapshot.decode_response (Rpc_session.snapshot_visible session) with + | Ok value -> value | Error message -> fail message in + App_test.send app (Model.ApplyCoreSnapshot snapshot); App_test.send app (Model.ResolveSearchNode (Q.alpha, true)); App_test.flush app; + let host = Lui_app.runtime app in + let search = App_test.extension_node app "native-search-presentation" in + (* Search overlays the retained app page, which can contain the same property IDs. *) + let find id = List.find_map (fun root -> + let node = App_test.descendant_with_identifier host root id in + if node = -1 then None else Some node) (List.rev (App_test.children host search)) + |> Option.value ~default:(-1) in + let click id = let node = find id in check ~msg:("Missing search action: " ^ id) (node <> -1); + App_test.dispatch app (Lui_protocol.Press node); App_test.flush app in let expand = "properties.expand." ^ Q.alpha in if find expand <> -1 then click expand; click ("property.value." ^ Q.alpha ^ "." ^ R.due); click ("view.edit.open.property." ^ Q.alpha ^ "." ^ R.due ^ "." ^ R.day_a); check ~msg:"a journal opened from a search property remains in the visible search stack" (List.exists (function Model.OpenSearchNodeEffect (_, uuid) -> uuid = R.day_a | _ -> false) - (App.model app).pending_effects))) + (App.model app).pending_effects); + ignore (Rpc_test.dispatch_json session "closeNode" ""))) let tag_pages_allow_removing_tags_and_schema_properties () = R.with_runtime (fun session runtime conn -> update runtime conn [put Q.project "block/tags" (D.Ref_to (D.Ident "logseq.class/Task"));