Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add toggle to restore scroll position for same locations #15

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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