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:
Tommaso Casaburi
2026-03-10 18:08:20 +08:00
committed by GitHub
parent 4cb5c4a833
commit 6ed3708322
13 changed files with 382 additions and 151 deletions
+33 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { copyToClipboard } from '../clipboard-utils';
import { hashStringToColor, getTextColorForBackground, removeMarkdown } from '../post-utils';
import { preloadThemeAssets } from '../preload-utils';
import { preloadReplyModal, preloadThemeAssets } from '../preload-utils';
import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../replies-preview-utils';
import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils';
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils';
@@ -119,6 +119,38 @@ describe('misc utils', () => {
expect(loadedSources).toEqual(['/buttons/default.png', '/buttons/hover.png', '/backgrounds/wallpaper.png']);
});
it('schedules reply modal preload with requestIdleCallback when available', () => {
const requestIdleCallback = vi.fn();
Object.defineProperty(window, 'requestIdleCallback', {
configurable: true,
value: requestIdleCallback,
});
preloadReplyModal();
expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 1500 });
});
it('falls back to setTimeout for reply modal preload when requestIdleCallback is unavailable', () => {
const originalRequestIdleCallback = window.requestIdleCallback;
// @ts-expect-error test fallback path
delete window.requestIdleCallback;
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
preloadReplyModal();
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
if (originalRequestIdleCallback) {
Object.defineProperty(window, 'requestIdleCallback', {
configurable: true,
value: originalRequestIdleCallback,
});
}
});
it('builds reply previews, omitted counts, and fallback reply totals', () => {
expect(filterRepliesForDisplay([{ cid: 'visible' }, { cid: 'deleted', deleted: true }, { cid: 'removed', deleted: false }])).toEqual([
{ cid: 'visible' },
+30
View File
@@ -19,3 +19,33 @@ export const preloadThemeAssets = (): void => {
preloadImages(THEME_BUTTON_IMAGES);
preloadImages(THEME_BACKGROUND_IMAGES);
};
const scheduleIdlePreload = (callback: () => void): void => {
if (typeof globalThis === 'undefined') {
callback();
return;
}
const requestIdle = (
globalThis as typeof globalThis & {
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
}
).requestIdleCallback;
if (typeof requestIdle === 'function') {
requestIdle(() => callback(), { timeout: 1500 });
return;
}
globalThis.setTimeout(callback, 500);
};
/**
* Preloads the reply modal chunk during idle time so the first `No.` click
* doesn't have to wait for the lazy import to resolve.
*/
export const preloadReplyModal = (): void => {
scheduleIdlePreload(() => {
void import('../../components/reply-modal');
});
};
+48
View File
@@ -0,0 +1,48 @@
const THREAD_SCROLL_SPACER_ID = 'thread-scroll-spacer';
const setThreadScrollSpacerHeight = (height: number) => {
const existingSpacer = document.getElementById(THREAD_SCROLL_SPACER_ID);
if (height <= 0) {
existingSpacer?.remove();
return;
}
const spacer =
existingSpacer ||
Object.assign(document.createElement('div'), {
id: THREAD_SCROLL_SPACER_ID,
});
spacer.setAttribute('aria-hidden', 'true');
spacer.style.height = `${Math.ceil(height)}px`;
spacer.style.pointerEvents = 'none';
spacer.style.opacity = '0';
if (!existingSpacer) {
document.body.appendChild(spacer);
}
};
export const clearThreadScrollSpacer = () => {
document.getElementById(THREAD_SCROLL_SPACER_ID)?.remove();
};
export const scrollThreadContainerToTop = (cid?: string) => {
if (!cid) return false;
const threadContainer = document.querySelector<HTMLElement>(`[data-thread-container-cid="${cid}"]`);
if (!threadContainer) return false;
const desiredTop = window.scrollY + threadContainer.getBoundingClientRect().top;
const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight;
const extraSpace = Math.max(0, desiredTop - maxScrollTop);
setThreadScrollSpacerHeight(extraSpace);
window.scrollTo({
top: desiredTop,
left: 0,
behavior: 'auto',
});
return true;
};