mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(replies): reply permalink didn't auto-scroll to deep replies
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { act, useMemo } from 'react';
|
||||
import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { VirtuosoHandle } from 'react-virtuoso';
|
||||
import useScrollToReply from './use-scroll-to-reply';
|
||||
|
||||
const TestHarness = ({
|
||||
targetReplyCid,
|
||||
replies,
|
||||
hasMore,
|
||||
loadMore,
|
||||
virtuosoRef,
|
||||
renderTargetElement = false,
|
||||
}: {
|
||||
targetReplyCid?: string;
|
||||
replies: Array<{ cid?: string | null }>;
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
|
||||
renderTargetElement?: boolean;
|
||||
}) => {
|
||||
const memoizedReplies = useMemo(() => replies, [replies]);
|
||||
useScrollToReply({
|
||||
targetReplyCid,
|
||||
replies: memoizedReplies,
|
||||
hasMore,
|
||||
loadMore,
|
||||
virtuosoRef,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return renderTargetElement ? <div data-cid={targetReplyCid} /> : null;
|
||||
};
|
||||
|
||||
describe('useScrollToReply', () => {
|
||||
const originalScrollIntoView = Element.prototype.scrollIntoView;
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
Element.prototype.scrollIntoView = originalScrollIntoView;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('scrolls to target reply index when present', async () => {
|
||||
const scrollToIndex = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
|
||||
const replies = [{ cid: 'a' }, { cid: 'b' }, { cid: 'c' }];
|
||||
|
||||
await act(() => {
|
||||
root.render(<TestHarness targetReplyCid='b' replies={replies} hasMore={true} loadMore={vi.fn()} virtuosoRef={virtuosoRef} />);
|
||||
});
|
||||
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(scrollToIndex).toHaveBeenCalledWith({
|
||||
index: 1,
|
||||
align: 'center',
|
||||
behavior: 'smooth',
|
||||
});
|
||||
});
|
||||
|
||||
it('loads more when target reply is not yet present', async () => {
|
||||
const loadMore = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex: vi.fn() } as unknown as VirtuosoHandle };
|
||||
let intervalCallback: (() => void) | null = null;
|
||||
let timeoutCallback: (() => void) | null = null;
|
||||
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((callback) => {
|
||||
intervalCallback = callback as () => void;
|
||||
return 1;
|
||||
});
|
||||
|
||||
vi.spyOn(window, 'setTimeout').mockImplementation((callback) => {
|
||||
timeoutCallback = callback as () => void;
|
||||
return 1;
|
||||
});
|
||||
|
||||
await act(() => {
|
||||
root.render(<TestHarness targetReplyCid='missing' replies={[{ cid: 'a' }]} hasMore={true} loadMore={loadMore} virtuosoRef={virtuosoRef} />);
|
||||
});
|
||||
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
act(() => {
|
||||
intervalCallback?.();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
timeoutCallback?.();
|
||||
});
|
||||
|
||||
expect(loadMore).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls into view when virtualization is not active', async () => {
|
||||
const loadMore = vi.fn();
|
||||
const virtuosoRef = { current: { scrollToIndex: vi.fn() } as unknown as VirtuosoHandle };
|
||||
|
||||
await act(() => {
|
||||
root.render(
|
||||
<TestHarness targetReplyCid='target' replies={[{ cid: 'target' }]} hasMore={false} loadMore={loadMore} virtuosoRef={virtuosoRef} renderTargetElement={true} />,
|
||||
);
|
||||
});
|
||||
|
||||
await act(() => Promise.resolve());
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
||||
type ReplyItem = {
|
||||
cid?: string | null;
|
||||
};
|
||||
|
||||
interface UseScrollToReplyParams {
|
||||
targetReplyCid?: string;
|
||||
replies: ReplyItem[];
|
||||
hasMore: boolean;
|
||||
loadMore: () => void;
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_LOAD_ATTEMPTS = 500;
|
||||
const MAX_LOAD_DURATION_MS = 120000;
|
||||
const LOAD_MORE_THROTTLE_MS = 300;
|
||||
const AUTO_SCROLL_INTERVAL_MS = 350;
|
||||
|
||||
const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuosoRef, enabled = true }: UseScrollToReplyParams) => {
|
||||
const hasScrolledRef = useRef(false);
|
||||
const loadAttemptsRef = useRef(0);
|
||||
const lastRepliesLengthRef = useRef(replies.length);
|
||||
const loadMoreTimeoutRef = useRef<number | null>(null);
|
||||
const lastLoadAtRef = useRef(0);
|
||||
const loadStartAtRef = useRef(0);
|
||||
const lastScrollIndexRef = useRef(-1);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const latestTargetRef = useRef(targetReplyCid);
|
||||
const latestRepliesRef = useRef(replies);
|
||||
const latestHasMoreRef = useRef(hasMore);
|
||||
const latestLoadMoreRef = useRef(loadMore);
|
||||
|
||||
// Only reset when the target changes, NOT when replies.length changes
|
||||
// (replies.length changing is expected as we load more pages)
|
||||
useEffect(() => {
|
||||
hasScrolledRef.current = false;
|
||||
loadAttemptsRef.current = 0;
|
||||
lastRepliesLengthRef.current = replies.length;
|
||||
lastLoadAtRef.current = 0;
|
||||
loadStartAtRef.current = Date.now();
|
||||
lastScrollIndexRef.current = -1;
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (loadMoreTimeoutRef.current) {
|
||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
||||
loadMoreTimeoutRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [targetReplyCid]);
|
||||
|
||||
useEffect(() => {
|
||||
latestTargetRef.current = targetReplyCid;
|
||||
}, [targetReplyCid]);
|
||||
|
||||
useEffect(() => {
|
||||
latestRepliesRef.current = replies;
|
||||
}, [replies]);
|
||||
|
||||
useEffect(() => {
|
||||
latestHasMoreRef.current = hasMore;
|
||||
}, [hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
latestLoadMoreRef.current = loadMore;
|
||||
}, [loadMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (replies.length !== lastRepliesLengthRef.current) {
|
||||
lastRepliesLengthRef.current = replies.length;
|
||||
}
|
||||
}, [replies.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !targetReplyCid || intervalRef.current) return;
|
||||
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
const latestTarget = latestTargetRef.current;
|
||||
const latestReplies = latestRepliesRef.current;
|
||||
const latestHasMore = latestHasMoreRef.current;
|
||||
const latestLoadMore = latestLoadMoreRef.current;
|
||||
|
||||
if (!enabled || !latestTarget || hasScrolledRef.current) {
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latestHasMore) {
|
||||
const element = document.querySelector(`[data-cid="${latestTarget}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
hasScrolledRef.current = true;
|
||||
} else {
|
||||
// Reply not found after loading all pages
|
||||
console.warn(`[scroll-to-reply] Could not find reply with CID "${latestTarget}" in the feed.`);
|
||||
hasScrolledRef.current = true; // Stop trying
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Iteratively load pages until the target reply appears in the list.
|
||||
const targetIndex = latestReplies.findIndex((reply) => reply?.cid === latestTarget);
|
||||
if (targetIndex >= 0) {
|
||||
hasScrolledRef.current = true;
|
||||
if (loadMoreTimeoutRef.current) {
|
||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
||||
loadMoreTimeoutRef.current = null;
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
virtuosoRef.current?.scrollToIndex({ index: targetIndex, align: 'center', behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Smoothly scroll to the latest loaded replies while loading more pages
|
||||
const lastIndex = latestReplies.length - 1;
|
||||
if (lastIndex >= 0 && lastIndex !== lastScrollIndexRef.current) {
|
||||
lastScrollIndexRef.current = lastIndex;
|
||||
virtuosoRef.current?.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
const loadDuration = Date.now() - loadStartAtRef.current;
|
||||
if (!latestHasMore || loadAttemptsRef.current >= DEFAULT_MAX_LOAD_ATTEMPTS || loadDuration >= MAX_LOAD_DURATION_MS) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastLoadAtRef.current < LOAD_MORE_THROTTLE_MS) return;
|
||||
|
||||
lastLoadAtRef.current = now;
|
||||
loadAttemptsRef.current += 1;
|
||||
|
||||
if (loadMoreTimeoutRef.current) {
|
||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
||||
}
|
||||
loadMoreTimeoutRef.current = window.setTimeout(() => {
|
||||
latestLoadMore();
|
||||
}, LOAD_MORE_THROTTLE_MS);
|
||||
}, AUTO_SCROLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (loadMoreTimeoutRef.current) {
|
||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
||||
loadMoreTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, targetReplyCid, virtuosoRef]);
|
||||
};
|
||||
|
||||
export default useScrollToReply;
|
||||
Reference in New Issue
Block a user