From 3f0e613edac49872d34af72c742ba64116626da4 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 16:08:26 +0800 Subject: [PATCH] fix(retained): rebuild kid lists after a wholesale invalidation invalidate (journal truncation past the retained cursor, viewport/scale change, invalidate_paint) reset every fragment, but fresh fragments start with no kid list and nothing re-marked them, so the next frame painted childless roots: a blank window. In Logseq, opening the Keymap settings pane (~2.5k nodes in one batch, > the 8192-op journal) blanked the whole UI. Re-mark every node's kids on the next paint. Adds regression tests for journal overflow and explicit invalidate. --- platform/native/lui_paint/lui_retained.ml | 15 ++++++++- .../lui_paint/test/lui_retained_test.ml | 33 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/platform/native/lui_paint/lui_retained.ml b/platform/native/lui_paint/lui_retained.ml index 93b5d702..8b0eb77e 100644 --- a/platform/native/lui_paint/lui_retained.ml +++ b/platform/native/lui_paint/lui_retained.ml @@ -101,6 +101,7 @@ type retained = { mutable scale : float; (* Any mark since the last materialize. *) mutable dirty : bool; + mutable resync : bool; (* Materialize generation — fragments record which frame last pushed their buffers. *) mutable mgen : int; @@ -118,12 +119,16 @@ let create () = { frags = Hashtbl.create 256; fparent = Hashtbl.create 256; pend_kids = Hashtbl.create 16; pend_segs = Hashtbl.create 64; rev = -1; vp = rect 0. 0. 0. 0.; scale = 1.; - dirty = false; mgen = 0; stat_repaints = 0; stat_visits = 0; + dirty = false; resync = false; mgen = 0; stat_repaints = 0; stat_visits = 0; vwin = Hashtbl.create 4; mounted = (fun _ -> None) } let invalidate (rt : retained) = Hashtbl.reset rt.frags; Hashtbl.reset rt.vwin; + (* Fresh fragments start with no kid list and nothing marks them: + the next paint must re-derive every node's children, or the tree + paints as childless roots (an empty frame). *) + rt.resync <- true; rt.dirty <- true let stat_repaints (rt : retained) = rt.stat_repaints @@ -699,6 +704,14 @@ let paint_retained rt hooks store (scene : Lui_scene.t) ?moved ?hints ?vlists end; if Lui_store.revision store <> rt.rev then advance rt store; rt.mounted <- hooks.Lui_paint.mounted_children; + if rt.resync then begin + rt.resync <- false; + List.iter + (fun root -> + List.iter (fun id -> mark_rekids rt store id) + (Lui_store.preorder ~root store)) + (Lui_store.root_ids store) + end; (* A virtual list whose mounted window slid (scroll, resize) has a stale kids list even though no store op named it. *) (match vlists with diff --git a/platform/native/lui_paint/test/lui_retained_test.ml b/platform/native/lui_paint/test/lui_retained_test.ml index 689a3a4c..fd0e7c19 100644 --- a/platform/native/lui_paint/test/lui_retained_test.ml +++ b/platform/native/lui_paint/test/lui_retained_test.ml @@ -530,6 +530,35 @@ let test_random_equivalence () = List.iter run_seed [ [| 0x5EED; 0xBEEF |]; [| 1; 0xC0FFEE |]; [| 33; 7 |]; [| 104; 42 |] ] +(* A batch larger than the store journal (8192 ops) truncates the retained + cursor; the wholesale resync must re-derive every kid list, not + paint childless roots (an empty frame). *) +let test_journal_overflow_resync () = + let env = env () in + build_rows env 4; + frame env (); + let ops = + List.concat_map + (fun i -> + let id = 1000 + i in + [ CreateNode (id, Button); + SetProp (id, TextValue, StringValue "x"); + InsertChild (1, id, 4 + i) ]) + (List.init 5000 Fun.id) + in + apply env.store ops; + frame env ~height:720 (); + check bool "frame not empty" true (env.kept.ops <> []) + +(* Same for an explicit wholesale invalidation (theme swap). *) +let test_invalidate_rebuilds_kids () = + let env = env () in + build_rows env 6; + frame env (); + Lui_retained.invalidate env.rt; + frame env (); + check bool "frame not empty" true (env.kept.ops <> []) + let () = run "lui_retained" [ ( "fragments", @@ -549,4 +578,6 @@ let () = test_case "drop subtree" `Quick test_drop_subtree; test_case "wide removal" `Quick test_wide_removal; test_case "vlist scroll" `Quick test_vlist_scroll; - test_case "random equivalence" `Quick test_random_equivalence ] ) ] + test_case "random equivalence" `Quick test_random_equivalence; + test_case "journal overflow resync" `Quick test_journal_overflow_resync; + test_case "invalidate rebuilds kids" `Quick test_invalidate_rebuilds_kids ] ) ]