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 82fa624
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 13 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
55 changes: 50 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"history": "^3.0.0",
"hoist-non-react-statics": "^3.3.2",
"lodash": "^4.17.21",
"md5": "^2.3.0",
"prop-types": "^15.7.2",
"scroll-behavior": "^0.11.0"
},
Expand Down
18 changes: 13 additions & 5 deletions src/RouterScrollProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,38 @@ const useDisableNextLinkScroll = (disableNextLinkScroll) => {
}, [disableNextLinkScroll]);
};

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

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(() => () => 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 +82,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]);
});
Loading

0 comments on commit 82fa624

Please sign in to comment.