Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useRef } from 'react';
import { useSyncScroll } from './useSyncScroll.js';

// Plain divs so the sync runs without overlay-scrollbar gating.
const SyncScrollHarness = () => {
const contentRef = useRef<HTMLDivElement>(null);
const scrollbarRef = useRef<HTMLDivElement>(null);

useSyncScroll(contentRef, scrollbarRef, true, false);

return (
<div style={{ display: 'flex', gap: '4px' }}>
<div ref={contentRef} data-testid="content" style={{ height: '200px', width: '200px', overflowY: 'auto' }}>
<div style={{ height: '4000px' }}>content</div>
</div>
<div ref={scrollbarRef} data-testid="scrollbar" style={{ height: '200px', width: '16px', overflowY: 'scroll' }}>
<div style={{ height: '4000px' }} />
</div>
</div>
);
};

describe('useSyncScroll', () => {
it('keeps following and consumes the echo instead of pulling the position back', () => {
cy.mount(<SyncScrollHarness />);
cy.get('[data-testid="content"]').should('exist');

cy.window().then((win) => {
const content = win.document.querySelector<HTMLElement>('[data-testid="content"]')!;
const scrollbar = win.document.querySelector<HTMLElement>('[data-testid="scrollbar"]')!;
const fireScroll = (el: HTMLElement) => el.dispatchEvent(new win.Event('scroll'));

content.scrollTop = 100;
fireScroll(content);
expect(scrollbar.scrollTop, 'scrollbar mirrors the initial scroll').to.equal(100);

// Second scroll before the first echo — the old single-flag guard blocked this.
content.scrollTop = 200;
fireScroll(content);
expect(scrollbar.scrollTop, 'scrollbar keeps following without being blocked').to.equal(200);

fireScroll(scrollbar);
expect(content.scrollTop, 'content position is not pulled backwards by the echo').to.equal(200);
});
});

it('still syncs a genuine scroll back to a value whose echo was superseded', () => {
cy.mount(<SyncScrollHarness />);
cy.get('[data-testid="content"]').should('exist');

cy.window().then((win) => {
const content = win.document.querySelector<HTMLElement>('[data-testid="content"]')!;
const scrollbar = win.document.querySelector<HTMLElement>('[data-testid="scrollbar"]')!;
const fireScroll = (el: HTMLElement) => el.dispatchEvent(new win.Event('scroll'));

content.scrollTop = 100;
fireScroll(content);
expect(scrollbar.scrollTop).to.equal(100);

// Scrolling the scrollbar elsewhere supersedes the pending echo for 100.
scrollbar.scrollTop = 300;
fireScroll(scrollbar);
expect(content.scrollTop, 'content follows the scrollbar').to.equal(300);

scrollbar.scrollTop = 100;
fireScroll(scrollbar);
expect(content.scrollTop, 'genuine scroll to the old value is not swallowed').to.equal(100);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import type { MutableRefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';

export function useSyncScroll(
refContent: MutableRefObject<HTMLElement>,
refScrollbar: MutableRefObject<HTMLElement>,
isScrollable: boolean,
disabled = false,
) {
const isProgrammatic = useRef(false);
const [isMounted, setIsMounted] = useState(false);

// DOM scrollTop manipulation, not React state
// eslint-disable-next-line react-hooks/immutability
useEffect(() => {
if (disabled || !isScrollable) {
return;
Expand All @@ -27,19 +24,36 @@ export function useSyncScroll(
return;
}

// Tracks written values to recognize their echo `scroll` events.
const lastWritten = new WeakMap<Element, number>();

const prevScrollbar = scrollbar.scrollTop;
// Is a React ref
// eslint-disable-next-line react-hooks/immutability
scrollbar.scrollTop = content.scrollTop;
if (scrollbar.scrollTop !== prevScrollbar) {
lastWritten.set(scrollbar, scrollbar.scrollTop);
}

const sync = (source: 'content' | 'scrollbar') => {
const sourceEl = source === 'content' ? content : scrollbar;
const targetEl = source === 'content' ? scrollbar : content;
const value = sourceEl.scrollTop;

// Consume our own echo; drop stale records so a genuine scroll is never mistaken for one.
const isEcho = lastWritten.get(sourceEl) === value;
lastWritten.delete(sourceEl);
if (isEcho) {
return;
}

if (!isProgrammatic.current && targetEl.scrollTop !== sourceEl.scrollTop) {
isProgrammatic.current = true;
targetEl.scrollTop = sourceEl.scrollTop;
// Clear the flag on next frame
requestAnimationFrame(() => (isProgrammatic.current = false));
if (targetEl.scrollTop !== value) {
const prev = targetEl.scrollTop;
targetEl.scrollTop = value;
// A clamped no-op write fires no echo, so only record when the value actually changed.
if (targetEl.scrollTop !== prev) {
lastWritten.set(targetEl, targetEl.scrollTop);
}
}
};

Expand Down
Loading