import { pick } from 'lodash'; import Router from 'next/router'; import NextScrollBehavior from './NextScrollBehavior.browser'; const sleep = (duration) => new Promise((resolve) => setTimeout(resolve, duration)); let mockStateStorage; let scrollBehavior; window.scrollTo = jest.fn(); jest.spyOn(window, 'addEventListener'); jest.spyOn(window, 'removeEventListener'); jest.spyOn(Router.events, 'on'); jest.spyOn(Router.events, 'off'); jest.mock('./StateStorage', () => { const StateStorage = jest.requireActual('./StateStorage'); class SpiedStateStorage extends StateStorage { constructor(...args) { super(...args); mockStateStorage = this; // eslint-disable-line consistent-this jest.spyOn(this, 'save'); jest.spyOn(this, 'read'); } } return SpiedStateStorage; }); beforeAll(() => { history.scrollRestoration = 'auto'; }); afterEach(() => { mockStateStorage = undefined; scrollBehavior?.stop(); window.pageYOffset = 0; jest.clearAllMocks(); }); describe('constructor()', () => { beforeAll(() => { Object.defineProperty(navigator, 'userAgent', { value: navigator.userAgent, writable: true }); Object.defineProperty(navigator, 'platform', { value: navigator.platform, writable: true }); }); it('should setup router', () => { const beforePopState = Router.beforePopState; scrollBehavior = new NextScrollBehavior(); expect(Router.beforePopState).not.toBe(beforePopState); }); it('should setup listeners', () => { scrollBehavior = new NextScrollBehavior(); expect(window.addEventListener).toHaveBeenCalledTimes(1); expect(window.removeEventListener).toHaveBeenCalledTimes(0); expect(Router.events.on).toHaveBeenCalledTimes(1); expect(Router.events.off).toHaveBeenCalledTimes(0); }); it('should forward shouldUpdateScroll to ScrollBehavior', () => { const shouldUpdateScroll = () => {}; scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); expect(scrollBehavior._shouldUpdateScroll).toBe(shouldUpdateScroll); }); it('should set history.scrollRestoration to manual, even on Safari iOS', () => { // eslint-disable-next-line max-len navigator.userAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/605.1'; navigator.platform = 'iPhone'; scrollBehavior = new NextScrollBehavior(); expect(history.scrollRestoration).toBe('manual'); }); it('should set current context correctly', () => { Router.pathname = '/bar'; Router.asPath = '/bar'; Router.query = {}; const router = pick(Router, 'pathname', 'asPath', 'query'); scrollBehavior = new NextScrollBehavior(); expect(scrollBehavior._context).toEqual({ location, router }); expect(scrollBehavior._prevContext).toBe(null); }); }); describe('stop()', () => { it('should unregister all elements when stopping', () => { scrollBehavior = new NextScrollBehavior(); jest.spyOn(scrollBehavior, 'unregisterElement'); scrollBehavior.registerElement('foo', document.createElement('div')); scrollBehavior.stop(); expect(scrollBehavior.unregisterElement).toHaveBeenCalledTimes(1); }); it('should cancel all ongoing _setPosition debouncers', async () => { scrollBehavior = new NextScrollBehavior(); expect(mockStateStorage.save).toHaveBeenCalledTimes(0); window.dispatchEvent(new CustomEvent('scroll')); await sleep(30); window.dispatchEvent(new CustomEvent('scroll')); await sleep(30); scrollBehavior.stop(); await sleep(200); expect(mockStateStorage.save).toHaveBeenCalledTimes(1); }); it('should call super', () => { const scrollBehavior = new NextScrollBehavior(); expect(window.removeEventListener).toHaveBeenCalledTimes(0); expect(Router.events.off).toHaveBeenCalledTimes(0); scrollBehavior.stop(); expect(window.removeEventListener).toHaveBeenCalledTimes(1); expect(Router.events.off).toHaveBeenCalledTimes(1); }); }); describe('updateScroll()', () => { it('should inject prevContext and context', () => { const shouldUpdateScroll = jest.fn(() => false); Router.pathname = '/'; Router.asPath = '/?foo=1'; Router.query = { foo: '1' }; scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); scrollBehavior.updateScroll(); const router1 = pick(Router, 'pathname', 'asPath', 'query'); expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 1, null, { location: expect.any(Location), router: router1 }, ); Router.pathname = '/bar'; Router.asPath = '/bar'; Router.query = {}; Router.events.emit('routeChangeComplete'); scrollBehavior.updateScroll(); const router2 = pick(Router, 'pathname', 'asPath', 'query'); expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 2, { location: expect.any(Location), router: router1 }, { location: expect.any(Location), router: router2 }, ); }); it('should shallow merge prevContext and context', () => { const shouldUpdateScroll = jest.fn(() => false); Router.pathname = '/'; Router.asPath = '/?foo=1'; Router.query = { foo: '1' }; scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); scrollBehavior.updateScroll({ foo: 'bar' }, { foz: 'baz' }); const router = pick(Router, 'pathname', 'asPath', 'query'); expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 1, { foo: 'bar' }, { foz: 'baz', location: expect.any(Location), router }, ); }); it('should call super', () => { const shouldUpdateScroll = jest.fn(() => false); scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); scrollBehavior.updateScroll(); expect(shouldUpdateScroll).toHaveBeenCalledTimes(1); }); }); describe('registerElement()', () => { it('should inject context', () => { const element = document.createElement('div'); const shouldUpdateScroll = jest.fn(() => false); scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); scrollBehavior.registerElement('foo', element, shouldUpdateScroll); const router = pick(Router, 'pathname', 'asPath', 'query'); expect(shouldUpdateScroll).toHaveBeenCalledTimes(1); expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 1, null, { location: expect.any(Location), router }, ); }); it('should shallow merge context', () => { const element = document.createElement('div'); const shouldUpdateScroll = jest.fn(() => false); scrollBehavior = new NextScrollBehavior(shouldUpdateScroll); scrollBehavior.registerElement('foo', element, shouldUpdateScroll, { foo: 'bar' }); const router = pick(Router, 'pathname', 'asPath', 'query'); expect(shouldUpdateScroll).toHaveBeenCalledTimes(1); expect(shouldUpdateScroll).toHaveBeenNthCalledWith( 1, null, { foo: 'bar', location: expect.any(Location), router }, ); }); it('should call super', () => { const element = document.createElement('div'); scrollBehavior = new NextScrollBehavior(); scrollBehavior.registerElement('foo', element); expect(scrollBehavior._scrollElements.foo).toBeTruthy(); }); }); describe('unregisterElement()', () => { it('should cancel all ongoing _setPosition debouncers', async () => { const element = document.createElement('div'); scrollBehavior = new NextScrollBehavior(); scrollBehavior.registerElement('foo', element); expect(mockStateStorage.save).toHaveBeenCalledTimes(0); element.dispatchEvent(new CustomEvent('scroll')); await sleep(30); element.dispatchEvent(new CustomEvent('scroll')); await sleep(30); scrollBehavior.unregisterElement('foo'); await sleep(200); expect(mockStateStorage.save).toHaveBeenCalledTimes(1); }); it('should be idempotent', () => { scrollBehavior = new NextScrollBehavior(); expect(() => scrollBehavior.unregisterElement('foo')).not.toThrow(); }); it('should call super', () => { const element = document.createElement('div'); scrollBehavior = new NextScrollBehavior(); scrollBehavior.registerElement('foo', element); scrollBehavior.unregisterElement('foo'); expect(scrollBehavior._scrollElements.foo).toBe(undefined); }); }); describe('on route change complete', () => { it('should update prevContext and context', () => { Router.pathname = '/'; Router.asPath = '/?foo=1'; Router.query = { foo: '1' }; const router1 = pick(Router, 'pathname', 'asPath', 'query'); scrollBehavior = new NextScrollBehavior(); Router.pathname = '/bar'; Router.asPath = '/bar'; Router.query = {}; const router2 = pick(Router, 'pathname', 'asPath', 'query'); Router.events.emit('routeChangeComplete'); expect(scrollBehavior._context).toEqual({ location: expect.any(Location), router: router2 }); expect(scrollBehavior._prevContext).toEqual({ location: expect.any(Location), router: router1 }); }); it('should not save scroll position', async () => { scrollBehavior = new NextScrollBehavior(); Router.events.emit('routeChangeComplete'); await sleep(50); expect(mockStateStorage.save).toHaveBeenCalledTimes(0); }); }); it('should update scroll correctly based on history changes', async () => { scrollBehavior = new NextScrollBehavior(); jest.spyOn(scrollBehavior, 'scrollToTarget'); Object.defineProperty(scrollBehavior, '_numWindowScrollAttempts', { get: () => 1000, set: () => {}, }); // First page history.replaceState({ as: '/' }, '', '/'); Router.events.emit('routeChangeComplete', '/'); window.pageYOffset = 0; scrollBehavior.updateScroll(); await sleep(10); expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(1, window, [0, 0]); // Navigate to new page & scroll history.pushState({ as: '/page2' }, '', '/page2'); Router.events.emit('routeChangeComplete', '/'); window.pageYOffset = 123; window.dispatchEvent(new CustomEvent('scroll')); await sleep(200); scrollBehavior.updateScroll(); expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(2, window, [0, 123]); // Go to previous page history.back(); Router.events.emit('routeChangeComplete', '/'); await sleep(10); location.key = history.state.locationKey; scrollBehavior.updateScroll(); expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(3, window, [0, 0]); // Go to next page history.forward(); Router.events.emit('routeChangeComplete', '/'); await sleep(10); location.key = history.state.locationKey; scrollBehavior.updateScroll(); expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]); });