From 39cfa2955ebd437473a53590a6033c80b71f5e2e Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 14:53:02 +0800 Subject: [PATCH 1/2] fix(web): preserve retained reading anchor across prepends --- .../2026-10-19-virtual-prepend-anchor.md | 63 ++++++++++++ .../web/melange/widgets/lui_web_virtual.ml | 30 ++++++ .../melange/widgets/lui_web_virtual_anchor.ml | 58 +++++++++++ .../widgets/lui_web_virtual_anchor.mli | 16 ++++ .../fixtures/virtual-prepend-regression.html | 76 +++++++++++++++ platform/web/test/virtual-prepend.e2e.mjs | 96 +++++++++++++++++++ 6 files changed, 339 insertions(+) create mode 100644 docs/agent-guide/proposed/bugfix/2026-10-19-virtual-prepend-anchor.md create mode 100644 platform/web/melange/widgets/lui_web_virtual_anchor.ml create mode 100644 platform/web/melange/widgets/lui_web_virtual_anchor.mli create mode 100644 platform/web/test/fixtures/virtual-prepend-regression.html create mode 100644 platform/web/test/virtual-prepend.e2e.mjs 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..9d16ab7f --- /dev/null +++ b/docs/agent-guide/proposed/bugfix/2026-10-19-virtual-prepend-anchor.md @@ -0,0 +1,63 @@ +# 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. diff --git a/platform/web/melange/widgets/lui_web_virtual.ml b/platform/web/melange/widgets/lui_web_virtual.ml index 0f9d35bc..9cb84257 100644 --- a/platform/web/melange/widgets/lui_web_virtual.ml +++ b/platform/web/melange/widgets/lui_web_virtual.ml @@ -157,6 +157,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 +397,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 +417,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 +437,21 @@ 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 must not keep compensating geometry for an older change. *) + 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 -> + 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 +462,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 @@ + + + +LUI virtual prepend regressions + + + +
+ diff --git a/platform/web/test/virtual-prepend.e2e.mjs b/platform/web/test/virtual-prepend.e2e.mjs new file mode 100644 index 00000000..b2b4adeb --- /dev/null +++ b/platform/web/test/virtual-prepend.e2e.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict" +import { once } from "node:events" +import test, { before, after } from "node:test" +import { createStaticServer } from "../../../tooling/serve_web.mjs" +import { createPlaywrightSession } from "./playwright-session.mjs" + +let server, origin, session, page +before(async () => { + session = await createPlaywrightSession() + page = session.page + page.setDefaultTimeout(5000) + server = createStaticServer(new URL("../../../", import.meta.url).pathname) + server.listen(0, "127.0.0.1") + await once(server, "listening") + origin = `http://127.0.0.1:${server.address().port}` +}) +after(async () => { + await session?.close() + if (server?.listening) { + const done = once(server, "close") + server.close() + await done + } +}) +const frames = () => page.evaluate(() => new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve))))) +const viewport = () => page.locator(".lui-virtual-list") +const row = index => page.locator(`[data-row="${index}"]`) + +function aligned(index) { + const item = document.querySelector(`[data-row="${index}"]`) + const root = document.querySelector(".lui-virtual-list") + return item && Math.abs(item.getBoundingClientRect().top - root.getBoundingClientRect().top) < 2 +} + +async function open() { + await page.goto(`${origin}/platform/web/test/fixtures/virtual-prepend-regression.html`) + await page.waitForFunction(() => window.probe) + await page.evaluate(() => window.probe.jump(120)) + await page.waitForFunction(aligned, 120) + await viewport().hover() + await page.mouse.wheel(0, 20) + await frames() +} + +async function visible() { + return viewport().evaluate(root => { + const top = root.getBoundingClientRect().top + const item = [...root.querySelectorAll("[data-row]")] + .find(item => item.getBoundingClientRect().top >= top) + return { index: Number(item.dataset.row), y: item.getBoundingClientRect().top } + }) +} + +async function unchanged(anchor) { + await page.waitForFunction(({ index, y }) => { + const item = document.querySelector(`[data-row="${index}"]`) + return item && Math.abs(item.getBoundingClientRect().top - y) < 2 + }, anchor) + await frames() + assert.ok(Math.abs((await row(anchor.index).boundingBox()).y - anchor.y) < 2) +} + +test("mixed-height prepend restores the same visible retained row and offset", async () => { + await open() + const anchor = await visible() + await page.evaluate(() => window.probe.prepend(63)) + await unchanged(anchor) +}) + +test("repeated prepends preserve reading and later append does not reuse the anchor", async () => { + await open() + const anchor = await visible() + for (const count of [63, 17]) { + await page.evaluate(count => window.probe.prepend(count), count) + await unchanged(anchor) + } + await viewport().hover() + await page.mouse.wheel(0, 200) + await frames() + const moved = await visible() + assert.notEqual(moved.index, anchor.index) + await page.evaluate(() => window.probe.append()) + await unchanged(moved) +}) + +test("an explicit scroll after prepend supersedes its reading anchor", async () => { + await open() + await page.evaluate(() => window.probe.prepend(63)) + await page.evaluate(() => window.probe.jump(180)) + await page.waitForFunction(aligned, 180) + await page.evaluate(() => window.probe.append()) + await frames() + const bounds = await row(180).boundingBox(), root = await viewport().boundingBox() + assert.ok(Math.abs(bounds.y - root.y) < 2) +}) From 8917b1a1664de91ad8aee1c07e20ddfff1e490b7 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Mon, 19 Oct 2026 15:01:15 +0800 Subject: [PATCH 2/2] fix(web): cancel virtual reading anchors on reparent --- .../bugfix/2026-10-19-virtual-prepend-anchor.md | 16 ++++++++++++++++ platform/web/melange/widgets/lui_web_virtual.ml | 13 +++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 index 9d16ab7f..dd033bb0 100644 --- 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 @@ -61,3 +61,19 @@ 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 9cb84257..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 @@ -438,7 +443,11 @@ let install renderer node ~materialize ~unmount = let refresh () = if not !disposed then begin (* Preserve measured retained identity only for a prepend. A later - append must not keep compensating geometry for an older change. *) + 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) @@ -448,7 +457,7 @@ let install renderer node ~materialize ~unmount = 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 -> + | Some index when index > 0 && same_parent -> Lui_web_virtual_anchor.preserve reading_anchor anchor | _ -> () end;