From 930f30eb70a147f7d99881b04afffcdba2406794 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 2 Jul 2026 20:49:52 +0100 Subject: [PATCH 1/4] fix(render): disable browser scroll anchoring on the table holder Chrome's scroll anchoring adjusts scrollTop when the virtual renderer inserts rows above the viewport, double-compensating against the renderer's own paddingTop/scrollTop management and causing drift on scroll-up. Set overflow-anchor:none on .tabulator-tableholder, the standard approach for JS-managed virtual scrollers. --- src/scss/tabulator.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scss/tabulator.scss b/src/scss/tabulator.scss index 0eb30ecc1..e06e68851 100644 --- a/src/scss/tabulator.scss +++ b/src/scss/tabulator.scss @@ -440,6 +440,7 @@ $rangeHeaderTextHighlightBackground: #000000 !default; //header text color when width:100%; white-space: nowrap; overflow:auto; + overflow-anchor: none; -webkit-overflow-scrolling: touch; &:focus{ From dcb0e0e3a7b3ad827023d69a8bbf0afe5d730ea7 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:29:56 +0100 Subject: [PATCH 2/4] test(render): assert table holder disables scroll anchoring e2e guard that .tabulator-tableholder computes overflow-anchor:none, so the scroll-anchoring fix can't silently regress out of the built CSS. --- test/e2e/overflow-anchor.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 test/e2e/overflow-anchor.spec.ts diff --git a/test/e2e/overflow-anchor.spec.ts b/test/e2e/overflow-anchor.spec.ts new file mode 100644 index 000000000..7c4623fd7 --- /dev/null +++ b/test/e2e/overflow-anchor.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from "@playwright/test"; +import { join } from "path"; + +// Regression coverage for disabling browser scroll anchoring on the table +// holder. The virtual renderer manages scrollTop/padding itself; Chrome's +// scroll anchoring double-compensates when rows are inserted above the +// viewport, causing drift on scroll-up. The fix sets overflow-anchor:none on +// .tabulator-tableholder. This guards that the rule survives in the built CSS +// and is applied by the browser. +test.describe("table holder disables browser scroll anchoring", () => { + test("computed overflow-anchor is none", async ({ page }) => { + await page.goto(`file://${join(__dirname, "scroll-jump.html")}`); + await page.waitForSelector(".tabulator-tableholder"); + + const value = await page + .locator(".tabulator-tableholder") + .evaluate((el) => getComputedStyle(el).overflowAnchor); + + expect(value).toBe("none"); + }); +}); From 578c0ad39902dd54fc13126d26cfd0857c405559 Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:09:29 +0100 Subject: [PATCH 3/4] test(e2e): use a step smaller than the viewport in the grouped scroll-jump case --- test/e2e/scroll-jump.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/e2e/scroll-jump.spec.ts b/test/e2e/scroll-jump.spec.ts index 9467f663f..ec26a4aab 100644 --- a/test/e2e/scroll-jump.spec.ts +++ b/test/e2e/scroll-jump.spec.ts @@ -218,9 +218,12 @@ test.describe("Vertical scroll jumping with grouped variable height rows (#3654) await page.waitForTimeout(50); // Track data rows only, never the group header rows. + // The step must stay well under the ~275px viewport: a step that turns + // over the whole rendered window leaves no row present both before and + // after the scroll, so nothing can be measured. const jumps = await scrollUpAndMeasure( page, - 250, + 80, 40, ".tabulator-row:not(.tabulator-group)", ); From db2e3d8f775c7d7d3dc4b36588fb0b0e35b44eef Mon Sep 17 00:00:00 2001 From: Luke Cotter <4013877+lukecotter@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:27:16 +0100 Subject: [PATCH 4/4] test(e2e): measure the scroll jump against the requested distance --- test/e2e/scroll-jump.spec.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/test/e2e/scroll-jump.spec.ts b/test/e2e/scroll-jump.spec.ts index ec26a4aab..9af411cec 100644 --- a/test/e2e/scroll-jump.spec.ts +++ b/test/e2e/scroll-jump.spec.ts @@ -21,12 +21,16 @@ import { join } from "path"; // HOW THE JUMP IS MEASURED // When you scroll up by D pixels the visible content must move DOWN by exactly // D. We track one identifiable row element across a scroll-up step and compare -// how far it moved on screen ("moved") with how far the holder actually -// scrolled ("scrolled"). For a correct virtual DOM the row is glued to the -// content so moved === scrolled; "jump = moved - scrolled" is therefore the -// number of pixels the content shifted underneath the scroll position. This -// metric is independent of whether scrollTop itself is trustworthy (it isn't, -// once the bug fires). +// how far it moved on screen ("moved") with the D we asked for, so +// "jump = moved - requested" is the number of pixels the content shifted that +// the user did not ask for. +// +// We deliberately do NOT compare against the holder's own scrollTop delta. +// A virtual renderer revises its total scrollHeight as it measures real row +// heights, and scrollTop is measured from the top of that changing document, so +// it shifts by the height correction while nothing on screen moves at all. That +// makes "moved - scrolled" report a jump for a view that is perfectly steady. +// The requested delta has no such term. // // The companion "gentle scrolling" test below demonstrates that this metric // reads ~0 for well-behaved scrolling, so a large reading is a real jump. @@ -133,7 +137,7 @@ async function scrollUpAndMeasure( id: beforeScrolling.id, moved: Math.round(moved), scrolled: Math.round(scrolled), - jump: Math.round(moved - scrolled), + jump: Math.round(moved - delta), }); } } @@ -169,9 +173,8 @@ test.describe("Vertical scroll jumping with variable height rows (#3654)", () => // Sanity: the scroll-up actually moved content. expect(jumps.length).toBeGreaterThan(0); - // The tracked row should stay glued to the content (move by exactly the - // scroll distance, give or take sub-pixel rounding). A larger reading - // is the #3654 jump. + // The tracked row should move by exactly the distance we scrolled up, + // give or take sub-pixel rounding. A larger reading is the #3654 jump. expect(worstJump).toBeLessThanOrEqual(20); });