Files
readest/apps/readest-app/src/__tests__/components/BooknoteView.test.tsx
T
Huang Xin 578b7ba14f fix(reader): restore annotation list auto-scroll to the nearest item (#4428)
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the
note nearest the current reading position, leaving the list stranded at the
top. The single scrollToIndex that replaced the per-item useScrollToItem was
missing the machinery TOCView already uses for virtualized auto-scroll:

- Re-apply the scroll inside the OverlayScrollbars `initialized` callback
  (read via a ref): its deferred init resets the viewport scrollTop to 0, and
  the lastScrolledCfiRef guard otherwise blocked any retry (reload case).
- Mount Virtuoso natively centered via initialTopMostItemIndex with a
  skip-gate, so opening the panel while reading doesn't fire a scrollToIndex
  that races and wedges the freshly mounted, unmeasured list (tab-switch case).
- Jump instantly (behavior 'auto') for far moves and on eink, animating
  'smooth' only for short in-session updates — mirroring TOCView.

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

224 lines
8.2 KiB
TypeScript

import { render, act, cleanup } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { BookNote } from '@/types/book';
// ---------- Shared mutable test state (captured by the mock factories) ----------
let scrollToIndexSpy: Mock<(arg: unknown) => void>;
// Only the FIRST (mount-time) `initialized` callback is captured, mirroring how
// OverlayScrollbars binds its event handlers when it initializes the viewport.
// A fix that relied on a fresher render closure would pass against the latest
// callback but still break in the real app — so the test forces a ref-based fix.
let capturedInitialized:
| ((instance: { elements: () => { viewport: HTMLElement } }) => void)
| undefined;
// Latest props Virtuoso was rendered with, so tests can assert the mount-time
// position (initialTopMostItemIndex) the panel hands it.
let capturedVirtuosoProps: Record<string, unknown> | undefined;
let mockProgress: { location: string } | null;
let mockBooknotes: BookNote[];
// ---------- Mocks ----------
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: () => ({ getConfig: () => ({ booknotes: mockBooknotes }) }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ getProgress: () => mockProgress }),
}));
vi.mock('@/store/sidebarStore', () => ({
useSidebarStore: () => ({
setActiveBooknoteType: vi.fn(),
setBooknoteResults: vi.fn(),
}),
}));
// Derive a per-chapter TOC group from the spine step of each note's CFI so the
// flattened list is [header, note, header, note, ...] sorted by chapter.
vi.mock('@/services/nav', () => ({
findTocItemBS: (_toc: unknown, cfi: string) => {
const match = cfi.match(/\/6\/(\d+)!/);
const n = match ? Number(match[1]) : 0;
return { id: n, href: `ch${n}.html`, label: `Chapter ${n}`, index: n };
},
}));
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (s: string) => s,
}));
vi.mock('@/utils/event', () => ({
eventDispatcher: { dispatch: vi.fn(), on: vi.fn(), off: vi.fn() },
}));
vi.mock('@/app/reader/components/sidebar/BooknoteItem', () => ({
default: () => null,
}));
vi.mock('@/app/reader/components/EmptyState', () => ({
default: () => null,
}));
// Virtuoso is replaced with a stub that exposes a spy-able `scrollToIndex`
// through the imperative handle and hands BooknoteView a scroller element.
vi.mock('react-virtuoso', async () => {
const ReactMod = await import('react');
return {
Virtuoso: ReactMod.forwardRef(
(
props: { scrollerRef?: (el: HTMLElement | Window | null) => void },
ref: React.Ref<unknown>,
) => {
capturedVirtuosoProps = props as Record<string, unknown>;
ReactMod.useImperativeHandle(ref, () => ({
scrollToIndex: (arg: unknown) => scrollToIndexSpy(arg),
}));
ReactMod.useEffect(() => {
props.scrollerRef?.(document.createElement('div'));
}, []);
return null;
},
),
};
});
// Capture the OverlayScrollbars `initialized` callback so the test can fire it
// on demand (the real hook fires it after a deferred, timing-dependent init).
vi.mock('overlayscrollbars-react', () => ({
useOverlayScrollbars: (opts: {
events?: { initialized?: (i: { elements: () => { viewport: HTMLElement } }) => void };
}) => {
if (!capturedInitialized) capturedInitialized = opts.events?.initialized;
return [vi.fn(), () => undefined];
},
}));
// eslint-disable-next-line import/first
import BooknoteView from '@/app/reader/components/sidebar/BooknoteView';
const makeNote = (cfi: string): BookNote =>
({
id: cfi,
type: 'annotation',
cfi,
text: cfi,
note: '',
createdAt: 0,
updatedAt: 0,
}) as BookNote;
const fireOverlayScrollbarsInitialized = () => {
act(() => {
capturedInitialized?.({ elements: () => ({ viewport: document.createElement('div') }) });
});
};
beforeEach(() => {
scrollToIndexSpy = vi.fn<(arg: unknown) => void>();
capturedInitialized = undefined;
capturedVirtuosoProps = undefined;
mockProgress = null;
mockBooknotes = [
makeNote('epubcfi(/6/4!/4/2:0)'),
makeNote('epubcfi(/6/6!/4/4:0)'),
makeNote('epubcfi(/6/8!/4/2:0)'),
makeNote('epubcfi(/6/10!/4/6:0)'),
makeNote('epubcfi(/6/26!/4/2:0)'),
];
// Run rAF synchronously so the callback's scroll happens inside act().
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('BooknoteView — OverlayScrollbars init does not rewind the list to the top', () => {
it('re-applies the auto-scroll to the nearest note when OverlayScrollbars initializes after the reading position arrives', () => {
// Fresh open: BooknoteView mounts before the first relocate, so `progress`
// (and thus the nearest cfi) has no reading position yet.
mockProgress = null;
const { rerender } = render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
// The relocate arrives → the normal auto-scroll effect centers the nearest
// note. The list is [h4, n4, h6, n6, h8, n8, h10, n10, h26, n26]; the
// reading position is in the last chapter, so the nearest note is index 9.
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
act(() => {
rerender(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
});
// Ignore that first scroll; we only care whether the OverlayScrollbars init
// (which clobbers scrollTop) re-applies it instead of stranding the top.
scrollToIndexSpy.mockClear();
fireOverlayScrollbarsInitialized();
expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 }));
});
it('does not force a scroll on OverlayScrollbars init when there is no reading position', () => {
mockProgress = null;
render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
scrollToIndexSpy.mockClear();
fireOverlayScrollbarsInitialized();
expect(scrollToIndexSpy).not.toHaveBeenCalled();
});
it('positions Virtuoso natively on mount (without a racing scrollToIndex) when the reading position is already known', () => {
// Switching to the panel while reading: the reading position is available at
// mount. A scrollToIndex against the freshly mounted, unmeasured list no-ops
// or wedges it into rendering nothing — so the panel must mount Virtuoso
// already centered via initialTopMostItemIndex and the scroll effect must
// skip its first jump.
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
act(() => {
render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
});
expect(capturedVirtuosoProps?.['initialTopMostItemIndex']).toEqual({
index: 9,
align: 'center',
});
expect(scrollToIndexSpy).not.toHaveBeenCalled();
// The deferred OverlayScrollbars init resets scrollTop; the re-apply then
// restores the centered position (now that the rows have been measured).
fireOverlayScrollbarsInitialized();
expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 }));
});
it('jumps instantly (behavior auto) for a far scroll instead of animating it, like TOCView', () => {
// 12 notes in distinct chapters → flat list [h, n, h, n, ...] of 24 rows;
// the nearest note (last chapter) sits at index 23, far from the top.
mockBooknotes = Array.from({ length: 12 }, (_, i) =>
makeNote(`epubcfi(/6/${4 + i * 2}!/4/2:0)`),
);
// Reload-style: no reading position at mount, so initialTopMostItemIndex
// does not handle it and the scroll effect performs the jump.
mockProgress = null;
const { rerender } = render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
act(() => {
rerender(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
});
// distance (23 - 0) > 16 → instant jump, not a smooth animation.
expect(scrollToIndexSpy).toHaveBeenCalledWith(
expect.objectContaining({ index: 23, behavior: 'auto' }),
);
expect(scrollToIndexSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ behavior: 'smooth' }),
);
});
});