Skip to content

Commit 1e075f6

Browse files
authored
fix(painter-dom): skip non-scrollable scroll container in virtualization (SD-2199) (#2383)
Only first 7 pages rendered on documents with 8+ pages when SuperDoc was mounted inside an unconstrained flex layout. The scroll container detection found an ancestor with overflow:auto CSS but that element was never actually scrollable because its parent only had min-height (no height constraint). The element grew to fit content instead of constraining it, so scrollTop stayed at 0 and the virtual window never advanced beyond pages 0-6. The fix adds a runtime check in updateVirtualWindow() that verifies the scroll container is actually scrollable (scrollHeight > clientHeight) before using its scrollTop. When the container is not scrollable, it falls through to the viewport-based getBoundingClientRect calculation which works correctly with window-level scrolling.
1 parent 3e74426 commit 1e075f6

2 files changed

Lines changed: 72 additions & 4 deletions

File tree

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1799,9 +1799,16 @@ export class DomPainter {
17991799
const zoom = this.zoomFactor;
18001800
let scrollY: number;
18011801
const isContainerScrollable = this.mount.scrollHeight > this.mount.clientHeight + 1;
1802+
// Check if the external scroll container is actually scrollable (content overflows its
1803+
// visible area). An element can have overflow:auto but still not scroll if it's in an
1804+
// unconstrained flex layout where the parent has only min-height (no height). In that
1805+
// case the element grows to fit content and scrollTop stays 0 — fall through to the
1806+
// viewport-based calculation instead.
1807+
const scrollCont = this.scrollContainer;
1808+
const isScrollContainerActive = scrollCont != null && scrollCont.scrollHeight > scrollCont.clientHeight + 1;
18021809
if (isContainerScrollable) {
18031810
scrollY = Math.max(0, this.mount.scrollTop - paddingTop);
1804-
} else if (this.scrollContainer) {
1811+
} else if (isScrollContainerActive) {
18051812
// Intermediate scroll ancestor (e.g., a wrapper div with overflow-y: auto).
18061813
// Use scrollContainer.scrollTop with a cached mount offset instead of
18071814
// getBoundingClientRect(). Rects are affected by spacer DOM mutations
@@ -1811,10 +1818,10 @@ export class DomPainter {
18111818
// Computed once and cached; invalidated on mount/container/zoom change.
18121819
if (this.scrollContainerMountOffset == null) {
18131820
const mountRect = this.mount.getBoundingClientRect();
1814-
const containerRect = this.scrollContainer.getBoundingClientRect();
1815-
this.scrollContainerMountOffset = mountRect.top - containerRect.top + this.scrollContainer.scrollTop;
1821+
const containerRect = scrollCont.getBoundingClientRect();
1822+
this.scrollContainerMountOffset = mountRect.top - containerRect.top + scrollCont.scrollTop;
18161823
}
1817-
scrollY = Math.max(0, (this.scrollContainer.scrollTop - this.scrollContainerMountOffset) / zoom - paddingTop);
1824+
scrollY = Math.max(0, (scrollCont.scrollTop - this.scrollContainerMountOffset) / zoom - paddingTop);
18181825
} else {
18191826
const rect = this.mount.getBoundingClientRect();
18201827
// rect.top is in screen space (affected by CSS transform: scale).

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,65 @@ describe('DomPainter virtualization (vertical)', () => {
686686
expect(mount.querySelector('.superdoc-page-header')).toBeNull();
687687
expect(mount.querySelector('.superdoc-page-footer')).toBeNull();
688688
});
689+
690+
it('falls through to viewport-based calculation when scroll container is not actually scrollable (SD-2199)', () => {
691+
// Reproduces the version tester bug: a wrapper has overflow:auto but is in an
692+
// unconstrained flex layout (parent has only min-height, no height). The wrapper
693+
// grows to fit content and scrollTop stays 0, so the scroll container branch
694+
// must fall through to the viewport-based getBoundingClientRect path.
695+
const pageCount = 20;
696+
const painter = createDomPainter({
697+
blocks: [block],
698+
measures: [measure],
699+
virtualization: { enabled: true, window: 5, overscan: 1, gap: 72, paddingTop: 0 },
700+
});
701+
702+
const layout = makeLayout(pageCount);
703+
painter.paint(layout, mount);
704+
705+
// Mount itself is not scrollable
706+
Object.defineProperty(mount, 'scrollHeight', { value: 100, configurable: true });
707+
Object.defineProperty(mount, 'clientHeight', { value: 600, configurable: true });
708+
709+
// Create a scroll container that has overflow:auto CSS but is NOT actually
710+
// scrollable (scrollHeight == clientHeight, like an unconstrained flex child).
711+
const scrollWrapper = document.createElement('div');
712+
Object.defineProperty(scrollWrapper, 'scrollHeight', { value: 8000, configurable: true });
713+
Object.defineProperty(scrollWrapper, 'clientHeight', { value: 8000, configurable: true });
714+
Object.defineProperty(scrollWrapper, 'scrollTop', { value: 0, writable: true, configurable: true });
715+
scrollWrapper.getBoundingClientRect = () =>
716+
({ top: 0, left: 0, right: 400, bottom: 8000, width: 400, height: 8000, x: 0, y: 0, toJSON() {} }) as DOMRect;
717+
718+
painter.setScrollContainer!(scrollWrapper);
719+
720+
// Simulate window scroll: mount's rect.top becomes negative as user scrolls
721+
const layoutScrollTarget = 5000;
722+
mount.getBoundingClientRect = () =>
723+
({
724+
top: -layoutScrollTarget,
725+
left: 0,
726+
right: 400,
727+
bottom: 600 - layoutScrollTarget,
728+
width: 400,
729+
height: 600,
730+
x: 0,
731+
y: -layoutScrollTarget,
732+
toJSON() {},
733+
}) as DOMRect;
734+
735+
painter.onScroll!();
736+
737+
// With the fix, the non-scrollable scroll container is bypassed and
738+
// getBoundingClientRect is used: scrollY = -(-5000) / 1 = 5000
739+
// At scrollY=5000, anchor is page 8 (topOfIndex(8)=4576, topOfIndex(9)=5148)
740+
// Window = 5, overscan = 1: start = max(0, 8 - 2 - 1) = 5, end = min(19, 5 + 4 + 2) = 11
741+
// Pages: 5,6,7,8,9,10,11
742+
const pages = mount.querySelectorAll('.superdoc-page');
743+
const indices = Array.from(pages).map((p) => Number((p as HTMLElement).dataset.pageIndex));
744+
745+
// Key assertion: pages beyond the initial window (0-6) should be rendered
746+
expect(indices.some((i) => i > 6)).toBe(true);
747+
// Anchor page (8) should be in the rendered set
748+
expect(indices).toContain(8);
749+
});
689750
});

0 commit comments

Comments
 (0)