mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(post): align op permalinks to the outer thread container (#1050)
* fix(post): align op permalinks to the outer thread container * perf(scroll-to-reply): stop interval when max attempts/duration reached
This commit is contained in:
@@ -75,39 +75,69 @@ describe('useScrollToReply', () => {
|
||||
targetReplyCid: 'reply-2',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
|
||||
align: 'center',
|
||||
behavior: 'smooth',
|
||||
behavior: 'auto',
|
||||
index: 1,
|
||||
});
|
||||
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls to the latest loaded reply and schedules loadMore while searching', async () => {
|
||||
it('centers a mounted target immediately even when it is already visible', async () => {
|
||||
const replyElement = document.createElement('div');
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
replyElement.dataset.cid = 'reply-2';
|
||||
replyElement.dataset.postCid = 'post-1';
|
||||
replyElement.scrollIntoView = scrollIntoViewMock;
|
||||
document.body.appendChild(replyElement);
|
||||
|
||||
await renderHook({
|
||||
hasMore: true,
|
||||
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
|
||||
targetReplyCid: 'reply-2',
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
||||
behavior: 'auto',
|
||||
block: 'center',
|
||||
});
|
||||
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
||||
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls a mounted offscreen target into view immediately', async () => {
|
||||
const replyElement = document.createElement('div');
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
replyElement.dataset.cid = 'reply-3';
|
||||
replyElement.dataset.postCid = 'post-1';
|
||||
replyElement.scrollIntoView = scrollIntoViewMock;
|
||||
document.body.appendChild(replyElement);
|
||||
|
||||
await renderHook({
|
||||
hasMore: true,
|
||||
replies: [{ cid: 'reply-1' }, { cid: 'reply-3' }],
|
||||
targetReplyCid: 'reply-3',
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
||||
behavior: 'auto',
|
||||
block: 'center',
|
||||
});
|
||||
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrolls to the latest loaded reply and triggers loadMore immediately while searching', async () => {
|
||||
await renderHook({
|
||||
hasMore: true,
|
||||
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
|
||||
targetReplyCid: 'reply-3',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
|
||||
align: 'end',
|
||||
behavior: 'smooth',
|
||||
behavior: 'auto',
|
||||
index: 1,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -115,6 +145,7 @@ describe('useScrollToReply', () => {
|
||||
const replyElement = document.createElement('div');
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
replyElement.dataset.cid = 'reply-3';
|
||||
replyElement.dataset.postCid = 'post-1';
|
||||
replyElement.scrollIntoView = scrollIntoViewMock;
|
||||
document.body.appendChild(replyElement);
|
||||
|
||||
@@ -124,12 +155,8 @@ describe('useScrollToReply', () => {
|
||||
targetReplyCid: 'reply-3',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
behavior: 'auto',
|
||||
block: 'center',
|
||||
});
|
||||
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
||||
@@ -142,10 +169,6 @@ describe('useScrollToReply', () => {
|
||||
targetReplyCid: 'reply-404',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith('[scroll-to-reply] Could not find reply with CID "reply-404" in the feed.');
|
||||
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,29 +16,25 @@ interface UseScrollToReplyParams {
|
||||
|
||||
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 LOAD_MORE_THROTTLE_MS = 150;
|
||||
const AUTO_SCROLL_INTERVAL_MS = 100;
|
||||
|
||||
const getMountedReplyElement = (targetReplyCid: string) =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>(`[data-cid="${targetReplyCid}"][data-post-cid]`)).find((element) => !element.closest('[class*="replyQuotePreview"]'));
|
||||
|
||||
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;
|
||||
@@ -46,107 +42,73 @@ const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuoso
|
||||
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]);
|
||||
if (!enabled || !targetReplyCid || hasScrolledRef.current) return;
|
||||
|
||||
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;
|
||||
const attemptScroll = () => {
|
||||
if (hasScrolledRef.current) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
const mountedTarget = getMountedReplyElement(targetReplyCid);
|
||||
if (mountedTarget) {
|
||||
mountedTarget.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'center',
|
||||
});
|
||||
hasScrolledRef.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Iteratively load pages until the target reply appears in the list.
|
||||
const targetIndex = latestReplies.findIndex((reply) => reply?.cid === latestTarget);
|
||||
const targetIndex = replies.findIndex((reply) => reply?.cid === targetReplyCid);
|
||||
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;
|
||||
virtuosoRef.current?.scrollToIndex({ index: targetIndex, align: 'center', behavior: 'auto' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Smoothly scroll to the latest loaded replies while loading more pages
|
||||
const lastIndex = latestReplies.length - 1;
|
||||
if (!hasMore) {
|
||||
console.warn(`[scroll-to-reply] Could not find reply with CID "${targetReplyCid}" in the feed.`);
|
||||
hasScrolledRef.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep the viewport close to the newly loaded tail while the target is still missing.
|
||||
const lastIndex = replies.length - 1;
|
||||
if (lastIndex >= 0 && lastIndex !== lastScrollIndexRef.current) {
|
||||
lastScrollIndexRef.current = lastIndex;
|
||||
virtuosoRef.current?.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'smooth' });
|
||||
virtuosoRef.current?.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' });
|
||||
}
|
||||
|
||||
const loadDuration = Date.now() - loadStartAtRef.current;
|
||||
if (!latestHasMore || loadAttemptsRef.current >= DEFAULT_MAX_LOAD_ATTEMPTS || loadDuration >= MAX_LOAD_DURATION_MS) return;
|
||||
if (loadAttemptsRef.current >= DEFAULT_MAX_LOAD_ATTEMPTS || loadDuration >= MAX_LOAD_DURATION_MS) {
|
||||
console.warn(`[scroll-to-reply] Gave up scrolling to reply "${targetReplyCid}" after ${loadAttemptsRef.current} attempts / ${loadDuration}ms.`);
|
||||
hasScrolledRef.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastLoadAtRef.current < LOAD_MORE_THROTTLE_MS) return;
|
||||
if (now - lastLoadAtRef.current >= LOAD_MORE_THROTTLE_MS) {
|
||||
lastLoadAtRef.current = now;
|
||||
loadAttemptsRef.current += 1;
|
||||
loadMore();
|
||||
}
|
||||
|
||||
lastLoadAtRef.current = now;
|
||||
loadAttemptsRef.current += 1;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (loadMoreTimeoutRef.current) {
|
||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
||||
if (attemptScroll()) {
|
||||
return;
|
||||
}
|
||||
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
if (attemptScroll() && intervalRef.current) {
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
loadMoreTimeoutRef.current = window.setTimeout(() => {
|
||||
latestLoadMore();
|
||||
}, LOAD_MORE_THROTTLE_MS);
|
||||
}, AUTO_SCROLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
@@ -154,12 +116,8 @@ const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuoso
|
||||
window.clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
if (loadMoreTimeoutRef.current) {
|
||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
||||
loadMoreTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, targetReplyCid, virtuosoRef]);
|
||||
}, [enabled, targetReplyCid, replies, hasMore, loadMore, virtuosoRef]);
|
||||
};
|
||||
|
||||
export default useScrollToReply;
|
||||
|
||||
Reference in New Issue
Block a user