Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 39 additions & 0 deletions platform/web/melange/widgets/lui_web_virtual.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, _, _, _, _) ->
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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 ();
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions platform/web/melange/widgets/lui_web_virtual_anchor.ml
Original file line number Diff line number Diff line change
@@ -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))
16 changes: 16 additions & 0 deletions platform/web/melange/widgets/lui_web_virtual_anchor.mli
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions platform/web/test/fixtures/virtual-prepend-regression.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>LUI virtual prepend regressions</title>
<link rel="stylesheet" href="/platform/web/dist/lui.css">
<script type="importmap">{"imports":{
"@tanstack/virtual-core": "/platform/web/dist/tanstack-virtual.js",
"lui.web.dom/":"/_build/default/examples/components/web/lui-components-web/node_modules/lui.web.dom/",
"lui/":"/_build/default/examples/components/web/lui-components-web/node_modules/lui/",
"melange-webapi/":"/_build/default/examples/components/web/lui-components-web/node_modules/melange-webapi/",
"melange.dom/":"/_build/default/examples/components/web/lui-components-web/node_modules/melange.dom/",
"melange.js/":"/_build/default/examples/components/web/lui-components-web/node_modules/melange.js/",
"melange/":"/_build/default/examples/components/web/lui-components-web/node_modules/melange/",
"ocaml-signal/":"/_build/default/examples/components/web/lui-components-web/node_modules/ocaml-signal/"
}}</script>
<style>body { margin: 24px; font: 16px system-ui } #host { max-width: 720px }</style>
<main id="host"></main>
<script type="module">
import * as Web from "lui.web.dom/shell/lui_web.js"
import { Protocol, properties, kinds } from "./protocol-fixture.mjs"

const host = document.querySelector("#host")
const renderer = Web.create(undefined, host), backend = Web.backend(renderer)
const list = items => items.reduceRight((tl, hd) => ({ hd, tl }), 0)
let ops = [], generation = 0, id = 0, token = 0, first = 100, last = 199

function set(node, name, value) {
const tag = typeof value === "string" ? 0 : typeof value === "boolean" ? 1 : 2
ops.push(Protocol.set_prop_op(node, properties.get(name), { TAG: tag, _0: value }))
}
function node(kind, props = {}) {
const result = ++id
ops.push(Protocol.create_node_op(result, kinds.get(kind)))
for (const [key, value] of Object.entries(props)) set(result, key, value)
return result
}
function flush() {
backend.apply_batch({ generation: ++generation, ops: list(ops) })
ops = []
}
const root = node("virtual-list", { width: 720, height: 420, gap: 4 })
function row(index, at) {
// Actual row geometry deliberately differs from the virtual size estimate.
const height = index % 3 === 0 ? 160 : 80
const item = node("list-item", {
key: "row-" + index,
"data-attrs": `data-row\x1f${index}\x1estyle\x1fmin-height: ${height}px`,
})
const text = node("text", { text: "Reading row " + index })
ops.push(Protocol.insert_child_op(item, text, 0), Protocol.insert_child_op(root, item, at))
}
for (let i = first; i <= last; i++) row(i, i - first)
flush()
Web.mount(renderer, root, host)

window.probe = {
prepend(count) {
const start = first - count
for (let i = start; i < first; i++) row(i, i - start)
first = start
flush()
},
append() {
last++
row(last, last - first)
flush()
},
jump(index) {
set(root, "scroll-target", "row-" + index)
set(root, "scroll-anchor", "top")
set(root, "scroll-animated", false)
set(root, "scroll-token", ++token)
flush()
},
}
</script>
Loading
Loading