Skip to content

Commit fb3d25e

Browse files
authored
fix(painter-dom): prevent scroll acceleration feedback loop in virtualization (#2291)
* fix(painter-dom): prevent scroll acceleration feedback loop in virtualization The virtual window update caused uncontrollable scroll acceleration in long documents when an external scroll container handled scrolling. The root cause was a positive feedback loop between spacer DOM mutations and browser scroll anchoring. The browser picked virtualPagesEl as the scroll anchor. When the top spacer grew during window shifts, virtualPagesEl moved down, and scroll anchoring adjusted scrollTop to compensate. This fired a new scroll event with a higher scrollY, triggering another window shift, cascading to the end of the document. Fix: set overflow-anchor: none on virtualPagesEl so the browser anchors on page elements (children) instead. Page elements stay at stable positions regardless of spacer changes, so scroll anchoring produces zero adjustment. Also stabilize the scrollY calculation for external scroll containers by caching the mount-to-container offset and using scrollTop instead of getBoundingClientRect, which is susceptible to layout shifts during DOM mutations. * test(behavior): add scroll acceleration regression tests Adds 8 Playwright behavior tests covering scroll virtualization stability: - Incremental scroll does not jump ahead uncontrollably - Scroll to mid-document shows correct pages (not start/end) - Rapid small scrolls do not cause runaway to bottom - Virtual window shift preserves scroll position stability - Scroll stability with comments enabled (different DOM layout) - Scroll stability at 75% and 150% zoom levels All tests pass across Chromium, Firefox, and WebKit. * test(behavior): add comments: disabled config and scroll tests for SD-2005 Extends the behavior test harness to support comments='disabled', which sets modules: { comments: false } on the SuperDoc config. This matches the exact customer configuration that triggered the scroll acceleration bug (SD-2005). Adds 3 new test variants exercising scroll stability with comments explicitly disabled, including incremental scroll, rapid scroll, and 75% zoom. All 33 tests pass across Chromium, Firefox, and WebKit. * refactor: simplify scroll acceleration tests and fix review findings - Extract shared test patterns (assertIncrementalScrollStable, assertRapidScrollStable, assertScrollStableAtZoom) to eliminate copy-paste across describe blocks - Split getScrollInfo into separate setScrollTop (write) and getScrollInfo (read-only) to avoid action-query conflation - Extract getPageHeight helper (was duplicated 7x inline) - Replace test.extend<{}>({}) with test.use() inside describe blocks - Use waitForStable(50) instead of page.waitForTimeout(50) - Remove redundant instanceof HTMLElement guard in hot path - Remove as-any cast in harness (modules is already on SuperDocConfig) * fix: drop duplicate scrollContainerMountOffset reset (already in resetState)
1 parent eb48bf1 commit fb3d25e

5 files changed

Lines changed: 358 additions & 74 deletions

File tree

packages/layout-engine/painters/dom/src/renderer.ts

Lines changed: 91 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,96 @@
11
import type {
2+
CustomGeometryData,
3+
DrawingBlock,
4+
DrawingFragment,
5+
DrawingGeometry,
6+
DropCapDescriptor,
7+
FieldAnnotationRun,
28
FlowBlock,
9+
FlowMode,
10+
FlowRunLink,
311
Fragment,
12+
GradientFill,
13+
ImageBlock,
14+
ImageDrawing,
15+
ImageFragment,
16+
ImageRun,
417
Layout,
18+
Line,
19+
LineSegment,
20+
ListBlock,
21+
ListItemFragment,
22+
ListMeasure,
523
Measure,
624
Page,
725
PageMargins,
826
ParaFragment,
9-
ImageFragment,
10-
DrawingFragment,
11-
Run,
12-
TextRun,
13-
ImageRun,
14-
FieldAnnotationRun,
15-
Line,
16-
LineSegment,
17-
ParagraphBlock,
18-
ParagraphMeasure,
19-
ImageBlock,
20-
ImageDrawing,
2127
ParagraphAttrs,
28+
ParagraphBlock,
2229
ParagraphBorder,
23-
ListItemFragment,
24-
ListBlock,
25-
ListMeasure,
30+
ParagraphMeasure,
31+
PositionedDrawingGeometry,
32+
PositionMapping,
33+
Run,
34+
SdtMetadata,
35+
ShapeGroupChild,
36+
ShapeGroupDrawing,
37+
ShapeTextContent,
38+
SolidFillWithAlpha,
39+
TableAttrs,
2640
TableBlock,
41+
TableCellAttrs,
2742
TableFragment,
43+
TextRun,
2844
TrackedChangeKind,
2945
TrackedChangesMode,
30-
SdtMetadata,
31-
DrawingBlock,
3246
VectorShapeDrawing,
33-
ShapeGroupDrawing,
34-
ShapeGroupChild,
35-
DrawingGeometry,
36-
PositionedDrawingGeometry,
3747
VectorShapeStyle,
38-
FlowRunLink,
39-
GradientFill,
40-
SolidFillWithAlpha,
41-
ShapeTextContent,
42-
DropCapDescriptor,
43-
TableAttrs,
44-
TableCellAttrs,
45-
PositionMapping,
46-
FlowMode,
47-
CustomGeometryData,
4848
} from '@superdoc/contracts';
4949
import { calculateJustifySpacing, computeLinePmRange, shouldApplyJustify, SPACE_CHARS } from '@superdoc/contracts';
50+
import { toCssFontFamily } from '@superdoc/font-utils';
5051
import { getPresetShapeSvg } from '@superdoc/preset-geometry';
51-
import { applyGradientToSVG, applyAlphaToSVG, validateHexColor } from './svg-utils.js';
52+
import { encodeTooltip, sanitizeHref } from '@superdoc/url-validation';
53+
import { DOM_CLASS_NAMES } from './constants.js';
54+
import {
55+
getRunBooleanProp,
56+
getRunNumberProp,
57+
getRunStringProp,
58+
getRunUnderlineColor,
59+
getRunUnderlineStyle,
60+
hashCellBorders,
61+
hashParagraphBorders,
62+
hashTableBorders,
63+
} from './paragraph-hash-utils.js';
64+
import { assertFragmentPmPositions, assertPmPositions } from './pm-position-validation.js';
65+
import { createRulerElement, ensureRulerStyles, generateRulerDefinitionFromPx } from './ruler/index.js';
5266
import {
5367
CLASS_NAMES,
5468
containerStyles,
5569
containerStylesHorizontal,
56-
spreadStyles,
57-
fragmentStyles,
58-
lineStyles,
59-
pageStyles,
60-
ensurePrintStyles,
61-
ensureLinkStyles,
62-
ensureTrackChangeStyles,
63-
ensureSdtContainerStyles,
6470
ensureFieldAnnotationStyles,
6571
ensureImageSelectionStyles,
72+
ensureLinkStyles,
6673
ensureNativeSelectionStyles,
74+
ensurePrintStyles,
75+
ensureSdtContainerStyles,
76+
ensureTrackChangeStyles,
77+
fragmentStyles,
78+
lineStyles,
79+
pageStyles,
80+
spreadStyles,
6781
type PageStyles,
6882
} from './styles.js';
69-
import { DOM_CLASS_NAMES } from './constants.js';
70-
import { sanitizeHref, encodeTooltip } from '@superdoc/url-validation';
83+
import { applyAlphaToSVG, applyGradientToSVG, validateHexColor } from './svg-utils.js';
7184
import { renderTableFragment as renderTableFragmentElement } from './table/renderTableFragment.js';
72-
import { assertPmPositions, assertFragmentPmPositions } from './pm-position-validation.js';
7385
import { applyImageClipPath } from './utils/image-clip-path.js';
86+
import { computeTabWidth } from './utils/marker-helpers.js';
7487
import {
7588
applySdtContainerStyling,
7689
getSdtContainerKey,
7790
shouldRebuildForSdtBoundary,
7891
type SdtBoundaryOptions,
7992
} from './utils/sdt-helpers.js';
8093
import { SdtGroupedHover } from './utils/sdt-hover.js';
81-
import { computeTabWidth } from './utils/marker-helpers.js';
82-
import { generateRulerDefinitionFromPx, createRulerElement, ensureRulerStyles } from './ruler/index.js';
83-
import { toCssFontFamily } from '@superdoc/font-utils';
84-
import {
85-
hashParagraphBorders,
86-
hashTableBorders,
87-
hashCellBorders,
88-
getRunStringProp,
89-
getRunNumberProp,
90-
getRunBooleanProp,
91-
getRunUnderlineStyle,
92-
getRunUnderlineColor,
93-
} from './paragraph-hash-utils.js';
9494

9595
/**
9696
* Minimal type for WordParagraphLayoutOutput marker data used in rendering.
@@ -1041,6 +1041,12 @@ export class DomPainter {
10411041
* wrapper div that owns scrolling rather than the window.
10421042
*/
10431043
private scrollContainer: HTMLElement | null = null;
1044+
/**
1045+
* Cached offset (in px) from the scroll container's content top to the mount's top.
1046+
* Used for stable scrollY calculation that avoids feedback loops from spacer DOM mutations.
1047+
* Invalidated when the mount, scroll container, or zoom changes.
1048+
*/
1049+
private scrollContainerMountOffset: number | null = null;
10441050
private sdtHover = new SdtGroupedHover();
10451051
/** The currently active/selected comment ID for highlighting */
10461052
private activeCommentId: string | null = null;
@@ -1114,6 +1120,7 @@ export class DomPainter {
11141120
const next = typeof zoom === 'number' && Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
11151121
if (next !== this.zoomFactor) {
11161122
this.zoomFactor = next;
1123+
this.scrollContainerMountOffset = null; // Invalidate on zoom change
11171124
if (this.virtualEnabled && this.mount) {
11181125
this.updateVirtualWindow();
11191126
}
@@ -1137,6 +1144,7 @@ export class DomPainter {
11371144
public setScrollContainer(el: HTMLElement | null): void {
11381145
if (el !== this.scrollContainer) {
11391146
this.scrollContainer = el;
1147+
this.scrollContainerMountOffset = null; // Invalidate cached offset
11401148
if (this.virtualEnabled && this.mount) {
11411149
this.updateVirtualWindow();
11421150
}
@@ -1576,6 +1584,14 @@ export class DomPainter {
15761584
this.virtualPagesEl.style.alignItems = 'center';
15771585
this.virtualPagesEl.style.width = '100%';
15781586
this.virtualPagesEl.style.gap = `${this.virtualGap}px`;
1587+
// Prevent the browser from using this container as a scroll anchor.
1588+
// When the top spacer grows during virtual window shifts, this element
1589+
// moves down. If the browser anchors on it, scroll anchoring adjusts
1590+
// scrollTop to compensate, which fires a new scroll event with a higher
1591+
// scrollY, triggering another window shift — a positive feedback loop.
1592+
// With this set, the browser anchors on page elements (children) instead,
1593+
// which stay at stable positions regardless of spacer changes.
1594+
this.virtualPagesEl.style.overflowAnchor = 'none';
15791595

15801596
mount.appendChild(this.topSpacerEl);
15811597
mount.appendChild(this.virtualPagesEl);
@@ -1589,6 +1605,10 @@ export class DomPainter {
15891605
element.style.width = '1px';
15901606
element.style.height = '0px';
15911607
element.style.flex = '0 0 auto';
1608+
// Prevent Chrome's scroll anchoring from using spacers as anchor nodes.
1609+
// When spacer heights change during virtual window shifts, scroll anchoring
1610+
// could adjust scrollTop and trigger cascading scroll events.
1611+
element.style.overflowAnchor = 'none';
15921612
element.setAttribute('data-virtual-spacer', type);
15931613
}
15941614

@@ -1618,6 +1638,7 @@ export class DomPainter {
16181638
win.removeEventListener('resize', this.onResizeHandler);
16191639
}
16201640
this.onResizeHandler = () => {
1641+
this.scrollContainerMountOffset = null; // Recompute on resize
16211642
this.updateVirtualWindow();
16221643
};
16231644
win.addEventListener('resize', this.onResizeHandler);
@@ -1682,6 +1703,7 @@ export class DomPainter {
16821703
if (!this.mount || !this.topSpacerEl || !this.bottomSpacerEl || !this.virtualPagesEl || !this.currentLayout) return;
16831704
const layout = this.currentLayout;
16841705
const N = layout.pages.length;
1706+
16851707
if (N === 0) {
16861708
this.mount.innerHTML = '';
16871709
this.processedLayoutVersion = this.layoutVersion;
@@ -1700,13 +1722,18 @@ export class DomPainter {
17001722
scrollY = Math.max(0, this.mount.scrollTop - paddingTop);
17011723
} else if (this.scrollContainer) {
17021724
// Intermediate scroll ancestor (e.g., a wrapper div with overflow-y: auto).
1703-
// Compute scrollY relative to the scroll container, not the browser viewport.
1704-
// Using (scrollContainer.rect.top - mount.rect.top) instead of just (-mount.rect.top)
1705-
// eliminates the offset caused by the scroll container's distance from the viewport top
1706-
// (e.g., a toolbar/header above the scroll wrapper).
1707-
const mountRect = this.mount.getBoundingClientRect();
1708-
const containerRect = this.scrollContainer.getBoundingClientRect();
1709-
scrollY = Math.max(0, (containerRect.top - mountRect.top) / zoom - paddingTop);
1725+
// Use scrollContainer.scrollTop with a cached mount offset instead of
1726+
// getBoundingClientRect(). Rects are affected by spacer DOM mutations
1727+
// which can cause cascading scroll events and runaway scrolling.
1728+
//
1729+
// mountOffset = distance from scroll container's content top to mount's top.
1730+
// Computed once and cached; invalidated on mount/container/zoom change.
1731+
if (this.scrollContainerMountOffset == null) {
1732+
const mountRect = this.mount.getBoundingClientRect();
1733+
const containerRect = this.scrollContainer.getBoundingClientRect();
1734+
this.scrollContainerMountOffset = mountRect.top - containerRect.top + this.scrollContainer.scrollTop;
1735+
}
1736+
scrollY = Math.max(0, (this.scrollContainer.scrollTop - this.scrollContainerMountOffset) / zoom - paddingTop);
17101737
} else {
17111738
const rect = this.mount.getBoundingClientRect();
17121739
// rect.top is in screen space (affected by CSS transform: scale).
@@ -2210,6 +2237,7 @@ export class DomPainter {
22102237
this.onScrollHandler = null;
22112238
this.onWindowScrollHandler = null;
22122239
this.onResizeHandler = null;
2240+
this.scrollContainerMountOffset = null;
22132241
this.sdtHover.destroy();
22142242
this.layoutVersion = 0;
22152243
this.processedLayoutVersion = -1;

packages/layout-engine/painters/dom/src/virtualization.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -487,19 +487,19 @@ describe('DomPainter virtualization (vertical)', () => {
487487
const pagesWithout = mount.querySelectorAll('.superdoc-page');
488488
const indicesWithout = Array.from(pagesWithout).map((p) => Number((p as HTMLElement).dataset.pageIndex));
489489

490-
// WITH scroll container: scrollY = (100 - (-4900)) / 0.75 = 6666
491-
// That's different from the without case — but the key point is:
492-
// the offset from the viewport (toolbarHeight) is eliminated from the calculation.
493-
// scrollY = (containerRect.top - mountRect.top) / zoom = (100 - (-4900)) / 0.75 = 6666
490+
// WITH scroll container: uses scrollTop-based calculation.
491+
// offset = mountRect.top - containerRect.top + scrollTop = -4900 - 100 + 5000 = 0
492+
// scrollY = (5000 - 0) / 0.75 = 6666
493+
Object.defineProperty(scrollWrapper, 'scrollTop', { value: scrollTop, writable: true, configurable: true });
494494
painter.setScrollContainer!(scrollWrapper);
495495
painter.onScroll!();
496496
const pagesWith = mount.querySelectorAll('.superdoc-page');
497497
const indicesWith = Array.from(pagesWith).map((p) => Number((p as HTMLElement).dataset.pageIndex));
498498

499-
// Now simulate at scroll=0 to see the offset difference clearly.
500-
// Without container: mount.rect.top = 100, scrollY = max(0, -100/0.75) = 0 ← correct by luck (clamped)
501-
// But at small scroll (e.g., scroll=50): mount.rect.top = 50, scrollY = -50/0.75 = -66 → 0 ← WRONG, should be ~66
502-
// With container: scrollY = (100 - 50) / 0.75 = 66 ← CORRECT
499+
// Now simulate small scroll (scrollTop=150) to see the offset difference clearly.
500+
// The cached offset (0) stays valid.
501+
// scrollY = (150 - 0) / 0.75 = 200 in layout space
502+
// Anchor at page 0 (topOfIndex(0)=0, topOfIndex(1)=572), so pages [0,1,2]
503503
const smallScroll = 150;
504504
const mountTopSmall = toolbarHeight - smallScroll; // 100 - 150 = -50
505505

@@ -516,11 +516,11 @@ describe('DomPainter virtualization (vertical)', () => {
516516
toJSON() {},
517517
}) as DOMRect;
518518

519+
// Set scrollTop to match the simulated scroll position
520+
Object.defineProperty(scrollWrapper, 'scrollTop', { value: smallScroll, writable: true, configurable: true });
519521
painter.onScroll!();
520522
const pagesSmallScroll = mount.querySelectorAll('.superdoc-page');
521523
const indicesSmallScroll = Array.from(pagesSmallScroll).map((p) => Number((p as HTMLElement).dataset.pageIndex));
522-
// scrollY = (100 - (-50)) / 0.75 = 200 in layout space
523-
// Anchor at page 0 (topOfIndex(0)=0, topOfIndex(1)=572), so pages [0,1,2]
524524
expect(indicesSmallScroll).toEqual([0, 1, 2]);
525525

526526
// Verify: remove scroll container and the same scroll position gives different result

tests/behavior/fixtures/superdoc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const HARNESS_URL = 'http://localhost:9990';
99
interface HarnessConfig {
1010
layout?: boolean;
1111
toolbar?: 'none' | 'full';
12-
comments?: 'off' | 'on' | 'panel' | 'readonly';
12+
comments?: 'off' | 'on' | 'panel' | 'readonly' | 'disabled';
1313
trackChanges?: boolean;
1414
showCaret?: boolean;
1515
showSelection?: boolean;

tests/behavior/harness/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ function init(file?: File, content?: ContentOverrideInput) {
9595
config.comments = { visible: true };
9696
} else if (comments === 'readonly') {
9797
config.comments = { visible: true, readOnly: true };
98+
} else if (comments === 'disabled') {
99+
// Explicitly disable the comments module (modules: { comments: false }).
100+
// This matches the customer config pattern that triggers different scroll behavior.
101+
config.modules = { ...(config.modules ?? {}), comments: false };
98102
}
99103

100104
// Track changes

0 commit comments

Comments
 (0)