diff --git a/docs/agent-guide/proposed/bugfix/2026-10-19-virtual-prepend-anchor.md b/docs/agent-guide/proposed/bugfix/2026-10-19-virtual-prepend-anchor.md new file mode 100644 index 00000000..dd033bb0 --- /dev/null +++ b/docs/agent-guide/proposed/bugfix/2026-10-19-virtual-prepend-anchor.md @@ -0,0 +1,79 @@ +# Preserve virtual-list reading position across prepend + +## Problem + +A host cannot reliably restore a virtualized message using total scroll-height +changes. Unmeasured older rows and disappearing offscreen expanded tool groups +change that total independently of the reader's anchor. Mapre reproduces a +later reply moving the reading position after loading older history. + +## Proposal + +Preserve the first intersecting retained row and its actual viewport offset +when new rows are prepended. Resolve its retained identity in the new sequence, +materialize it through the virtualizer, and restore the measured offset. Keep +measurement corrections until another collection change, explicit scroll +intent or user scroll input. Appends must never reuse a prepend correction. +Mapre will remove its duplicate height-based prepend adjustment after actual +consumer acceptance. Existing tail-follow and explicit scroll intents retain +ownership of their behavior. + +## Alternatives considered + +### Repeated total-height compensation + +Rejected because tail geometry and virtualized measurement are unrelated to +the visible row's position. + +## Acceptance criteria + +- Browser tests fail first on mixed-height prepends and pass using retained IDs. +- Repeated prepends, later appends, user scrolling and explicit scroll requests + preserve their intended position without stale correction or tail jumps. +- The Mapre grouped-tool and existing history/scroll browser tests pass together. +- Melange compilation, formatting and focused function-length checks pass. + +## Risks + +- Scroll ownership can compete with a host; remove Mapre's duplicate prepend + compensation when consuming this implementation. +- Row deletion or navigation must cancel an anchor that no longer exists. + +## Questions + +None. This implements the existing authorized latest-first Chat and grouping +requirements without changing sandbox or model execution. + +## Verification + +Two actual browser cases reproduce lost row identity after mixed-height and +repeated prepends before implementation; the explicit-scroll case characterizes +existing priority. All three pass afterward in Chromium, Firefox and WebKit. +The 20-case Chromium cohort also covers ordinary lists, pinned replay, tall +responses, explicit scroll completion and retained DOM attributes. + +The actual Mapre consumer removes total-height compensation and passes all 31 +grouped activity, tool-call, recent history and scrolling browser cases together. +This includes history beyond 1,000 rows, concurrent live events, keyboard/touch +input, navigation and restored completed tools. Melange and install builds pass; +the new helper module passes the 64-line AST check. New modules are formatted +with explicit outside-project ocamlformat support. Whole-repository @fmt still +reports existing Dune formatting differences and absent ocamlformat config; +no unrelated files or Dune declarations are modified. The consumer integration, +additional regression cohort, CI and production rollout remain separate gates. + +### CI reparenting correction + +CI39212575627 passes six jobs and 135 of 136 browser cases. The unchanged +virtual-tree focus case fails because a virtual list moved into a new tree +inherits the reading position of its old container when tree rows are inserted +at the front. The same existing test reproduces this locally before correction. +Only preserve a prepend anchor within the same retained parent; a reparent +also cancels any pending correction. Do not special-case tree IDs or weaken +the bounded-DOM assertion. Focus, explicit scroll and consumer regressions must +pass again on this source before release. + +After this correction all eighteen Chromium virtual-list, tree-focus, +prepend and Chat-scroll cases pass together, including the unchanged failing +CI assertion. Melange/install compilation passes. Exact-head CI is still +required; this local correction does not erase the original failed run. diff --git a/platform/web/melange/widgets/lui_web_virtual.ml b/platform/web/melange/widgets/lui_web_virtual.ml index 0f9d35bc..aba82abd 100644 --- a/platform/web/melange/widgets/lui_web_virtual.ml +++ b/platform/web/melange/widgets/lui_web_virtual.ml @@ -71,6 +71,11 @@ let install renderer node ~materialize ~unmount = focused element — scroll-driven onChange ticks leave all three alone, so identical inputs skip the pass entirely. *) let last_roving_inputs = ref None in + let parent_scope () = + Option.bind (Store.node renderer.web_store node) + (fun current -> current.retained_parent) + in + let last_parent_scope = ref (parent_scope ()) in let same_element a b = match (a, b) with | None, None -> true @@ -157,6 +162,9 @@ let install renderer node ~materialize ~unmount = update_rows (); let virtualizer = V.make (make_options ()) in let pending_scroll = ref None in + let reading_anchor = + Lui_web_virtual_anchor.create root mounted positions virtualizer + in let complete_pending outcome = match !pending_scroll with | Some (token, _, _, _, _) -> @@ -394,6 +402,9 @@ let install renderer node ~materialize ~unmount = tail_pinned := false end | _ -> ()); + (if !pending_scroll = None then + Lui_web_virtual_anchor.restore reading_anchor + ~scrolled:(fun top -> prog_scroll := top)); attempt_scroll (); if !pending_render then begin pending_render := false; @@ -411,6 +422,7 @@ let install renderer node ~materialize ~unmount = match property ScrollToken with | Some (IntValue token) when !last_token <> Some token -> last_token := Some token; + Lui_web_virtual_anchor.cancel reading_anchor; complete_pending "cancelled"; let align = match text ScrollAnchor "top" with @@ -430,7 +442,25 @@ let install renderer node ~materialize ~unmount = in let refresh () = if not !disposed then begin + (* Preserve measured retained identity only for a prepend. A later + append or reparent must not inherit an older reading position. *) + let current_parent_scope = parent_scope () in + let same_parent = current_parent_scope = !last_parent_scope in + last_parent_scope := current_parent_scope; + if not same_parent then Lui_web_virtual_anchor.cancel reading_anchor; + let previous_sequence = !sequence in + let previous_first = + if Array.length !rows = 0 then None else Some !rows.(0) + in + let anchor = Lui_web_virtual_anchor.capture reading_anchor in update_rows (); + if previous_sequence != !sequence then begin + Lui_web_virtual_anchor.cancel reading_anchor; + match Option.bind previous_first (Hashtbl.find_opt positions) with + | Some index when index > 0 && same_parent -> + Lui_web_virtual_anchor.preserve reading_anchor anchor + | _ -> () + end; V.set_options virtualizer (make_options ()); if connected root then V.will_update virtualizer; render (); @@ -441,12 +471,21 @@ let install renderer node ~materialize ~unmount = attempt_scroll () end in + let cancel_anchor _ = Lui_web_virtual_anchor.cancel reading_anchor in + let anchor_inputs = [ "wheel"; "touchstart"; "pointerdown"; "keydown" ] in + List.iter + (fun event -> W.Element.addEventListener event cancel_anchor root) + anchor_inputs; let on_focus _ = refresh () in W.Element.addEventListener "focusin" on_focus root; W.Element.addEventListener "focusout" on_focus root; let dispose () = disposed := true; cleanup (); + Lui_web_virtual_anchor.cancel reading_anchor; + List.iter + (fun event -> W.Element.removeEventListener event cancel_anchor root) + anchor_inputs; W.Element.removeEventListener "focusin" on_focus root; W.Element.removeEventListener "focusout" on_focus root; Hashtbl.remove renderer.web_virtual_lists node diff --git a/platform/web/melange/widgets/lui_web_virtual_anchor.ml b/platform/web/melange/widgets/lui_web_virtual_anchor.ml new file mode 100644 index 00000000..bce59d5a --- /dev/null +++ b/platform/web/melange/widgets/lui_web_virtual_anchor.ml @@ -0,0 +1,58 @@ +module W = Webapi.Dom +module V = Lui_web_virtualizer + +type snapshot = int * float + +type t = { + root : W.Element.t; + mounted : (int, W.Element.t) Hashtbl.t; + positions : (int, int) Hashtbl.t; + virtualizer : V.t; + mutable pending : snapshot option; +} + +let create root mounted positions virtualizer = + { root; mounted; positions; virtualizer; pending = None } + +let capture t = + let viewport = W.Element.getBoundingClientRect t.root in + Hashtbl.fold + (fun id wrapper current -> + let bounds = W.Element.getBoundingClientRect wrapper in + let top = W.DomRect.top bounds in + if + W.DomRect.bottom bounds <= W.DomRect.top viewport + || top >= W.DomRect.bottom viewport + then current + else + let candidate = (id, top -. W.DomRect.top viewport) in + match current with + | Some (_, offset) when offset <= snd candidate -> current + | _ -> Some candidate) + t.mounted None + +let preserve t snapshot = t.pending <- snapshot +let cancel t = t.pending <- None + +let restore t ~scrolled = + match t.pending with + | None -> () + | Some (id, offset) -> ( + match Hashtbl.find_opt t.positions id with + | None -> cancel t + | Some index -> ( + match Hashtbl.find_opt t.mounted id with + | None -> + V.scroll_to_index t.virtualizer index + (V.scroll_to_index_options ~align:"start" ~behavior:"auto") + | Some wrapper -> + let delta = + W.DomRect.top (W.Element.getBoundingClientRect wrapper) + -. W.DomRect.top (W.Element.getBoundingClientRect t.root) + -. offset + in + if abs_float delta > 0.5 then begin + let destination = W.Element.scrollTop t.root +. delta in + scrolled destination; + W.Element.setScrollTop t.root destination + end)) diff --git a/platform/web/melange/widgets/lui_web_virtual_anchor.mli b/platform/web/melange/widgets/lui_web_virtual_anchor.mli new file mode 100644 index 00000000..fc55da82 --- /dev/null +++ b/platform/web/melange/widgets/lui_web_virtual_anchor.mli @@ -0,0 +1,16 @@ +type t +(** Retained-row reading position for a prepended virtual list. *) + +type snapshot + +val create : + Webapi.Dom.Element.t -> + (int, Webapi.Dom.Element.t) Hashtbl.t -> + (int, int) Hashtbl.t -> + Lui_web_virtualizer.t -> + t + +val capture : t -> snapshot option +val preserve : t -> snapshot option -> unit +val cancel : t -> unit +val restore : t -> scrolled:(float -> unit) -> unit diff --git a/platform/web/test/fixtures/virtual-prepend-regression.html b/platform/web/test/fixtures/virtual-prepend-regression.html new file mode 100644 index 00000000..0914bbda --- /dev/null +++ b/platform/web/test/fixtures/virtual-prepend-regression.html @@ -0,0 +1,76 @@ + + + +