fix(post): stop thread navigation from forcing OP alignment (#1052)

* Update README.md

* fix(post): stop thread navigation from forcing OP alignment

Route-driven thread opens now use the normal top-of-page behavior, while explicit OP permalink intents carry a scrollThreadContainerCid state and resolve against the visible thread container only. The old spacer injection path is removed.

* fix(post): address PR review follow-ups
This commit is contained in:
Tommaso Casaburi
2026-03-11 18:43:36 +08:00
committed by GitHub
parent 3401ec147f
commit 6c03253cb4
9 changed files with 297 additions and 199 deletions
+56 -4
View File
@@ -160,12 +160,12 @@ const flushEffects = async (count = 5) => {
}
};
const renderPostPage = async (initialEntry: string) => {
const renderPostPage = async (initialEntry: string | { pathname: string; state?: unknown }) => {
await act(async () => {
root.render(
createElement(
MemoryRouter,
{ initialEntries: [initialEntry] },
{ initialEntries: [initialEntry as any] },
createElement(
Routes,
{},
@@ -202,11 +202,41 @@ describe('Post', () => {
value: vi.fn(),
writable: true,
});
Object.defineProperty(window, 'scrollY', {
configurable: true,
value: 0,
writable: true,
});
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: vi.fn(),
writable: true,
});
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
configurable: true,
value: function () {
if ((this as HTMLElement).dataset.threadContainerCid) {
return {
bottom: 220,
height: 100,
left: 0,
right: 100,
top: 120,
width: 100,
} as DOMRect;
}
return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
} as DOMRect;
},
writable: true,
});
document.title = 'before';
container = document.createElement('div');
@@ -261,12 +291,34 @@ describe('Post', () => {
expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false');
expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false');
expect(document.title).toBe('/mu/ - Cached thread... - 5chan');
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
});
it('only aligns the OP container when navigation explicitly requests it', async () => {
testState.commentsByCid = {
'thread-cid': {
cid: 'thread-cid',
number: 8,
replyCount: 0,
subplebbitAddress: 'music-posting.eth',
title: 'Thread title',
},
};
await renderPostPage({
pathname: '/mu/thread/thread-cid',
state: {
scrollThreadContainerCid: 'thread-cid',
},
});
expect(window.scrollTo).toHaveBeenCalledWith({
behavior: 'auto',
left: 0,
top: 0,
top: 120,
});
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
expect(window.scrollTo).not.toHaveBeenCalledWith(0, 0);
});
it('redirects thread routes whose fetched comment belongs to a different board', async () => {
+20 -10
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useMemo } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks';
import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages';
@@ -13,7 +13,7 @@ import ErrorDisplay from '../../components/error-display/error-display';
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
import PostDesktop from '../../components/post-desktop';
import PostMobile from '../../components/post-mobile';
import { clearThreadScrollSpacer, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
import styles from './post.module.css';
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
@@ -134,6 +134,7 @@ const PostPage = () => {
const isInAllView = isAllView(location.pathname);
const comment = useCommentWithFeedCache({ commentCid });
const consumedThreadTopScrollRef = useRef<string | null>(null);
const navigate = useNavigate();
useEffect(() => {
@@ -154,23 +155,32 @@ const PostPage = () => {
} else {
post = comment;
}
const requestedThreadTopCid = getRequestedThreadTopCid(location.state);
const { error } = post || {};
useEffect(() => () => clearThreadScrollSpacer(), []);
// These two effects split normal opens from explicit OP-top intents:
// the first keeps ordinary thread visits on `window.scrollTo(0, 0)`, while the
// second consumes `requestedThreadTopCid` once per `location.key` via
// `consumedThreadTopScrollRef` so `scrollThreadContainerToTop(commentCid)` only
// replays for deliberate OP-link clicks and never for route-driven thread opens.
useEffect(() => {
if (!commentCid || post?.cid === commentCid) return;
clearThreadScrollSpacer();
}, [commentCid, post?.cid]);
if (!comment?.cid || comment.parentCid) return;
if (requestedThreadTopCid === comment.cid) return;
window.scrollTo(0, 0);
}, [comment?.cid, comment?.parentCid, requestedThreadTopCid]);
useEffect(() => {
if (!commentCid || post?.cid !== commentCid) return;
if (requestedThreadTopCid !== commentCid) return;
const consumedKey = `${location.key}:${commentCid}`;
if (consumedThreadTopScrollRef.current === consumedKey) return;
if (scrollThreadContainerToTop(commentCid)) {
return;
consumedThreadTopScrollRef.current = consumedKey;
}
window.scrollTo(0, 0);
}, [commentCid, post?.cid]);
}, [commentCid, location.key, post?.cid, requestedThreadTopCid]);
useEffect(() => {
const boardIdentifier = params.boardIdentifier;