From 1751ab76ca852da4a21e8c81bb6f245da75fbe98 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 08:34:16 +0000 Subject: [PATCH 1/7] fix: retain visible result windows during refresh --- shared/src/logseq/view_query.ml | 20 +++++++++++++------- shared/test/logseq/paged_result_test.ml | 2 ++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 2d3f4d19..4960b73a 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -728,14 +728,22 @@ let loaded_rows context ~owner ~feature view_source input model_source send = let start = jint (jfield "rowOffset" data) and count = row_count data in jtext (jfield "input" data) = input && start <= offset && start + List.length (jlist (jfield "rows" data)) >= min count (offset + limit) in + let visible_overlap data = + let first, last = !viewport in + let start = jint (jfield "rowOffset" data) in + max 0 (min (last + 1) (start + List.length (jlist (jfield "rows" data))) - max first start) in let drive ((view, input), ((offset, limit), (model, _))) = if jfield "rowsDeferred" view.data <> `Bool true then Signal.set page view else ( let reset = input <> !previous_input || jfield "sorting" view.data <> jfield "sorting" (!previous).data || jfield "filters" view.data <> jfield "filters" (!previous).data || view.typ <> (!previous).typ in - if reset then Signal.set range (0, 12); + if reset then (viewport := (0, 11); Signal.set range (0, 12)); previous_input := input; if not (Json.equal view.data (!previous).data) then ( - previous := view; incr generation; requested := None; Signal.set page view); + previous := view; incr generation; requested := None; + (* Keep the presented window during same-query revalidation. *) + if reset || initial_model.Model.selected_graph_id <> model.Model.selected_graph_id + || row_count view.data <= fst !viewport || jfield "error" view.data <> `Null + then Signal.set page view); let offset, limit = if reset then 0, 12 else offset, limit in let generation_prefix = prefix ^ string_of_int !generation ^ ":" in let key = generation_prefix ^ Json.to_string (`List [`String input; `Int offset; `Int limit]) in @@ -743,10 +751,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = let overlap data = if jtext (jfield "input" data) <> input || jfield "revision" data <> jfield "revision" view.data then 0 - else - let first, last = !viewport in - let start = jint (jfield "rowOffset" data) in - max 0 (min (last + 1) (start + List.length (jlist (jfield "rows" data))) - max first start) in + else visible_overlap data in let candidate = match List.assoc_opt key model.Model.result_row_windows with | Some raw -> (try Some (Json.from_string raw) with _ -> None) | None when jfield "revision" current.data = jfield "revision" view.data @@ -758,7 +763,8 @@ let loaded_rows context ~owner ~feature view_source input model_source send = Signal.set error failure; Signal.set loading (candidate = None && failure = "" && jlist (jfield "rows" current.data) = []); match candidate with - | Some data -> if overlap data >= overlap current.data && not (Json.equal data current.data) + | Some data -> if (overlap data > 0 || visible_overlap current.data = 0) + && overlap data >= overlap current.data && not (Json.equal data current.data) then Signal.set page {view with data} | None -> (match !requested with diff --git a/shared/test/logseq/paged_result_test.ml b/shared/test/logseq/paged_result_test.ml index aec14afa..e52c7a86 100644 --- a/shared/test/logseq/paged_result_test.ml +++ b/shared/test/logseq/paged_result_test.ml @@ -120,6 +120,8 @@ let native_ranges_load_rows_and_ignore_obsolete_requests () = Graph_runtime.rebase runtime 44 [] [row]; ignore (Native_bridge.apply_response (Rpc_session.snapshot_visible session)); check ~msg:"a graph revision invalidates windows even when the first rows did not change" (pending () <> []); + check ~msg:"a same-query refresh keeps the presented viewport mounted until its replacement is ready" + (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title") <> -1); drain (); check_eq ~msg:"the current viewport receives revised data without returning to the first row" (App_test.property_string backend (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title")) Lui_protocol.TextValue) "Revised far row"; From 709f0e9677d3e8ea534808c608c264d8d7c34c91 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 08:39:01 +0000 Subject: [PATCH 2/7] fix: present empty results after changing table search --- shared/src/logseq/view_query.ml | 2 +- shared/test/logseq/paged_result_test.ml | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 4960b73a..fbf6887c 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -763,7 +763,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = Signal.set error failure; Signal.set loading (candidate = None && failure = "" && jlist (jfield "rows" current.data) = []); match candidate with - | Some data -> if (overlap data > 0 || visible_overlap current.data = 0) + | Some data -> if (row_count data = 0 || overlap data > 0 || visible_overlap current.data = 0) && overlap data >= overlap current.data && not (Json.equal data current.data) then Signal.set page {view with data} | None -> diff --git a/shared/test/logseq/paged_result_test.ml b/shared/test/logseq/paged_result_test.ml index e52c7a86..8b4ee951 100644 --- a/shared/test/logseq/paged_result_test.ml +++ b/shared/test/logseq/paged_result_test.ml @@ -139,7 +139,12 @@ let native_ranges_load_rows_and_ignore_obsolete_requests () = check ~msg:"failed range reads offer a local retry" (find ("view.rows.retry." ^ R.table_view) <> -1); click ("view.rows.retry." ^ R.table_view); drain (); check ~msg:"retry replaces the failed range with real rows" - (find ("view.cell." ^ R.table_view ^ "." ^ uuid 888 ^ ".block/title") <> -1)))) + (find ("view.cell." ^ R.table_view ^ "." ^ uuid 888 ^ ".block/title") <> -1); + App_test.dispatch app (Lui_protocol.TextChanged (find ("view.search." ^ R.owner), "No matching paged row")); App_test.flush app; + drain (); + check ~msg:"an empty search replaces previously visible results with the empty state" + (find ("query.empty." ^ R.owner) <> -1 + && find ("view.cell." ^ R.table_view ^ "." ^ uuid 888 ^ ".block/title") = -1)))) [2, 2; 3, 4] let list_and_resized_gallery_reach_the_final_page () = From dbcf97961ccc79107e389cf5130dd8029a0df57a Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 08:50:37 +0000 Subject: [PATCH 3/7] fix: revalidate retained windows across projection generations --- shared/src/logseq/view_query.ml | 15 +++++++++------ shared/test/logseq/paged_result_test.ml | 12 ++++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index fbf6887c..5854dd2a 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -721,7 +721,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = let retry_count = Signal.state context.ui_scheduler 0 in let page = Signal.state context.ui_scheduler initial in let loading = Signal.state context.ui_scheduler false and error = Signal.state context.ui_scheduler "" in - let previous = ref initial and generation = ref 0 and requested = ref None in + let previous = ref initial and generation = ref 0 and page_generation = ref 0 and requested = ref None in let previous_input = ref (Signal.sample input) in let viewport = ref (0, 11) in let has_range data input offset limit = @@ -743,7 +743,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = (* Keep the presented window during same-query revalidation. *) if reset || initial_model.Model.selected_graph_id <> model.Model.selected_graph_id || row_count view.data <= fst !viewport || jfield "error" view.data <> `Null - then Signal.set page view); + then (page_generation := !generation; Signal.set page view)); let offset, limit = if reset then 0, 12 else offset, limit in let generation_prefix = prefix ^ string_of_int !generation ^ ":" in let key = generation_prefix ^ Json.to_string (`List [`String input; `Int offset; `Int limit]) in @@ -752,9 +752,10 @@ let loaded_rows context ~owner ~feature view_source input model_source send = if jtext (jfield "input" data) <> input || jfield "revision" data <> jfield "revision" view.data then 0 else visible_overlap data in + let current_overlap = if !page_generation = !generation then overlap current.data else 0 in let candidate = match List.assoc_opt key model.Model.result_row_windows with | Some raw -> (try Some (Json.from_string raw) with _ -> None) - | None when jfield "revision" current.data = jfield "revision" view.data + | None when !page_generation = !generation && jfield "revision" current.data = jfield "revision" view.data && has_range current.data input offset limit -> Some current.data | None when has_range view.data input offset limit -> Some view.data | _ -> None in @@ -764,15 +765,17 @@ let loaded_rows context ~owner ~feature view_source input model_source send = Signal.set loading (candidate = None && failure = "" && jlist (jfield "rows" current.data) = []); match candidate with | Some data -> if (row_count data = 0 || overlap data > 0 || visible_overlap current.data = 0) - && overlap data >= overlap current.data && not (Json.equal data current.data) - then Signal.set page {view with data} + && overlap data >= current_overlap + then (page_generation := !generation; + if not (Json.equal data current.data) then Signal.set page {view with data}) | None -> (match !requested with | Some previous_key when String.starts_with ~prefix:generation_prefix previous_key -> Option.iter (fun raw -> try let data = Json.from_string raw in - if overlap data > overlap current.data then Signal.set page {view with data} + if overlap data > current_overlap then ( + page_generation := !generation; Signal.set page {view with data}) with Yojson.Json_error _ -> ()) (List.assoc_opt previous_key model.result_row_windows) | _ -> ()); diff --git a/shared/test/logseq/paged_result_test.ml b/shared/test/logseq/paged_result_test.ml index 8b4ee951..33ee6e21 100644 --- a/shared/test/logseq/paged_result_test.ml +++ b/shared/test/logseq/paged_result_test.ml @@ -115,6 +115,18 @@ let native_ranges_load_rows_and_ignore_obsolete_requests () = (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title") <> -1); check ~msg:"the first window no longer owns UI cells" (find ("view.cell." ^ R.table_view ^ "." ^ R.alpha ^ ".block/title") = -1); + let base = D.conn_db conn in + List.iter (fun title -> + ignore (D.reset_conn_bang conn (D.db_with [Q.put R.table_view "block/title" (D.String title); + Q.put row "block/title" (D.String title)] base)); + Graph_runtime.rebase runtime 43 [] [R.table_view; row]; + ignore (Native_bridge.apply_response (Rpc_session.snapshot_visible session)); + check ~msg:"projection refresh keeps the presented rows mounted" + (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title") <> -1); + drain (); + check_eq ~msg:"a changed projection updates visible cells even at the same transaction number" + (App_test.property_string backend (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title")) Lui_protocol.TextValue) + title) ["Projection A"; "Projection B"]; let id = Option.get (D.entid (Graph_runtime.db runtime) "block/uuid" (D.Uuid row)) in ignore (D.transact_conn conn [D.Add (D.Entity_id id, "block/title", D.String "Revised far row")]); Graph_runtime.rebase runtime 44 [] [row]; From bdf2253fb46bfd7a64ac94c75f95d5ad8e1ab350 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 09:40:52 +0000 Subject: [PATCH 4/7] test: use visible sidebar controls in drawer flow --- tests/e2e/sidebar.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/sidebar.yaml b/tests/e2e/sidebar.yaml index af702ae5..50928cf7 100644 --- a/tests/e2e/sidebar.yaml +++ b/tests/e2e/sidebar.yaml @@ -19,18 +19,18 @@ appId: com.logseq.logseq - assertVisible: id: "section.sidebar.recent" - tapOn: - id: "button.sidebar.dismiss" + id: "button.sidebar" - assertNotVisible: - id: "button.sidebar.dismiss" + id: "link.sidebar.graphs" - swipe: start: "0%,50%" end: "75%,50%" duration: 500 - assertVisible: - id: "button.sidebar.dismiss" + id: "link.sidebar.graphs" - swipe: start: "75%,50%" end: "0%,50%" duration: 500 - assertNotVisible: - id: "button.sidebar.dismiss" + id: "link.sidebar.graphs" From 3c24275257df7ad2be44741ee4e7c77ff37a3ad2 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 09:40:52 +0000 Subject: [PATCH 5/7] fix: version deferred windows by database projection --- docs/agent-guide/011-query-block-rendering.md | 31 +++++++++++++++++++ shared/src/logseq/core/result_view.ml | 19 +++++++----- shared/src/logseq/core/result_view.mli | 2 +- shared/src/logseq/core/rpc_session.ml | 2 +- shared/src/logseq/view_query.ml | 29 +++++++++++------ shared/test/logseq/paged_result_test.ml | 5 ++- 6 files changed, 67 insertions(+), 21 deletions(-) diff --git a/docs/agent-guide/011-query-block-rendering.md b/docs/agent-guide/011-query-block-rendering.md index 4397a891..f283c578 100644 --- a/docs/agent-guide/011-query-block-rendering.md +++ b/docs/agent-guide/011-query-block-rendering.md @@ -101,6 +101,37 @@ The full core test alias checks regressions in synchronization, projection, hydr - Surface unsupported custom Hiccup views explicitly; do not execute browser rendering code. - Preserve pre-existing workspace edits and avoid unrelated changes. +### Deferred-result projection identity + +Cached result views include an optional integer `projectionRevision` in both +snapshot views and `resultRows.view`. It changes when the session's immutable +database identity or local journal day changes, even if `revision` (the database +transaction number), the first 12 rows, and the view configuration are unchanged. +For example, replacing a far-row projection may change +`{"revision": 100, "projectionRevision": 7}` to +`{"revision": 100, "projectionRevision": 8}`. + +Clients must match both revisions, the input, and the graph before reusing a row +window. During same-query revalidation, the previous page may stay visible but +cannot satisfy the new generation's cache. The initial read remains 12 rows and +deferred reads remain bounded to 64. The token is session-local: compare it for +equality, never persist it or compare it across recreated sessions. The field is +additive; uncached renders and older cores may omit it, in which case clients +retain the legacy transaction-revision check. Older clients can ignore the field. +`Result_view.render_rows` accepts an optional `~cache` without changing existing +uncached callers; query snapshots pass the same session cache as `resultRows` +reads so both paths share the projection token. Supplied snapshot rows remain +authoritative and are not reused from the prepared-view cache: they can depend +on the active page as well as the database. + +For opt-in release diagnostics, set `LOGSEQ_TRACE_RESULT_WINDOWS=1` at launch +(`SIMCTL_CHILD_LOGSEQ_TRACE_RESULT_WINDOWS=1` with `simctl`). Stderr records +`init`, `source`, `request`, `accept`/`accept-reuse`, and `reset` with the view UUID, +generation, transaction/projection revisions, inclusive viewport endpoints, +requested offset/limit, and row offset/length. It never records row content or +search text. These are shared page-state events, not native mount completion or +render-latency measurements. Logging is disabled by default. + ## Question No user input is required to begin. diff --git a/shared/src/logseq/core/result_view.ml b/shared/src/logseq/core/result_view.ml index 5e50a8bb..c88e133d 100644 --- a/shared/src/logseq/core/result_view.ml +++ b/shared/src/logseq/core/result_view.ml @@ -287,15 +287,18 @@ type prepared_view = { } type cache = { mutable database : (Ds.db * int) option; + mutable projection_revision : int; mutable scopes : ((string * string) * Query_block.row list) list; mutable views : ((string * string * string * bool * string) * prepared_view) list; } -let create_cache () = {database = None; scopes = []; views = []} +let create_cache () = {database = None; projection_revision = 0; scopes = []; views = []} let validate_cache db cache = let day = Cache_model.journal_day_for_ms (int_of_float (Unix.gettimeofday () *. 1000.)) in match cache.database with | Some (previous, previous_day) when previous == db && previous_day = day -> () - | _ -> cache.database <- Some (db, day); cache.scopes <- []; cache.views <- [] + | _ -> cache.database <- Some (db, day); + cache.projection_revision <- cache.projection_revision + 1; + cache.scopes <- []; cache.views <- [] let initial_window = {view_uuid = None; offset = 0; limit = 12} let slice offset limit rows = let rec drop offset rows = if offset <= 0 then rows else match rows with [] -> [] | _ :: rest -> drop (offset - 1) rest in @@ -303,7 +306,7 @@ let slice offset limit rows = | _ when limit <= 0 -> List.rev acc | [] -> List.rev acc | row :: rest -> take (limit - 1) (row :: acc) rest in take limit [] (drop offset rows) -let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db ~owner ~feature ~encode_block rows = +let render_rows_cached ?cache ~reuse_prepared ?(input = "") ?(include_values = true) ?window db ~owner ~feature ~encode_block rows = Option.iter (validate_cache db) cache; let views = saved_views db owner feature in let views = if views = [] then [None] else List.map Option.some views in @@ -331,7 +334,8 @@ let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db | Some "logseq.property.view/type.list" | None -> "list" | _ -> "invalid" in let fields = ["uuid", `String id; "name", `String name; "type", `String typ; "persisted", `Bool (view <> None); "sorting", plain (setting "logseq.property.table/sorting"); - "filters", plain (setting "logseq.property.table/filters")] in + "filters", plain (setting "logseq.property.table/filters")] @ + Option.fold ~none:[] ~some:(fun cache -> ["projectionRevision", `Int cache.projection_revision]) cache in try if typ = "invalid" then invalid_arg "Unsupported view type"; let prepare () = @@ -353,6 +357,7 @@ let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db catalog = `List (List.map (column (typ <> "list")) catalog)} in let prepared = match cache with | None -> prepare () + | Some _ when not reuse_prepared -> prepare () | Some cache -> let key = owner, feature, input, include_values, id in match List.assoc_opt key cache.views with @@ -375,8 +380,8 @@ let render_rows_cached ?cache ?(input = "") ?(include_values = true) ?window db "rows", `List []; "columns", `List []]) in `Assoc ["ownerUuid", `String owner; "featureType", `String feature; "views", `List (List.map view_json views)] -let render_rows ?input ?include_values ?window db ~owner ~feature ~encode_block rows = - render_rows_cached ?input ?include_values ?window db ~owner ~feature ~encode_block rows +let render_rows ?cache ?input ?include_values ?window db ~owner ~feature ~encode_block rows = + render_rows_cached ?cache ~reuse_prepared:false ?input ?include_values ?window db ~owner ~feature ~encode_block rows let scope db owner feature = match feature with | "query-result" -> (match Query_block.render db owner with Some result -> result.rows | None -> invalid_arg "This object has no query") @@ -402,7 +407,7 @@ let render ?cache ?input ?include_values ?window db ~owner ~feature ~encode_bloc | Some rows -> rows | None -> let rows = scope db owner feature in cache.scopes <- slice 0 8 ((key, rows) :: cache.scopes); rows in - Ok (render_rows_cached ?cache ?input ?include_values ?window db ~owner ~feature ~encode_block rows) + Ok (render_rows_cached ?cache ~reuse_prepared:true ?input ?include_values ?window db ~owner ~feature ~encode_block rows) with error -> Error (match error with Invalid_argument message -> message | _ -> Printexc.to_string error) let filter_values db ~owner ~feature ~attr = diff --git a/shared/src/logseq/core/result_view.mli b/shared/src/logseq/core/result_view.mli index f73d114e..eaf6a260 100644 --- a/shared/src/logseq/core/result_view.mli +++ b/shared/src/logseq/core/result_view.mli @@ -3,7 +3,7 @@ type cache val create_cache : unit -> cache val initial_window : row_window val render_rows : - ?input:string -> ?include_values:bool -> ?window:row_window -> Datascript.db -> owner:string -> feature:string -> + ?cache:cache -> ?input:string -> ?include_values:bool -> ?window:row_window -> Datascript.db -> owner:string -> feature:string -> encode_block:(Cache_model.block -> Yojson.Basic.t) -> Query_block.row list -> Yojson.Basic.t val render : diff --git a/shared/src/logseq/core/rpc_session.ml b/shared/src/logseq/core/rpc_session.ml index 2864ceb3..350844b6 100644 --- a/shared/src/logseq/core/rpc_session.ml +++ b/shared/src/logseq/core/rpc_session.ml @@ -137,7 +137,7 @@ let query_for session db uuid = Query_block.render ?current_page db uuid |> Option.map (fun result -> try - let views = Result_view.render_rows ~include_values:false ~window:Result_view.initial_window db ~owner:uuid ~feature:"query-result" ~encode_block:encode_tree result.Query_block.rows in + let views = Result_view.render_rows ~cache:session.result_view_cache ~include_values:false ~window:Result_view.initial_window db ~owner:uuid ~feature:"query-result" ~encode_block:encode_tree result.Query_block.rows in let preview = {result with Query_block.rows = List.of_seq (Seq.take 12 (List.to_seq result.rows))} in (match Query_block.to_yojson encode_tree preview, views with | `Assoc fields, `Assoc view_fields -> `Assoc (fields @ view_fields) diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index 5854dd2a..bbf78751 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -724,6 +724,13 @@ let loaded_rows context ~owner ~feature view_source input model_source send = let previous = ref initial and generation = ref 0 and page_generation = ref 0 and requested = ref None in let previous_input = ref (Signal.sample input) in let viewport = ref (0, 11) in + let trace_enabled = Sys.getenv_opt "LOGSEQ_TRACE_RESULT_WINDOWS" = Some "1" in + let trace phase data = if trace_enabled then ( + let first, last = !viewport and offset, limit = Signal.sample (Signal.value range) in + Printf.eprintf "result-window phase=%s view=%s generation=%d page-generation=%d revision=%s projection=%s viewport=%d:%d range=%d:%d rows=%d:%d\n%!" + phase (jtext (jfield "uuid" data)) !generation !page_generation + (Json.to_string (jfield "revision" data)) (Json.to_string (jfield "projectionRevision" data)) + first last offset limit (jint (jfield "rowOffset" data)) (List.length (jlist (jfield "rows" data)))) in let has_range data input offset limit = let start = jint (jfield "rowOffset" data) and count = row_count data in jtext (jfield "input" data) = input && start <= offset && @@ -740,22 +747,24 @@ let loaded_rows context ~owner ~feature view_source input model_source send = previous_input := input; if not (Json.equal view.data (!previous).data) then ( previous := view; incr generation; requested := None; + trace "source" view.data; (* Keep the presented window during same-query revalidation. *) if reset || initial_model.Model.selected_graph_id <> model.Model.selected_graph_id || row_count view.data <= fst !viewport || jfield "error" view.data <> `Null - then (page_generation := !generation; Signal.set page view)); + then (page_generation := !generation; trace "reset" view.data; Signal.set page view)); let offset, limit = if reset then 0, 12 else offset, limit in let generation_prefix = prefix ^ string_of_int !generation ^ ":" in let key = generation_prefix ^ Json.to_string (`List [`String input; `Int offset; `Int limit]) in let current = Signal.sample (Signal.value page) in - let overlap data = - if jtext (jfield "input" data) <> input - || jfield "revision" data <> jfield "revision" view.data then 0 - else visible_overlap data in + let current_projection data = jtext (jfield "input" data) = input + && jfield "revision" data = jfield "revision" view.data + && jfield "projectionRevision" data = jfield "projectionRevision" view.data in + let overlap data = if current_projection data then visible_overlap data else 0 in let current_overlap = if !page_generation = !generation then overlap current.data else 0 in let candidate = match List.assoc_opt key model.Model.result_row_windows with - | Some raw -> (try Some (Json.from_string raw) with _ -> None) - | None when !page_generation = !generation && jfield "revision" current.data = jfield "revision" view.data + | Some raw -> (try let data = Json.from_string raw in + if current_projection data then Some data else None with _ -> None) + | None when !page_generation = !generation && current_projection current.data && has_range current.data input offset limit -> Some current.data | None when has_range view.data input offset limit -> Some view.data | _ -> None in @@ -767,7 +776,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = | Some data -> if (row_count data = 0 || overlap data > 0 || visible_overlap current.data = 0) && overlap data >= current_overlap then (page_generation := !generation; - if not (Json.equal data current.data) then Signal.set page {view with data}) + if not (Json.equal data current.data) then (trace "accept" data; Signal.set page {view with data})) | None -> (match !requested with | Some previous_key when String.starts_with ~prefix:generation_prefix previous_key -> @@ -775,7 +784,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = try let data = Json.from_string raw in if overlap data > current_overlap then ( - page_generation := !generation; Signal.set page {view with data}) + page_generation := !generation; trace "accept-reuse" data; Signal.set page {view with data}) with Yojson.Json_error _ -> ()) (List.assoc_opt previous_key model.result_row_windows) | _ -> ()); @@ -783,6 +792,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = (model.pending_effects @ model.in_flight_effects) in if not busy && !requested <> Some key then ( requested := Some key; + trace "request" view.data; ignore (send (Model.LoadResultRows (key, Json.to_string (`Assoc ([ "key", `String key; "ownerUuid", `String owner; "featureType", `String feature; "viewUuid", `String view.id; "input", `String input; "offset", `Int offset; "limit", `Int limit] @ @@ -792,6 +802,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = (own (Signal.map2 (fun range model -> range, model) (Signal.value range) (own (Signal.map2 (fun model retry -> model, retry) model_source (Signal.value retry_count)))))) in ignore (Signal.own context.ui_scope (Signal.observe request drive)); + trace "init" initial.data; drive (Signal.sample request); let visible columns first last = if first >= 0 && last >= first then ( diff --git a/shared/test/logseq/paged_result_test.ml b/shared/test/logseq/paged_result_test.ml index 33ee6e21..24af5def 100644 --- a/shared/test/logseq/paged_result_test.ml +++ b/shared/test/logseq/paged_result_test.ml @@ -117,9 +117,8 @@ let native_ranges_load_rows_and_ignore_obsolete_requests () = (find ("view.cell." ^ R.table_view ^ "." ^ R.alpha ^ ".block/title") = -1); let base = D.conn_db conn in List.iter (fun title -> - ignore (D.reset_conn_bang conn (D.db_with [Q.put R.table_view "block/title" (D.String title); - Q.put row "block/title" (D.String title)] base)); - Graph_runtime.rebase runtime 43 [] [R.table_view; row]; + ignore (D.reset_conn_bang conn (D.db_with [Q.put row "block/title" (D.String title)] base)); + Graph_runtime.rebase runtime 43 [] [row]; ignore (Native_bridge.apply_response (Rpc_session.snapshot_visible session)); check ~msg:"projection refresh keeps the presented rows mounted" (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title") <> -1); From 629f1963d559c57b47dcc82c0006e9b8ab0a66fa Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 09:53:19 +0000 Subject: [PATCH 6/7] fix: preserve window coverage while refresh and scrolling race --- docs/agent-guide/011-query-block-rendering.md | 6 +++++ shared/src/logseq/view_query.ml | 23 +++++++++++++------ shared/test/logseq/paged_result_test.ml | 12 +++++++++- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/docs/agent-guide/011-query-block-rendering.md b/docs/agent-guide/011-query-block-rendering.md index f283c578..d4768e7b 100644 --- a/docs/agent-guide/011-query-block-rendering.md +++ b/docs/agent-guide/011-query-block-rendering.md @@ -124,6 +124,12 @@ reads so both paths share the projection token. Supplied snapshot rows remain authoritative and are not reused from the prepared-view cache: they can depend on the active page as well as the database. +Retained rows still count toward presentation coverage, not cache freshness: +a partial fresh reply must not displace a more complete visible page. Compare +coverage within the replacement's row count so shrinking/empty results can +remove obsolete rows. Keep the requested range covering the current viewport, +including when it moves back inside a retained page during an outstanding read. + For opt-in release diagnostics, set `LOGSEQ_TRACE_RESULT_WINDOWS=1` at launch (`SIMCTL_CHILD_LOGSEQ_TRACE_RESULT_WINDOWS=1` with `simctl`). Stderr records `init`, `source`, `request`, `accept`/`accept-reuse`, and `reset` with the view UUID, diff --git a/shared/src/logseq/view_query.ml b/shared/src/logseq/view_query.ml index bbf78751..255ed80f 100644 --- a/shared/src/logseq/view_query.ml +++ b/shared/src/logseq/view_query.ml @@ -725,8 +725,9 @@ let loaded_rows context ~owner ~feature view_source input model_source send = let previous_input = ref (Signal.sample input) in let viewport = ref (0, 11) in let trace_enabled = Sys.getenv_opt "LOGSEQ_TRACE_RESULT_WINDOWS" = Some "1" in - let trace phase data = if trace_enabled then ( - let first, last = !viewport and offset, limit = Signal.sample (Signal.value range) in + let trace ?requested_range phase data = if trace_enabled then ( + let first, last = !viewport in + let offset, limit = match requested_range with Some range -> range | None -> Signal.sample (Signal.value range) in Printf.eprintf "result-window phase=%s view=%s generation=%d page-generation=%d revision=%s projection=%s viewport=%d:%d range=%d:%d rows=%d:%d\n%!" phase (jtext (jfield "uuid" data)) !generation !page_generation (Json.to_string (jfield "revision" data)) (Json.to_string (jfield "projectionRevision" data)) @@ -760,7 +761,12 @@ let loaded_rows context ~owner ~feature view_source input model_source send = && jfield "revision" data = jfield "revision" view.data && jfield "projectionRevision" data = jfield "projectionRevision" view.data in let overlap data = if current_projection data then visible_overlap data else 0 in - let current_overlap = if !page_generation = !generation then overlap current.data else 0 in + let presented_overlap data = + if jtext (jfield "input" current.data) <> input then 0 else + let first, last = !viewport in + let start = jint (jfield "rowOffset" current.data) in + max 0 (min (min (last + 1) (row_count data)) + (start + List.length (jlist (jfield "rows" current.data))) - max first start) in let candidate = match List.assoc_opt key model.Model.result_row_windows with | Some raw -> (try let data = Json.from_string raw in if current_projection data then Some data else None with _ -> None) @@ -774,7 +780,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = Signal.set loading (candidate = None && failure = "" && jlist (jfield "rows" current.data) = []); match candidate with | Some data -> if (row_count data = 0 || overlap data > 0 || visible_overlap current.data = 0) - && overlap data >= current_overlap + && overlap data >= presented_overlap data then (page_generation := !generation; if not (Json.equal data current.data) then (trace "accept" data; Signal.set page {view with data})) | None -> @@ -783,7 +789,8 @@ let loaded_rows context ~owner ~feature view_source input model_source send = Option.iter (fun raw -> try let data = Json.from_string raw in - if overlap data > current_overlap then ( + let coverage = overlap data and presented = presented_overlap data in + if coverage > presented || !page_generation <> !generation && coverage > 0 && coverage >= presented then ( page_generation := !generation; trace "accept-reuse" data; Signal.set page {view with data}) with Yojson.Json_error _ -> ()) (List.assoc_opt previous_key model.result_row_windows) @@ -792,7 +799,7 @@ let loaded_rows context ~owner ~feature view_source input model_source send = (model.pending_effects @ model.in_flight_effects) in if not busy && !requested <> Some key then ( requested := Some key; - trace "request" view.data; + trace ~requested_range:(offset, limit) "request" view.data; ignore (send (Model.LoadResultRows (key, Json.to_string (`Assoc ([ "key", `String key; "ownerUuid", `String owner; "featureType", `String feature; "viewUuid", `String view.id; "input", `String input; "offset", `Int offset; "limit", `Int limit] @ @@ -809,8 +816,10 @@ let loaded_rows context ~owner ~feature view_source input model_source send = viewport := first, last; let current = (Signal.sample (Signal.value page)).data in let start = jint (jfield "rowOffset" current) and length = List.length (jlist (jfield "rows" current)) in + let offset, limit = Signal.sample (Signal.value range) in let margin = max 8 (last - first + 1) in - if first < start + margin && start > 0 || last >= start + length - margin && start + length < row_count current then ( + if first < offset || min last (row_count current - 1) >= offset + limit + || first < start + margin && start > 0 || last >= start + length - margin && start + length < row_count current then ( let limit = max columns (64 / columns * columns) in let maximum = (max 0 (row_count current - limit) + columns - 1) / columns * columns in let offset = min maximum (max 0 (first - max 0 ((limit - (last - first + 1)) / 2))) in diff --git a/shared/test/logseq/paged_result_test.ml b/shared/test/logseq/paged_result_test.ml index 24af5def..29e97264 100644 --- a/shared/test/logseq/paged_result_test.ml +++ b/shared/test/logseq/paged_result_test.ml @@ -126,13 +126,23 @@ let native_ranges_load_rows_and_ignore_obsolete_requests () = check_eq ~msg:"a changed projection updates visible cells even at the same transaction number" (App_test.property_string backend (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title")) Lui_protocol.TextValue) title) ["Projection A"; "Projection B"]; + scroll 923 931; let id = Option.get (D.entid (Graph_runtime.db runtime) "block/uuid" (D.Uuid row)) in ignore (D.transact_conn conn [D.Add (D.Entity_id id, "block/title", D.String "Revised far row")]); Graph_runtime.rebase runtime 44 [] [row]; ignore (Native_bridge.apply_response (Rpc_session.snapshot_visible session)); check ~msg:"a graph revision invalidates windows even when the first rows did not change" (pending () <> []); + let retained = Option.get (desired 925) |> member "block" |> member "uuid" |> text in check ~msg:"a same-query refresh keeps the presented viewport mounted until its replacement is ready" - (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title") <> -1); + (find ("view.cell." ^ R.table_view ^ "." ^ retained ^ ".block/title") <> -1); + drain (); + scroll 889 897; + let retained = Option.get (desired 895) |> member "block" |> member "uuid" |> text in + drain (); + check ~msg:"a partial fresh window cannot displace the retained complete viewport" + (find ("view.cell." ^ R.table_view ^ "." ^ retained ^ ".block/title") <> -1); + drain (); + scroll 900 908; drain (); check_eq ~msg:"the current viewport receives revised data without returning to the first row" (App_test.property_string backend (find ("view.cell." ^ R.table_view ^ "." ^ row ^ ".block/title")) Lui_protocol.TextValue) "Revised far row"; From 01f1becab87280eaacf92abe995718d0a3b2a34b Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 12:40:32 +0000 Subject: [PATCH 7/7] fix: park closed compact drawers with pinned LUI update --- apple/Package.resolved | 4 ++-- apple/Package.swift | 2 +- logseq.opam | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apple/Package.resolved b/apple/Package.resolved index fd06ae17..d9f7c18a 100644 --- a/apple/Package.resolved +++ b/apple/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "2d81e431cc075fcace46930fa7174bc532cc38f3af8cec7264bc9d363c2a464d", + "originHash" : "c98cc0290cfacc56597b44779376df9d88392efa199e4f83a9c602ccbfcd11ad", "pins" : [ { "identity" : "lui", "kind" : "remoteSourceControl", "location" : "ssh://git@github.com/logseq/lui.git", "state" : { - "revision" : "a11edfe763e9a131e72a8e29e1dcbdd07f8d6427" + "revision" : "ca87d1cdf534954a38f55616320ede8b7ac6d91e" } } ], diff --git a/apple/Package.swift b/apple/Package.swift index 9f25e101..6266d0e1 100644 --- a/apple/Package.swift +++ b/apple/Package.swift @@ -44,7 +44,7 @@ let package = Package( .library(name: "LogseqModel", type: .dynamic, targets: ["LogseqModel"]), ], dependencies: [ - .package(url: "ssh://git@github.com/logseq/lui.git", revision: "a11edfe763e9a131e72a8e29e1dcbdd07f8d6427") + .package(url: "ssh://git@github.com/logseq/lui.git", revision: "ca87d1cdf534954a38f55616320ede8b7ac6d91e") ], targets: [ .executableTarget( diff --git a/logseq.opam b/logseq.opam index 4c9b120b..0a82cc0a 100644 --- a/logseq.opam +++ b/logseq.opam @@ -34,7 +34,7 @@ pin-depends: [ ["melange-transit-core.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] ["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"] - ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#a11edfe763e9a131e72a8e29e1dcbdd07f8d6427"] + ["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#ca87d1cdf534954a38f55616320ede8b7ac6d91e"] ["drive.dev" "git+https://github.com/logseq/drive.git#dd735a65ed525d1c36db38ac8e8114aa81d314a1"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"] ["mldoc.dev" "git+https://github.com/logseq/mldoc.git#f11230c367964af2b575524d00779cfb1ffc7147"]