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
@@ -1,9 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { clearThreadScrollSpacer, openThreadAtTop } from '../thread-scroll-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getRequestedThreadTopCid, getThreadTopNavigationState, scrollThreadContainerToTop } from '../thread-scroll-utils';
const appendThreadContainer = (cid: string, top = 240) => {
const appendThreadContainer = ({ cid, top = 240, hidden = false, parent = document.body }: { cid: string; top?: number; hidden?: boolean; parent?: HTMLElement }) => {
const element = document.createElement('div');
element.dataset.threadContainerCid = cid;
if (hidden) {
element.style.display = 'none';
}
element.getBoundingClientRect = () =>
({
bottom: top + 100,
@@ -13,110 +16,87 @@ const appendThreadContainer = (cid: string, top = 240) => {
top,
width: 100,
}) as DOMRect;
document.body.appendChild(element);
parent.appendChild(element);
return element;
};
describe('thread-scroll-utils', () => {
let scrollToMock: ReturnType<typeof vi.fn>;
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
scrollToMock = vi.fn();
requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
Object.defineProperty(window, 'scrollTo', {
configurable: true,
value: scrollToMock,
writable: true,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: requestAnimationFrameMock,
writable: true,
});
Object.defineProperty(window, 'scrollY', {
configurable: true,
value: 0,
value: 32,
writable: true,
});
Object.defineProperty(window, 'innerHeight', {
configurable: true,
value: 100,
writable: true,
});
Object.defineProperty(document.documentElement, 'scrollHeight', {
configurable: true,
value: 200,
writable: true,
});
});
afterEach(() => {
clearThreadScrollSpacer();
document.body.innerHTML = '';
});
it('scrolls the current thread without pushing duplicate history entries', () => {
appendThreadContainer('thread-cid');
const navigateMock = vi.fn();
it('scrolls the matching thread container with a plain window scroll', () => {
appendThreadContainer({ cid: 'thread-cid', top: 240 });
expect(
openThreadAtTop({
cid: 'thread-cid',
currentPathname: '/mu/thread/thread-cid',
navigate: navigateMock,
threadRoute: '/mu/thread/thread-cid',
}),
).toBe(true);
expect(scrollThreadContainerToTop('thread-cid')).toBe(true);
expect(navigateMock).not.toHaveBeenCalled();
expect(requestAnimationFrameMock).toHaveBeenCalledOnce();
expect(scrollToMock).toHaveBeenCalledTimes(2);
expect(scrollToMock).toHaveBeenNthCalledWith(1, {
expect(scrollToMock).toHaveBeenCalledOnce();
expect(scrollToMock).toHaveBeenCalledWith({
behavior: 'auto',
left: 0,
top: 240,
top: 272,
});
});
it('navigates to the OP thread before scrolling when the route differs', () => {
appendThreadContainer('thread-cid');
const navigateMock = vi.fn();
it('ignores preview copies when resolving the scroll target', () => {
const previewWrapper = document.createElement('div');
previewWrapper.dataset.threadScrollPreview = 'true';
document.body.appendChild(previewWrapper);
appendThreadContainer({ cid: 'thread-cid', top: 12, parent: previewWrapper });
appendThreadContainer({ cid: 'thread-cid', top: 180 });
expect(
openThreadAtTop({
cid: 'thread-cid',
currentPathname: '/mu/thread/reply-cid',
navigate: navigateMock,
threadRoute: '/mu/thread/thread-cid',
}),
).toBe(true);
expect(scrollThreadContainerToTop('thread-cid')).toBe(true);
expect(navigateMock).toHaveBeenCalledWith('/mu/thread/thread-cid');
expect(requestAnimationFrameMock).toHaveBeenCalledOnce();
expect(scrollToMock).toHaveBeenCalledTimes(2);
expect(scrollToMock).toHaveBeenCalledWith({
behavior: 'auto',
left: 0,
top: 212,
});
});
it('returns false when the permalink cannot resolve a thread target', () => {
const navigateMock = vi.fn();
it('prefers the visible thread container when cached duplicates are hidden', () => {
appendThreadContainer({ cid: 'thread-cid', top: 12, hidden: true });
appendThreadContainer({ cid: 'thread-cid', top: 180 });
expect(
openThreadAtTop({
cid: undefined,
currentPathname: '/mu/thread/thread-cid',
navigate: navigateMock,
threadRoute: '/mu/thread/thread-cid',
}),
).toBe(false);
expect(scrollThreadContainerToTop('thread-cid')).toBe(true);
expect(navigateMock).not.toHaveBeenCalled();
expect(requestAnimationFrameMock).not.toHaveBeenCalled();
expect(scrollToMock).toHaveBeenCalledWith({
behavior: 'auto',
left: 0,
top: 212,
});
});
it('returns false when no thread container exists', () => {
expect(scrollThreadContainerToTop('missing-cid')).toBe(false);
expect(scrollToMock).not.toHaveBeenCalled();
});
it('serializes and reads thread-top navigation state', () => {
expect(getThreadTopNavigationState()).toBeUndefined();
expect(getThreadTopNavigationState('thread-cid')).toEqual({
scrollThreadContainerCid: 'thread-cid',
});
expect(getRequestedThreadTopCid(getThreadTopNavigationState())).toBeUndefined();
expect(getRequestedThreadTopCid({ scrollThreadContainerCid: 'thread-cid' })).toBe('thread-cid');
expect(getRequestedThreadTopCid({ scrollThreadContainerCid: 42 })).toBeUndefined();
expect(getRequestedThreadTopCid(null)).toBeUndefined();
});
});