diff --git a/src/js/core/rendering/Renderer.js b/src/js/core/rendering/Renderer.js
index 5f5824940..e95904c24 100644
--- a/src/js/core/rendering/Renderer.js
+++ b/src/js/core/rendering/Renderer.js
@@ -165,34 +165,35 @@ export default class Renderer extends CoreFeature{
//scroll to row
this.scrollToRow(row);
- //align to correct position
+ //align to correct position. After scrollToRow the target row is
+ //in the rendered window but NOT necessarily at the top, so derive
+ //the alignment from its actual offsetTop rather than assuming the
+ //current scrollTop places it at the top.
switch(position){
case "middle":
case "center":
-
- if(this.elementVertical.scrollHeight - this.elementVertical.scrollTop == this.elementVertical.clientHeight){
- this.elementVertical.scrollTop = this.elementVertical.scrollTop + (rowEl.offsetTop - this.elementVertical.scrollTop) - ((this.elementVertical.scrollHeight - rowEl.offsetTop) / 2);
- }else{
- this.elementVertical.scrollTop = this.elementVertical.scrollTop - (this.elementVertical.clientHeight / 2);
- }
-
+ this.elementVertical.scrollTop = rowEl.offsetTop - (this.elementVertical.clientHeight / 2) + (rowEl.offsetHeight / 2);
break;
case "bottom":
-
- if(this.elementVertical.scrollHeight - this.elementVertical.scrollTop == this.elementVertical.clientHeight){
- this.elementVertical.scrollTop = this.elementVertical.scrollTop - (this.elementVertical.scrollHeight - rowEl.offsetTop) + rowEl.offsetHeight;
- }else{
- this.elementVertical.scrollTop = this.elementVertical.scrollTop - this.elementVertical.clientHeight + rowEl.offsetHeight;
- }
-
+ this.elementVertical.scrollTop = rowEl.offsetTop - this.elementVertical.clientHeight + rowEl.offsetHeight;
break;
case "top":
- this.elementVertical.scrollTop = rowEl.offsetTop;
+ this.elementVertical.scrollTop = rowEl.offsetTop;
break;
}
+ //keep the virtual renderer's scroll trackers in sync with the
+ //manual scrollTop write, so the next user scroll doesn't compute a
+ //large diff against a stale tracker and trigger a spurious full
+ //re-render/jump. Guarded for the basic renderer, which has no
+ //trackers.
+ if(this.vDomScrollPosTop !== undefined){
+ this.vDomScrollPosTop = this.elementVertical.scrollTop;
+ this.vDomScrollPosBottom = this.elementVertical.scrollTop;
+ }
+
resolve();
}else{
diff --git a/test/e2e/scroll-to-row-position.html b/test/e2e/scroll-to-row-position.html
new file mode 100644
index 000000000..fbbe8e113
--- /dev/null
+++ b/test/e2e/scroll-to-row-position.html
@@ -0,0 +1,46 @@
+
+
+
+
+ Tabulator scrollToRow position test
+
+
+
+
+
+
+
+
+
diff --git a/test/e2e/scroll-to-row-position.spec.ts b/test/e2e/scroll-to-row-position.spec.ts
new file mode 100644
index 000000000..69d2a2c6f
--- /dev/null
+++ b/test/e2e/scroll-to-row-position.spec.ts
@@ -0,0 +1,74 @@
+import { test, expect, Page } from "@playwright/test";
+import { join } from "path";
+
+// Regression coverage for scrollToRowPosition alignment.
+//
+// After scrollToRow(row) fills the virtual DOM, the target row is in the window
+// but NOT necessarily at the top. The old center/bottom math derived the offset
+// from the current scrollTop (assuming top placement), so for rows deep in the
+// list it scrolled the wrong distance — the row could land far from the
+// requested position or detach entirely. The fix reads the row's actual
+// offsetTop. It also syncs vDomScrollPosTop/Bottom so a following user scroll
+// doesn't see a stale tracker and jump.
+//
+// Metric: distance (px) between where the target row landed and where the
+// requested position wants it. Lower is better; a large reading is the bug.
+
+// A row near the bottom of the list: after scrollToRow fills the window it
+// cannot sit at the window top (not enough rows below it), so the old math that
+// assumed top placement misaligns it — the documented trigger.
+const TARGET_ID = 1490;
+
+async function landingMiss(page: Page, position: "top" | "center" | "bottom") {
+ await page.evaluate(
+ async ({ id, position }) => {
+ // @ts-expect-error test global
+ await window.testTable.scrollToRow(id, position, true);
+ },
+ { id: TARGET_ID, position },
+ );
+ await page.waitForTimeout(80);
+
+ return page.evaluate(
+ ({ id, position }) => {
+ const holder = document.querySelector(".tabulator-tableholder") as HTMLElement;
+ const rows = [...holder.querySelectorAll(".tabulator-row")];
+ const target = rows.find((r) => {
+ const cell = r.querySelector(".tabulator-cell");
+ return cell && cell.textContent === String(id);
+ });
+ if (!target) {
+ return { found: false as const };
+ }
+ const rr = target.getBoundingClientRect();
+ const hr = holder.getBoundingClientRect();
+ let miss: number;
+ if (position === "center") {
+ miss = Math.abs(rr.top + rr.height / 2 - (hr.top + hr.height / 2));
+ } else if (position === "bottom") {
+ miss = Math.abs(rr.bottom - hr.bottom);
+ } else {
+ miss = Math.abs(rr.top - hr.top);
+ }
+ return { found: true as const, miss: Math.round(miss) };
+ },
+ { id: TARGET_ID, position },
+ );
+}
+
+test.describe("scrollToRow position alignment", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(`file://${join(__dirname, "scroll-to-row-position.html")}`);
+ await page.waitForSelector(".tabulator-tableholder");
+ });
+
+ for (const position of ["top", "center", "bottom"] as const) {
+ test(`scrollToRow lands the target at ${position}`, async ({ page }) => {
+ const res = await landingMiss(page, position);
+ expect(res.found).toBe(true);
+ // The target row (id 750 of 1500, variable heights) must land within a
+ // row-height of the requested position.
+ expect(res.miss).toBeLessThanOrEqual(6);
+ });
+ }
+});