Skip to content

Commit

Permalink
feat: store scroll position for page with the same location
Browse files Browse the repository at this point in the history
This will mean that even when not using back/forward buttons,
the scroll position will be restored as long as it is the same
url with the same parameters.
  • Loading branch information
Tobias Lengsholz committed Jan 7, 2022
1 parent a71adb4 commit f1958ae
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 8 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ Default: true

True to set Next.js Link default `scroll` property to `false`, false otherwise. Since the goal of this package is to manually control the scroll, you don't want Next.js default behavior of scrolling to top when clicking links.

#### restoreSameLocation?

Type: `boolean`
Default: false

True to enable scroll restoration when the same location is navigated. By default, only going backwards and forward in the browser history will cause the scroll position to be restored.

#### children

Type: `ReactNode`
Expand Down
26 changes: 21 additions & 5 deletions src/RouterScrollProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,46 @@ const useDisableNextLinkScroll = (disableNextLinkScroll) => {
}, [disableNextLinkScroll]);
};

const useScrollBehavior = (shouldUpdateScroll) => {
const useScrollBehavior = (shouldUpdateScroll, restoreSameLocation) => {
// Create NextScrollBehavior instance once.
const shouldUpdateScrollRef = useRef();
const scrollBehaviorRef = useRef();
const mounted = useRef(false);

shouldUpdateScrollRef.current = shouldUpdateScroll;

useEffect(() => {
if (scrollBehaviorRef.current) {
scrollBehaviorRef.current.setRestoreSameLocation(restoreSameLocation);
}
}, [restoreSameLocation]);

if (!scrollBehaviorRef.current) {
scrollBehaviorRef.current = new NextScrollBehavior(
(...args) => shouldUpdateScrollRef.current(...args),
restoreSameLocation,
);
}

// Destroy NextScrollBehavior instance when unmonting.
useEffect(() => () => scrollBehaviorRef.current.stop(), []);
// Destroy NextScrollBehavior instance when unmounting.
useEffect(() => {
mounted.current = true;

return () => {
mounted.current = false;
scrollBehaviorRef.current?.stop();
};
}, []);

return scrollBehaviorRef.current;
};

const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, children }) => {
const ScrollBehaviorProvider = ({ disableNextLinkScroll, shouldUpdateScroll, restoreSameLocation, children }) => {
// Disable next <Link> scroll or not.
useDisableNextLinkScroll(disableNextLinkScroll);

// Get the scroll behavior, creating it just once.
const scrollBehavior = useScrollBehavior(shouldUpdateScroll);
const scrollBehavior = useScrollBehavior(shouldUpdateScroll, restoreSameLocation);

// Create facade to use as the provider value.
const providerValue = useMemo(() => ({
Expand All @@ -75,6 +90,7 @@ ScrollBehaviorProvider.defaultProps = {
ScrollBehaviorProvider.propTypes = {
disableNextLinkScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
restoreSameLocation: PropTypes.bool,
children: PropTypes.node,
};

Expand Down
66 changes: 66 additions & 0 deletions src/RouterScrollProvider.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import RouterScrollContext from './context';
import RouterScrollProvider from './RouterScrollProvider';

let mockNextScrollBehavior;
let mockStateStorage;

jest.mock('./scroll-behavior', () => {
const NextScrollBehavior = jest.requireActual('./scroll-behavior');
Expand All @@ -25,6 +26,23 @@ jest.mock('./scroll-behavior', () => {
return SpiedNextScrollBehavior;
});

jest.mock('./scroll-behavior/StateStorage', () => {
const StateStorage = jest.requireActual('./scroll-behavior/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;
});

afterEach(() => {
mockNextScrollBehavior = undefined;
});
Expand Down Expand Up @@ -173,3 +191,51 @@ it('should allow changing shouldUpdateScroll', () => {
expect(shouldUpdateScroll1).toHaveBeenCalledTimes(1);
expect(shouldUpdateScroll2).toHaveBeenCalledTimes(1);
});

it('allows setting restoreSameLocation', () => {
const MyComponent = () => {
useContext(RouterScrollContext);

return null;
};

render(
<RouterScrollProvider>
<MyComponent />
</RouterScrollProvider>,
);

expect(mockStateStorage.restoreSameLocation).toBe(false);

render(
<RouterScrollProvider restoreSameLocation>
<MyComponent />
</RouterScrollProvider>,
);

expect(mockStateStorage.restoreSameLocation).toBe(true);
});

it('allows changing restoreSameLocation', () => {
const MyComponent = () => {
useContext(RouterScrollContext);

return null;
};

const { rerender } = render(
<RouterScrollProvider>
<MyComponent />
</RouterScrollProvider>,
);

expect(mockStateStorage.restoreSameLocation).toBe(false);

rerender(
<RouterScrollProvider restoreSameLocation>
<MyComponent />
</RouterScrollProvider>,
);

expect(mockStateStorage.restoreSameLocation).toBe(true);
});
11 changes: 9 additions & 2 deletions src/scroll-behavior/NextScrollBehavior.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ export default class NextScrollBehavior extends ScrollBehavior {
_context;
_prevContext;
_debounceSavePositionMap = new Map();
_stateStorage;

constructor(shouldUpdateScroll) {
constructor(shouldUpdateScroll, restoreSameLocation = false) {
setupRouter();
const stateStorage = new StateStorage({ restoreSameLocation });

super({
addNavigationListener: (callback) => {
Expand All @@ -37,10 +39,11 @@ export default class NextScrollBehavior extends ScrollBehavior {
};
},
getCurrentLocation: () => this._context.location,
stateStorage: new StateStorage(),
stateStorage,
shouldUpdateScroll,
});

this._stateStorage = stateStorage;
this._context = this._createContext();
this._prevContext = null;

Expand All @@ -64,6 +67,10 @@ export default class NextScrollBehavior extends ScrollBehavior {
super.updateScroll(prevContext, context);
}

setRestoreSameLocation(newValue = false) {
this._stateStorage.restoreSameLocation = newValue;
}

stop() {
super.stop();

Expand Down
58 changes: 58 additions & 0 deletions src/scroll-behavior/NextScrollBehavior.browser.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ describe('constructor()', () => {
expect(scrollBehavior._shouldUpdateScroll).toBe(shouldUpdateScroll);
});

it('should forward restoreSameLocation to StateStorage', () => {
scrollBehavior = new NextScrollBehavior(() => {}, true);

expect(mockStateStorage.restoreSameLocation).toBe(true);
});

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';
Expand Down Expand Up @@ -377,3 +383,55 @@ it('should update scroll correctly based on history changes', async () => {

expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]);
});

it('should restore scroll position if same url is opened', async () => {
scrollBehavior = new NextScrollBehavior(undefined, true);

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.pushState({ as: '/' }, '', '/');
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.pushState({ as: '/page2' }, '', '/page2');
Router.events.emit('routeChangeComplete', '/');
await sleep(10);

location.key = history.state.locationKey;
scrollBehavior.updateScroll();

expect(scrollBehavior.scrollToTarget).toHaveBeenNthCalledWith(4, window, [0, 123]);
});
12 changes: 11 additions & 1 deletion src/scroll-behavior/StateStorage.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
/* istanbul ignore file */
import { readState, saveState } from 'history/lib/DOMStateStorage';
import md5 from 'md5';

const STATE_KEY_PREFIX = '@@scroll|';

const hashLocation = (location) => md5(`${location.host}${location.pathname}${location.hash}${location.search}`);

export default class StateStorage {
restoreSameLocation;

constructor({ restoreSameLocation }) {
this.restoreSameLocation = restoreSameLocation || false;
}

read(location, key) {
return readState(this.getStateKey(location, key));
}
Expand All @@ -13,7 +22,8 @@ export default class StateStorage {
}

getStateKey(location, key) {
const locationKey = location.key ?? '_default';
const locationKey = this.restoreSameLocation ? hashLocation(location) : (location.key ?? '_default');

const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;

return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
Expand Down

0 comments on commit f1958ae

Please sign in to comment.