diff --git a/packages/shared/src/hooks/usePostModalNavigation.spec.ts b/packages/shared/src/hooks/usePostModalNavigation.spec.ts new file mode 100644 index 0000000000..ab554fed11 --- /dev/null +++ b/packages/shared/src/hooks/usePostModalNavigation.spec.ts @@ -0,0 +1,233 @@ +import { act, renderHook } from '@testing-library/react'; +import type { ParsedUrlQuery } from 'querystring'; +import post from '../../__tests__/fixture/post'; +import { FeedItemType } from '../components/cards/common/common'; +import type { UseRouterMemory } from './useRouterMemory'; +import { usePostModalNavigation } from './usePostModalNavigation'; +import { useScrollRestoration } from './useScrollRestoration'; + +const mockRouter = { + asPath: '/', + pathname: '/', + query: {} as ParsedUrlQuery, + push: jest.fn< + ReturnType, + Parameters + >(), +}; + +jest.mock('next/router', () => ({ useRouter: () => mockRouter })); +jest.mock('../contexts/LogContext', () => ({ + useLogContext: () => ({ logEvent: jest.fn() }), +})); +jest.mock('../lib/feed', () => ({ postLogEvent: jest.fn() })); +jest.mock('./useFeed', () => ({ isBoostedPostAd: () => false })); +jest.mock('./useKeyboardNavigation', () => ({ + useKeyboardNavigation: jest.fn(), +})); + +let historyKey = 0; +let pageHeight = 20000; +let notifyResize: (() => void) | undefined; +const advanceFrame = () => act(() => jest.advanceTimersByTime(16)); + +const setScrollY = (value: number): void => { + Object.defineProperty(window, 'scrollY', { configurable: true, value }); +}; + +const getHistoryEntry = () => ({ + key: window.history.state.key as string, + asPath: mockRouter.asPath, + pathname: mockRouter.pathname, + query: mockRouter.query, +}); + +const restoreHistoryEntry = (entry: ReturnType) => { + window.history.replaceState({ key: entry.key }, '', entry.asPath); + mockRouter.asPath = entry.asPath; + mockRouter.pathname = entry.pathname; + mockRouter.query = entry.query; +}; + +const renderNavigation = () => + renderHook(() => + usePostModalNavigation({ + items: [post, { ...post, id: 'second-post' }].map((item, index) => ({ + type: FeedItemType.Post, + post: item, + page: 0, + index, + dataUpdatedAt: 0, + })), + fetchPage: jest.fn(), + updatePost: jest.fn(), + canFetchMore: false, + feedName: 'main', + }), + ); + +beforeEach(() => { + jest.useFakeTimers(); + pageHeight = 20000; + notifyResize = undefined; + Object.defineProperty(document.documentElement, 'scrollHeight', { + configurable: true, + get: () => pageHeight, + }); + jest.mocked(ResizeObserver).mockImplementation((callback) => { + const observer = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + }; + notifyResize = () => callback([], observer); + return observer; + }); + historyKey += 1; + restoreHistoryEntry({ + key: `${historyKey}`, + asPath: '/', + pathname: '/', + query: {}, + }); + mockRouter.push.mockReset(); + mockRouter.push.mockImplementation(async (url, as) => { + const parsed = new URL(url, window.location.origin); + historyKey += 1; + restoreHistoryEntry({ + key: `${historyKey}`, + asPath: as ?? url, + pathname: parsed.pathname, + query: Object.fromEntries(parsed.searchParams.entries()), + }); + return true; + }); + setScrollY(0); + Object.defineProperty(window, 'scrollTo', { + configurable: true, + value: jest.fn((_x: number, y: number) => + setScrollY(Math.min(y, Math.max(0, pageHeight - window.innerHeight))), + ), + }); +}); + +afterEach(() => jest.useRealTimers()); + +it('restores the feed when browser Back reopens a closed post', async () => { + const { result, rerender } = renderNavigation(); + setScrollY(5000); + await act(async () => result.current.onOpenModal(0)); + rerender(); + const postEntry = getHistoryEntry(); + setScrollY(0); + + await act(async () => result.current.onCloseModal()); + rerender(); + advanceFrame(); + expect(window.scrollY).toBe(5000); + + restoreHistoryEntry(postEntry); + rerender(); + expect(result.current.selectedPost?.id).toBe(post.id); + setScrollY(0); + await act(async () => result.current.onCloseModal()); + + advanceFrame(); + expect(window.scrollY).toBe(5000); +}); + +it('keeps separate positions for earlier modal history entries after a remount', async () => { + const view = renderNavigation(); + setScrollY(5000); + await act(async () => view.result.current.onOpenModal(0)); + view.rerender(); + const firstEntry = getHistoryEntry(); + await act(async () => view.result.current.onCloseModal()); + view.rerender(); + + setScrollY(9000); + await act(async () => view.result.current.onOpenModal(0)); + view.rerender(); + const secondEntry = getHistoryEntry(); + view.unmount(); + + restoreHistoryEntry(firstEntry); + setScrollY(0); + const utils = renderNavigation(); + await act(async () => utils.result.current.onCloseModal()); + advanceFrame(); + expect(window.scrollY).toBe(5000); + + restoreHistoryEntry(secondEntry); + setScrollY(0); + utils.rerender(); + await act(async () => utils.result.current.onCloseModal()); + advanceFrame(); + expect(window.scrollY).toBe(9000); +}); + +it('carries the original feed position through next-post navigation after a remount', async () => { + const view = renderNavigation(); + setScrollY(5000); + await act(async () => view.result.current.onOpenModal(0)); + view.unmount(); + setScrollY(0); + + const utils = renderNavigation(); + await act(async () => utils.result.current.onNext()); + utils.rerender(); + expect(utils.result.current.selectedPost?.id).toBe('second-post'); + await act(async () => utils.result.current.onCloseModal()); + + advanceFrame(); + expect(window.scrollY).toBe(5000); +}); + +it('does not reset the scroll when a modal has no saved position', async () => { + mockRouter.query = { pmid: post.id, pmcid: 'main', pmp: '/', pmap: '/' }; + const { result } = renderNavigation(); + setScrollY(1200); + await act(async () => result.current.onCloseModal()); + + expect(window.scrollTo).not.toHaveBeenCalled(); +}); + +it('does not restore the feed when closing navigation is cancelled', async () => { + const { result, rerender } = renderNavigation(); + setScrollY(5000); + await act(async () => result.current.onOpenModal(0)); + rerender(); + setScrollY(0); + mockRouter.push.mockResolvedValueOnce(false); + await act(async () => result.current.onCloseModal()); + + expect(window.scrollTo).not.toHaveBeenCalled(); +}); + +it('waits for a remounted feed to grow after closing a post', async () => { + const view = renderNavigation(); + setScrollY(5000); + await act(async () => view.result.current.onOpenModal(0)); + view.unmount(); + setScrollY(0); + pageHeight = window.innerHeight; + + const { result, rerender } = renderNavigation(); + await act(async () => result.current.onCloseModal()); + rerender(); + const destination = getHistoryEntry(); + act(() => jest.advanceTimersByTime(2500)); + expect(window.scrollTo).not.toHaveBeenCalled(); + expect(window.scrollY).toBe(0); + + pageHeight = 20000; + notifyResize?.(); + advanceFrame(); + expect(window.scrollY).toBe(5000); + + restoreHistoryEntry(destination); + setScrollY(0); + renderHook(() => useScrollRestoration()); + advanceFrame(); + expect(window.scrollY).toBe(5000); +}); diff --git a/packages/shared/src/hooks/usePostModalNavigation.ts b/packages/shared/src/hooks/usePostModalNavigation.ts index 3c96bfee58..d38a74bdd5 100644 --- a/packages/shared/src/hooks/usePostModalNavigation.ts +++ b/packages/shared/src/hooks/usePostModalNavigation.ts @@ -13,6 +13,11 @@ import { useKeyboardNavigation } from './useKeyboardNavigation'; import { isExtension } from '../lib/func'; import type { UseRouterMemory as UsePostModalRouter } from './useRouterMemory'; import { useRouterMemory } from './useRouterMemory'; +import { + getScrollPosition, + saveScrollPosition, + restoreScrollPosition, +} from '../lib/scrollRestoration'; export enum PostPosition { First = 'first', @@ -29,7 +34,7 @@ interface UsePostModalNavigation { onCloseModal: (fromPopState?: boolean) => void; isFetchingNextPage?: boolean; selectedPost: Post | null; - selectedPostIndex: number; + selectedPostIndex: number | undefined; selectedPostIsAd: boolean; } @@ -63,7 +68,10 @@ export const usePostModalNavigation = ({ const pmid = router.query?.pmid as string; const { logEvent } = useLogContext(); const [isFetchingNextPage, setIsFetchingNextPage] = useState(false); - const scrollPositionOnFeed = useRef(0); + const scrollPositionOnFeed = useRef(); + const cancelRestore = useRef<() => void>(); + + useEffect(() => () => cancelRestore.current?.(), []); // if multiple feeds/hooks are rendered prevent effects from running while other modal is open const isNavigationActive = feedName === activeFeedName; @@ -86,7 +94,9 @@ export const usePostModalNavigation = ({ return item.post.slug === pmid || item.post.id === pmid; } if (isBoostedPostAd(item)) { - return item.ad.data.post.slug === pmid || item.ad.data.post.id === pmid; + return ( + item.ad.data.post?.slug === pmid || item.ad.data.post?.id === pmid + ); } return false; @@ -100,23 +110,25 @@ export const usePostModalNavigation = ({ }, [items, pmid, isNavigationActive]); const getPostItem = useCallback( - (index: number) => { - if (index === null || !items[index]) { + ( + index: number | undefined, + ): Pick | null => { + if (index === undefined || !items[index]) { return null; } const item = items[index]; if (item.type === 'post') { - return item as PostItem; + return item; } - if (isBoostedPostAd(item)) { + if (isBoostedPostAd(item) && item.ad.data.post) { // For Post Ads, we need to create a PostItem-like structure // Note: AdItem doesn't have a page property, so we'll use -1 as default return { post: item.ad.data.post, page: -1, index: item.index, - } as PostItem; + }; } return null; @@ -125,22 +137,9 @@ export const usePostModalNavigation = ({ ); const getPost = useCallback( - (index: number) => { - if (index === null || !items[index]) { - return null; - } - - const item = items[index]; - if (item.type === 'post') { - return item.post; - } - if (isBoostedPostAd(item)) { - return item.ad.data.post; - } - - return null; - }, - [items], + (index: number | undefined): Post | null => + getPostItem(index)?.post ?? null, + [getPostItem], ); const onChangeSelected = useCallback( @@ -149,6 +148,11 @@ export const usePostModalNavigation = ({ if (post) { const postId = post.slug || post.id; + const feedScrollPosition = + pmid && !isExtension + ? getScrollPosition(router.asPath, 'post-modal') + : scrollPositionOnFeed.current; + cancelRestore.current?.(); const newPathname = getPathnameWithQuery( basePathname, @@ -164,14 +168,27 @@ export const usePostModalNavigation = ({ // shallow keeps the feed route: the masked `/posts/:id` URL matches the // markdown middleware matcher, and a non-shallow push lets the server // resolve it into a real post-page navigation instead of the modal - await router.push(newPathname, `${webappUrl}posts/${postId}`, { - scroll: false, - shallow: true, - }); + const navigated = await router.push( + newPathname, + `${webappUrl}posts/${postId}`, + { + scroll: false, + shallow: true, + }, + ); + if (navigated && !isExtension && feedScrollPosition !== undefined) { + saveScrollPosition( + `${webappUrl}posts/${postId}`, + feedScrollPosition, + 'post-modal', + ); + } } if (post?.type === PostType.Share) { const item = getPostItem(index); - updatePost(item.page, item.index, { ...post, read: true }); + if (item) { + updatePost(item.page, item.index, { ...post, read: true }); + } } }, [ @@ -182,10 +199,12 @@ export const usePostModalNavigation = ({ router, updatePost, feedName, + pmid, ], ); const onOpenModal = (index: number) => { + cancelRestore.current?.(); if (!pmid) { scrollPositionOnFeed.current = window.scrollY; } @@ -228,7 +247,9 @@ export const usePostModalNavigation = ({ return item.post.slug === pmid || item.post.id === pmid; } if (isBoostedPostAd(item)) { - return item.ad.data.post.slug === pmid || item.ad.data.post.id === pmid; + return ( + item.ad.data.post?.slug === pmid || item.ad.data.post?.id === pmid + ); } return false; @@ -239,17 +260,21 @@ export const usePostModalNavigation = ({ } }, [openedPostIndex, pmid, items, onChangeSelected, isNavigationActive]); - const selectedPostIsAd = isBoostedPostAd(items[openedPostIndex]); + const selectedPostIsAd = + openedPostIndex !== undefined && isBoostedPostAd(items[openedPostIndex]); const result = { postPosition: getPostPosition(), isFetchingNextPage: false, selectedPostIsAd, onCloseModal: async () => { + const feedScrollPosition = isExtension + ? scrollPositionOnFeed.current + : getScrollPosition(router.asPath, 'post-modal'); // Extract query params from baseAsPath to preserve original params like 'id' const baseUrl = new URL(baseAsPath, window.location.origin); const searchParams = new URLSearchParams(baseUrl.search); - await router.push( + const navigated = await router.push( getPathnameWithQuery(basePathname, searchParams), baseAsPath, { @@ -257,12 +282,16 @@ export const usePostModalNavigation = ({ }, ); - window.scrollTo(0, scrollPositionOnFeed.current); - - scrollPositionOnFeed.current = 0; + if (navigated && feedScrollPosition !== undefined) { + saveScrollPosition(baseAsPath, feedScrollPosition); + cancelRestore.current = restoreScrollPosition(feedScrollPosition); + } }, onOpenModal, onPrevious: () => { + if (openedPostIndex === undefined) { + return; + } let index = openedPostIndex - 1; // look for the first post before the current one while (index > 0 && !isPostItem(items[index])) { @@ -284,6 +313,9 @@ export const usePostModalNavigation = ({ onChangeSelected(index); }, onNext: async () => { + if (openedPostIndex === undefined) { + return; + } let index = openedPostIndex + 1; // eslint-disable-next-line no-empty for (; index < items.length && !isPostItem(items[index]); index += 1) {} diff --git a/packages/shared/src/hooks/useScrollRestoration.spec.ts b/packages/shared/src/hooks/useScrollRestoration.spec.ts index 90c8098308..63c2f80a09 100644 --- a/packages/shared/src/hooks/useScrollRestoration.spec.ts +++ b/packages/shared/src/hooks/useScrollRestoration.spec.ts @@ -14,6 +14,7 @@ const FEED_HEIGHT = 20000; let scrollTo: jest.Mock; let pageHeight: number; +let notifyResize: (() => void) | undefined; // The hook keys positions by history entry, so a fresh key per test keeps its // module-level map from leaking between them. let historyKey: string; @@ -21,6 +22,7 @@ let historyKeyCount = 0; const setPageHeight = (height: number) => { pageHeight = height; + notifyResize?.(); }; const setScrollY = (position: number) => { @@ -45,6 +47,18 @@ const renderScrollRestoration = () => renderHook(() => useScrollRestoration()); beforeEach(() => { jest.useFakeTimers(); + notifyResize = undefined; + jest.mocked(ResizeObserver).mockImplementation((callback) => { + const observer = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(() => { + notifyResize = undefined; + }), + }; + notifyResize = () => callback([], observer); + return observer; + }); historyKeyCount += 1; historyKey = `feed-entry-${historyKeyCount}`; @@ -93,10 +107,8 @@ describe('useScrollRestoration', () => { renderScrollRestoration(); - // The previous 1s budget expired here and dropped the user at the bottom of - // the partially rendered feed. act(() => { - jest.advanceTimersByTime(1500); + jest.advanceTimersByTime(2500); }); expect(scrollTo).not.toHaveBeenCalled(); @@ -106,7 +118,7 @@ describe('useScrollRestoration', () => { expect(scrollTo).toHaveBeenCalledWith(0, SAVED_POSITION); }); - it('leaves the user at the top when the page never grows tall enough', () => { + it('abandons restoration before unrelated late page growth', () => { saveFeedPosition(); renderScrollRestoration(); @@ -117,6 +129,11 @@ describe('useScrollRestoration', () => { expect(scrollTo).not.toHaveBeenCalled(); expect(window.scrollY).toBe(0); + expect(jest.getTimerCount()).toBe(0); + + setPageHeight(FEED_HEIGHT); + advanceFrames(); + expect(scrollTo).not.toHaveBeenCalled(); }); it('keeps the saved position when the router resets the scroll to the top', () => { @@ -163,7 +180,7 @@ describe('useScrollRestoration', () => { it('records the position again once the user takes over', () => { saveFeedPosition(); - renderScrollRestoration(); + const { unmount } = renderScrollRestoration(); act(() => { window.dispatchEvent(new Event('touchmove')); @@ -172,6 +189,7 @@ describe('useScrollRestoration', () => { scrollUserTo(1200); // Remounting is the next back navigation to the same history entry. + unmount(); setPageHeight(VIEWPORT_HEIGHT); setScrollY(0); renderScrollRestoration(); @@ -189,4 +207,82 @@ describe('useScrollRestoration', () => { expect(scrollTo).not.toHaveBeenCalled(); }); + + it('cancels a pending restoration when leaving the feed', () => { + saveFeedPosition(); + const { unmount } = renderScrollRestoration(); + advanceFrames(); + setPageHeight(FEED_HEIGHT); + unmount(); + advanceFrames(); + + expect(scrollTo).not.toHaveBeenCalled(); + expect(notifyResize).toBeUndefined(); + }); + + it('restores when the viewport shrinks enough to reach the saved position', () => { + saveFeedPosition(); + setPageHeight(SAVED_POSITION + VIEWPORT_HEIGHT - 100); + renderScrollRestoration(); + advanceFrames(); + expect(scrollTo).not.toHaveBeenCalled(); + + Object.defineProperty(window, 'innerHeight', { + configurable: true, + value: VIEWPORT_HEIGHT - 100, + }); + act(() => window.dispatchEvent(new Event('resize'))); + + expect(scrollTo).toHaveBeenCalledWith(0, SAVED_POSITION); + }); + + it('stops on a changed scroll position even without a wheel or touch event', () => { + saveFeedPosition(); + const { unmount } = renderScrollRestoration(); + setPageHeight(3000); + scrollUserTo(1200); + setPageHeight(FEED_HEIGHT); + advanceFrames(); + expect(scrollTo).not.toHaveBeenCalled(); + + unmount(); + setScrollY(0); + renderScrollRestoration(); + advanceFrames(); + expect(scrollTo).toHaveBeenCalledWith(0, 1200); + }); + + it('records scrolling again after the restoration deadline', () => { + saveFeedPosition(); + const { unmount } = renderScrollRestoration(); + act(() => jest.advanceTimersByTime(10000)); + setPageHeight(FEED_HEIGHT); + scrollUserTo(1200); + unmount(); + setScrollY(0); + renderScrollRestoration(); + advanceFrames(); + expect(scrollTo).toHaveBeenCalledWith(0, 1200); + }); + + it('restores with bounded polling when ResizeObserver is unavailable', () => { + saveFeedPosition(); + const observerConstructor = global.ResizeObserver; + Object.defineProperty(global, 'ResizeObserver', { + value: undefined, + }); + try { + const { unmount } = renderScrollRestoration(); + act(() => jest.advanceTimersByTime(2500)); + setPageHeight(FEED_HEIGHT); + act(() => jest.advanceTimersByTime(120)); + expect(scrollTo).toHaveBeenCalledWith(0, SAVED_POSITION); + expect(jest.getTimerCount()).toBe(0); + unmount(); + } finally { + Object.defineProperty(global, 'ResizeObserver', { + value: observerConstructor, + }); + } + }); }); diff --git a/packages/shared/src/hooks/useScrollRestoration.ts b/packages/shared/src/hooks/useScrollRestoration.ts index 459de25a70..3a7ea02ba1 100644 --- a/packages/shared/src/hooks/useScrollRestoration.ts +++ b/packages/shared/src/hooks/useScrollRestoration.ts @@ -1,94 +1,35 @@ -import { useEffect, useRef } from 'react'; - +import { useEffect } from 'react'; import { useRouter } from 'next/router'; - -const scrollPositions: Record = {}; -// A feed restored from cache needs longer than a second to reconcile on a -// mid-range phone. A shorter budget expires mid-render, which is exactly when -// the page is still too short to hold the saved position. -const RESTORE_TIMEOUT_MS = 2000; - -const getScrollKey = (asPath: string): string => { - if (typeof window === 'undefined') { - return asPath; - } - const historyKey = (window.history.state as { key?: string } | null)?.key; - return historyKey ? `${asPath}:${historyKey}` : asPath; -}; +import { + cancelScrollRestoration, + getScrollPosition, + isScrollRestoring, + restoreScrollPosition, + saveScrollPosition, +} from '../lib/scrollRestoration'; export const useScrollRestoration = (): void => { - const { asPath } = useRouter(); - const isRestoringRef = useRef(false); + const { asPath, events } = useRouter(); useEffect(() => { const handleScroll = () => { - // Our own restore pass and Next's reset-to-top on navigation both emit - // scroll events, and neither is where the user actually was. - if (isRestoringRef.current) { - return; + if (!isScrollRestoring()) { + saveScrollPosition(asPath, window.scrollY); } - - scrollPositions[getScrollKey(asPath)] = window.scrollY; }; window.addEventListener('scroll', handleScroll, { passive: true }); + events?.on('routeChangeStart', cancelScrollRestoration); return () => { window.removeEventListener('scroll', handleScroll); + events?.off('routeChangeStart', cancelScrollRestoration); }; - }, [asPath]); + }, [asPath, events]); useEffect(() => { - const target = scrollPositions[getScrollKey(asPath)] ?? 0; - - if (!target) { - return undefined; - } - - isRestoringRef.current = true; - const deadline = performance.now() + RESTORE_TIMEOUT_MS; - let frame = 0; - - const stop = () => { - isRestoringRef.current = false; - cancelAnimationFrame(frame); - window.removeEventListener('wheel', stop); - window.removeEventListener('touchmove', stop); - window.removeEventListener('keydown', stop); - window.removeEventListener('mousedown', stop); - }; - - const tick = () => { - const maxScroll = - document.documentElement.scrollHeight - window.innerHeight; - - // Scrolling before the page is tall enough clamps to the bottom of what - // has rendered so far, so wait rather than settle for a wrong position. - if (maxScroll >= target) { - window.scrollTo(0, target); - stop(); - return; - } - - // Out of budget: the feed never got there, so leave the user at the top. - if (performance.now() >= deadline) { - stop(); - return; - } - - frame = requestAnimationFrame(tick); - }; - - // Restoring must never fight the user, any real input ends the attempt. - // `mousedown` covers scrollbar drags, which emit no wheel event. - window.addEventListener('wheel', stop, { passive: true }); - window.addEventListener('touchmove', stop, { passive: true }); - window.addEventListener('keydown', stop); - window.addEventListener('mousedown', stop); - - frame = requestAnimationFrame(tick); - - return stop; + const target = getScrollPosition(asPath); + return target ? restoreScrollPosition(target) : undefined; }, [asPath]); }; diff --git a/packages/shared/src/lib/scrollRestoration.spec.ts b/packages/shared/src/lib/scrollRestoration.spec.ts new file mode 100644 index 0000000000..6a6c0b8ce9 --- /dev/null +++ b/packages/shared/src/lib/scrollRestoration.spec.ts @@ -0,0 +1,20 @@ +import { getScrollPosition, saveScrollPosition } from './scrollRestoration'; + +it('evicts old history entries while retaining recent positions', () => { + for (let index = 0; index < 1000; index += 1) { + window.history.replaceState({ key: `bounded-${index}` }, '', '/'); + saveScrollPosition('/', index); + } + expect(getScrollPosition('/')).toBe(999); + window.history.replaceState({ key: 'bounded-0' }, '', '/'); + expect(getScrollPosition('/')).toBeUndefined(); +}); + +it('keeps modal origins separate from scrolling the post entry', () => { + window.history.replaceState({ key: 'modal-position' }, '', '/posts/test'); + saveScrollPosition('/posts/test', 5000, 'post-modal'); + saveScrollPosition('/posts/test', 0); + + expect(getScrollPosition('/posts/test', 'post-modal')).toBe(5000); + expect(getScrollPosition('/posts/test')).toBe(0); +}); diff --git a/packages/shared/src/lib/scrollRestoration.ts b/packages/shared/src/lib/scrollRestoration.ts new file mode 100644 index 0000000000..417ee98d5d --- /dev/null +++ b/packages/shared/src/lib/scrollRestoration.ts @@ -0,0 +1,119 @@ +const RESTORE_TIMEOUT_MS = 10000; +const RESTORE_POLL_INTERVAL_MS = 100; +const MAX_SCROLL_POSITIONS = 200; + +type ScrollPositionKind = 'page' | 'post-modal'; + +const scrollPositions = new Map(); +let activeRestoration: (() => void) | undefined; + +const getScrollKey = (asPath: string, kind: ScrollPositionKind): string => { + if (typeof window === 'undefined') { + return `${kind}:${asPath}`; + } + + const url = new URL(asPath, window.location.origin); + const historyKey = (window.history.state as { key?: string } | null)?.key; + return `${kind}:${url.pathname}${url.search}${url.hash}:${historyKey ?? ''}`; +}; + +export const getScrollPosition = ( + asPath: string, + kind: ScrollPositionKind = 'page', +): number | undefined => scrollPositions.get(getScrollKey(asPath, kind)); + +export const saveScrollPosition = ( + asPath: string, + position: number, + kind: ScrollPositionKind = 'page', +): void => { + const key = getScrollKey(asPath, kind); + scrollPositions.delete(key); + scrollPositions.set(key, position); + if (scrollPositions.size > MAX_SCROLL_POSITIONS) { + const oldestKey = scrollPositions.keys().next().value; + if (oldestKey !== undefined) { + scrollPositions.delete(oldestKey); + } + } +}; + +export const isScrollRestoring = (): boolean => !!activeRestoration; + +export const cancelScrollRestoration = (): void => activeRestoration?.(); + +export const restoreScrollPosition = (target: number): (() => void) => { + cancelScrollRestoration(); + const initialPosition = window.scrollY; + const scrollKey = getScrollKey(window.location.href, 'page'); + const controller = new AbortController(); + let observer: ResizeObserver | undefined; + let frame = 0; + let timeout = 0; + let poll = 0; + let stopped = false; + + const stop = () => { + stopped = true; + cancelAnimationFrame(frame); + window.clearTimeout(timeout); + window.clearInterval(poll); + observer?.disconnect(); + controller.abort(); + if (activeRestoration === stop) { + activeRestoration = undefined; + } + }; + + const restore = () => { + if (stopped) { + return; + } + if (getScrollKey(window.location.href, 'page') !== scrollKey) { + stop(); + return; + } + + const maxScroll = + document.documentElement.scrollHeight - window.innerHeight; + if (maxScroll >= target) { + window.scrollTo(0, target); + stop(); + } + }; + + const scheduleRestore = () => { + cancelAnimationFrame(frame); + frame = requestAnimationFrame(restore); + }; + + activeRestoration = stop; + timeout = window.setTimeout(stop, RESTORE_TIMEOUT_MS); + if (typeof ResizeObserver === 'undefined') { + poll = window.setInterval(scheduleRestore, RESTORE_POLL_INTERVAL_MS); + } else { + observer = new ResizeObserver(scheduleRestore); + observer.observe(document.body); + } + + const { signal } = controller; + window.addEventListener('resize', restore, { signal }); + window.addEventListener('popstate', stop, { signal }); + window.addEventListener('wheel', stop, { passive: true, signal }); + window.addEventListener('touchmove', stop, { passive: true, signal }); + window.addEventListener('keydown', stop, { signal }); + window.addEventListener('mousedown', stop, { signal }); + window.addEventListener( + 'scroll', + () => { + if (window.scrollY !== initialPosition) { + stop(); + saveScrollPosition(window.location.href, window.scrollY); + } + }, + { passive: true, signal }, + ); + scheduleRestore(); + + return stop; +};