Files
readest/apps/readest-app/src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts
T
Huang Xin db1d63cdcc test(reader): harden fixed-layout wheel double-scroll test against CI flake (#4978)
The readest#4727 regression test set scrollTop=0, dispatched a synthetic
wheel, waited 60ms, then asserted scrollTop stayed 0. On slow CI runners it
flaked with "expected 4 to be +0".

As sibling scroll pages finish loading, the renderer runs
restoreScrollModeAnchor asynchronously, which at scrollTop=0/page-index-0
snaps scrollTop to page 0's offsetTop, the 4px scroll-page-gap margin. The
60ms post-dispatch delay raced that re-anchoring, so the assertion observed 4
instead of 0. That 4 is unrelated to the wheel bug, which is a 120px jump.

The buggy handler was scrollBy with instant behavior, a synchronous scroll
that lands before dispatchEvent returns. Measure scrollTop synchronously
before and after the dispatch with no await in between and assert they match.
This isolates the wheel handler's own effect and is immune to the async
re-anchoring. Reintroducing the bug still fails the test (before=4, after=124,
a clean 120px delta).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 18:28:11 +02:00

94 lines
4.2 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest';
// Registers the <foliate-fxl> custom element (the fixed-layout / PDF renderer).
import 'foliate-js/fixed-layout.js';
// Regression test for readest#4727: in fixed-layout scroll mode, a wheel tick
// that lands on a page iframe (during the brief idle window where the iframe is
// interactive) must NOT be scrolled by JS. The browser already chains the wheel
// to the host scroller natively; if the in-iframe wheel handler also scrolls the
// host, the page travels twice as far in an instant lurch instead of one smooth
// notch.
//
// This needs a real browser: the bug is the interaction between native wheel
// scrolling and a programmatic scrollBy, and jsdom has no layout/scroll. We
// mount the real renderer and assert the JS-side handler does not move the host
// scroller on its own (the synthetic wheel does not trigger native scrolling,
// so any movement we observe is purely the JS handler — which must be zero).
const PAGE_HTML = `<!doctype html><html><head><style>
html, body { margin: 0; height: 1000px; background: linear-gradient(#fff, #ccc); }
</style></head><body></body></html>`;
const makeBook = (sectionCount: number) => ({
dir: 'ltr',
rendition: { viewport: { width: 600, height: 1000 }, spread: 'none' },
sections: Array.from({ length: sectionCount }, () => ({
// `src` only has to be truthy — when `data` is present the renderer loads it
// via srcdoc, which keeps the iframe same-origin so its document (and the
// wheel listener attached to it) is reachable.
load: async () => ({ src: 'srcdoc', data: PAGE_HTML }),
linear: 'yes',
})),
});
const waitFor = async <T>(fn: () => T | null | undefined, timeout = 4000): Promise<T> => {
const start = performance.now();
for (;;) {
const value = fn();
if (value) return value;
if (performance.now() - start > timeout) throw new Error('waitFor timed out');
await new Promise((r) => setTimeout(r, 30));
}
};
let renderer: HTMLElement | null = null;
afterEach(() => {
renderer?.remove();
renderer = null;
});
describe('fixed-layout scroll mode wheel handling (readest#4727)', () => {
it('does not double-scroll the host when a wheel lands on a page', async () => {
renderer = document.createElement('foliate-fxl');
renderer.style.width = '600px';
renderer.style.height = '400px';
renderer.setAttribute('flow', 'scrolled');
document.body.append(renderer);
(renderer as unknown as { open(book: unknown): void }).open(makeBook(3));
// The first page loads via the IntersectionObserver; wait until its iframe
// has rendered (display flipped from 'none') — by then the wheel listener
// that #loadScrollPage attaches to the iframe document is in place.
const iframe = await waitFor(() => {
const f = renderer!.shadowRoot?.querySelector<HTMLIFrameElement>('.scroll-page iframe');
return f && f.style.display && f.style.display !== 'none' && f.contentDocument ? f : null;
});
const scroller = renderer as unknown as HTMLElement;
// A wheel over the page iframe. With the bug, the iframe handler runs
// `host.scrollBy({ top: deltaY, behavior: 'instant' })`, an *instant*
// (synchronous) scroll that lands by ~120px before dispatchEvent() returns.
// With the fix the handler only drops pointer-events and never scrolls.
//
// Measure the scroll position synchronously around dispatchEvent() — with no
// await in between — so we capture only the wheel handler's own effect. Do
// NOT await/settle here: as sibling pages finish loading, the renderer runs
// #restoreScrollModeAnchor asynchronously, which snaps scrollTop to a page's
// offsetTop (the 4px --scroll-page-gap). A post-dispatch delay races that
// re-anchoring and observed scrollTop === 4 instead of 0 on slow CI runners
// (readest CI flake). The buggy scrollBy is synchronous, so a synchronous
// before/after comparison still catches it while being immune to the race.
const before = scroller.scrollTop;
iframe.contentDocument!.dispatchEvent(
new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true }),
);
const after = scroller.scrollTop;
expect(after).toBe(before);
});
});