UNPKG

navigation-stack

Version:

Handles navigation in a web browser

554 lines (484 loc) 21 kB
/* eslint-disable no-underscore-dangle */ import PageScrollPositionSetter from './PageScrollPositionSetter'; import ScrollPositionSaver from './ScrollPositionSaver'; import ScrollPositionSetter from './ScrollPositionSetter'; import { PAGE_SCROLLABLE_CONTAINER_KEY } from './constants'; import LocationDataStorage from '../data-storage/LocationDataStorage'; import debug from '../debug'; function areEqualScrollPositions(scrollPosition1, scrollPosition2) { let i = 0; while (i < scrollPosition1.length) { if (scrollPosition1[i] !== scrollPosition2[i]) { return false; } i++; } return true; } export default class ScrollPositionRestoration { constructor(session, _options) { this._scrollPosition = session.environment.scrollPosition; this._sessionLifecycle = session.lifecycle; this._locationDataStorage = new LocationDataStorage(session, { namespace: 'navigation-stack/scroll-position', }); this._scrollPositionSaver = new ScrollPositionSaver({ scrollPosition: this._scrollPosition, saveScrollPositionForLocation: this._saveScrollPositionForLocation, getScrollableContainers: () => this._scrollableContainers, getLocation: () => this._location, shouldSaveScrollPosition: () => !this._doNotSaveScrollPosition, }); // Originally, `scrollableContainerKey` was auto-generated, // but then it didn't work with the concept of dynamically adding or removing // scrollable containers after `ScrollPositionRestoration` has already started. // // this._scrollableContainerKeyCounter = 0; this._scrollableContainers = {}; // Add page scrollable container. this._scrollableContainers[PAGE_SCROLLABLE_CONTAINER_KEY] = { scrollableContainer: undefined, // Using this option, a developer could theoretically provide their own implementation // of setting a scroll position. For example, it could use "smooth" (animated) scrolling, etc. // This could be part of the public API if anyone provided a sensible real-world use case for it. scrollPositionSetter: (_options && _options._pageScrollPositionSetter) || // The default page scroll position setter. new PageScrollPositionSetter(), // This function is only used in tests. // There seems to be no use of it in real life, hence it's not public API. // It's only used in tests. _getSavedScrollPositionOnLocationChange: _options && _options._getSavedPageScrollPositionOnLocationChange, // This function is only used in tests. // There seems to be no use of it in real life, hence it's not public API. // It's only used in tests. _shouldSetScrollPositionOnLocationChange: _options && _options._shouldSetPageScrollPositionOnLocationChange, }; } addScrollableContainer( scrollableContainerKey, scrollableContainer, _options, ) { // Originally, `scrollableContainerKey` was auto-generated, // but then it didn't work with the concept of dynamically adding or removing // scrollable containers after `ScrollPositionRestoration` has already started. // // this._scrollableContainerKeyCounter++; // const scrollableContainerKey = String(this._scrollableContainerKeyCounter); // Validate `scrollableContainerKey`. if (scrollableContainerKey === PAGE_SCROLLABLE_CONTAINER_KEY) { throw new Error( `Scrollable container key "${scrollableContainerKey}" is not allowed`, ); } // Check that it hasn't already been added. if (this._scrollableContainers[scrollableContainerKey]) { throw new Error( `Scrollable container key "${scrollableContainerKey}" is already added`, ); } debug('add scrollable container', scrollableContainerKey); // Add scrollable container entry. this._scrollableContainers[scrollableContainerKey] = { // Scrollable container element. scrollableContainer, // Using this option, a developer could theoretically provide their own implementation // of setting a scroll position. For example, it could use "smooth" (animated) scrolling, etc. // This could be part of the public API if anyone provided a sensible real-world use case for it. scrollPositionSetter: (_options && _options._scrollPositionSetter) || // The default basic "immediate" scroll position setter. new ScrollPositionSetter(), // This function is only used in tests. // There seems to be no use of it in real life, hence it's not public API. // It's only used in tests. _shouldSetScrollPositionOnLocationChange: _options && _options._shouldSetScrollPositionOnLocationChange, // This function is only used in tests. // There seems to be no use of it in real life, hence it's not public API. // It's only used in tests. _getSavedScrollPositionOnLocationChange: _options && _options._getSavedScrollPositionOnLocationChange, }; // Scrollable containers could be added at any time, including page mount. // For example, a user navigates "Back" to a previous page where there's // a "unique" scrollable container that's only present on that page. // In that case, the previously-saved scroll position inside the scrollable container // should be restored, if it pre-exists. Otherwise, if it doesn't pre-exist, // the initial scroll position should be saved for the scrollable container. if (this._location) { const previouslySavedScrollPosition = this._getSavedScrollPositionForLocation( this._location, scrollableContainerKey, ); if (previouslySavedScrollPosition) { debug( 'restore scroll position on add scrollable container', this._location.pathname, scrollableContainerKey, previouslySavedScrollPosition, ); this._scrollPosition.setScrollableContainerScrollPosition( scrollableContainer, previouslySavedScrollPosition, ); } else { debug( 'save scroll position on add scrollable container', this._location.pathname, scrollableContainerKey, ); this._scrollPositionSaver.saveScrollableContainerScrollPosition( scrollableContainerKey, scrollableContainer, ); } } if (this._started) { this._scrollPositionSaver._scrollPositionAutoSaver.addScrollableContainerScrollListener( scrollableContainerKey, ); } // Removes the scrollable container. return () => { debug('remove scrollable container', scrollableContainerKey); this._scrollPositionSaver._scrollPositionAutoSaver.cancelSaveScrollableContainerScrollPosition( scrollableContainerKey, ); this._scrollPositionSaver._scrollPositionAutoSaver.removeScrollableContainerScrollListener( scrollableContainerKey, ); delete this._scrollableContainers[scrollableContainerKey]; }; } start() { // "Foolproof" check. if (this._started) { throw new Error('Already started'); } this._started = true; this._disableAutomaticScrollRestoration(); this._scrollPositionSaver.start(); this._removePageStatusListener = this._sessionLifecycle.addExecutionStatusListener( this._sessionExecutionStatusListener, ); } // This method is "idempotent", i.e. it can be called multiple times. stop() { // "Foolproof" check. if (!this._started) { return; // throw new Error('Not started'); } this._started = false; this._enableAutomaticScrollRestoration(); // If there's any scroll position still scheduled to be set, cancel it. this._cancelAnyPendingSettingOfScrollPosition(); this._scrollPositionSaver.stop(); this._removePageStatusListener(); } _cancelAnyPendingSettingOfScrollPosition() { for (const scrollableContainerEntry of Object.values( this._scrollableContainers, )) { scrollableContainerEntry.scrollPositionSetter.cancel(); } } // Once configured, scroll restoration mode persists across page reloads. // I.e. even if a user refreshes the page in a web browser, the custom // `window.history.scrollRestoration` value will still remain. // // And since it's set to a custom value of "manual", the web browser // won't attempt to restore the scroll position on page load // which it would otherwise normally do. // // So what happens if the website is fully server-side rendered? // It will wait for the javascript code to be downloaded an executed first // and only then that javascript code will programmatically restore the // previously-saved scroll position. // // That would work but it also wouldn't be the most efficient way to do that. // Instead, `window.history.scrollRestoration` value could be reset to default beforehand // so that when the page finishes refreshing, the web browser could automatically // restore the scroll position without waiting for the javascript code to download and run. // // To reset `window.history.scrollRestoration` value to default beforehand, // the code should be notified when the browser tab is about to be terminated or suspended. // Terminating could happen for various reasons such as not enough memory, code crash, etc. // Suspending is treated equally to terminating because once suspended, it could potentially be // terminated afterwards without the code being able to do its stuff while it's suspended. // // One could consider this feature a minor user experience optimization that relies on the web browser // to correctly restore the page scroll every time on page refresh, which it normally does. // _sessionExecutionStatusListener = ({ running }) => { if (running) { debug('▶ running'); this._disableAutomaticScrollRestoration(); } else { debug('⏹ not running'); this._enableAutomaticScrollRestoration(); // There might be previous scroll position already saved in the data storage. // Overwrite that previously-saved scroll position with the most up-to-date one // just so that there's no stale scroll position left over in the data storage. // Alternatively, it could just clear any saved scroll position for this page, // since the web browser's automatic scroll restoration is now enabled. this._scrollPositionSaver.saveScrollPosition(); } }; // willRenderLocation = (location) => { // // "Foolproof" check. // if (!this._started) { // throw new Error('`ScrollPositionRestoration` not started'); // } // // // For the initial location, it doesn't do anything. // if (location.operation === Operations.INIT) { // return; // } // // // Since the current page will no longer be rendered, // // cancel any scheduled setting of scroll position on it. // this._cancelAnyPendingSettingOfScrollPosition(); // // // The previous page may have scheduled an auto-save of scroll position. // // Since the previous page is no longer rendered, its scroll position can no longer be obtained, // // so any scheduled scroll position auto-save produres are irrelevant now. // this._scrollPositionSaver.cancelPreviouslyScheduledAutoSave(); // // // Save the current scroll position on the current page while it's still rendered. // // This saved scroll position could later be restored in case of returing to this page. // // Even if the current scroll position is a default one (scrolled to top), it should still // // be saved in order to overwrite any potential previously-saved non-default scroll position. // this._scrollPositionSaver.saveScrollPosition(); // }; // Should be called whenever a different location has been rendered (i.e. immediately after). // Returns a Promise that resolves when finished restoring scroll position. // There's no need to await for that Promise. It's just there because it exists. locationRendered(location) { // Validate that `location` has a `key`. if (!location.key) { throw new Error('`location` must have a `key`'); } debug('rendered location', location.pathname); this._prevLocation = this._location; this._location = location; this._scrollPosition.init(); if (!this._started) { // `this.start()` requires `this._location` to be set. this.start(); // The initial page might've been server-side rendered which means that // by the time this javascript code is downloaded and executed by the web browser, // the user might've already scrolled the page to some position, // and all those pre-javascript scroll events won't be registered by `ScrollPositionSaver`. // If the user doesn't scroll after javascript is loaded and just navigates to a new page, // the initial page won't have any saved scroll position to restore on "Back" navigation. // Hence, it should explicitly save the current scroll position at the start of operation. if (!this._isDefaultScrollPosition()) { // `this._scrollPositionSaver.saveScrollPosition()` requires `this._location` to be set. this._scrollPositionSaver.saveScrollPosition(); } } // The previous page may have scheduled an auto-save of scroll position. // Since the previous page is no longer rendered, its scroll position can no longer be obtained, // so any scheduled scroll position auto-save produres are irrelevant now. this._scrollPositionSaver.cancelPreviouslyScheduledAutoSave(); // If it was in the middle of setting scroll position for a previous location, cancel it. this._cancelAnyPendingSettingOfScrollPosition(); // Set the scroll position for the new page: // either restore a previously-saved one or set it to a default scroll position. return this._setScrollPosition(); } // Tells if the current scroll position is the default one. _isDefaultScrollPosition() { for (const scrollableContainerKey of Object.keys( this._scrollableContainers, )) { if (scrollableContainerKey === PAGE_SCROLLABLE_CONTAINER_KEY) { if ( !areEqualScrollPositions( this._scrollPosition.getPageScrollPosition(), this._getDefaultScrollPosition(), ) ) { return false; } } else { const scrollableContainerEntry = this._scrollableContainers[scrollableContainerKey]; if ( !areEqualScrollPositions( this._scrollPosition.getScrollableContainerScrollPosition( scrollableContainerEntry.scrollableContainer, ), this._getDefaultScrollPosition(), ) ) { return false; } } } return true; } // Restores scroll position. // Returns a Promise that resolves when finished setting scroll position. // There's no need to await for this Promise. It just exists. _setScrollPosition() { return Promise.all( Object.keys(this._scrollableContainers).map((scrollableContainerKey) => { const scrollableContainerEntry = this._scrollableContainers[scrollableContainerKey]; // This function is only used in tests. // There seems to be no use of it in real life, hence it's not public API. // It's only used in tests. if ( scrollableContainerEntry._shouldSetScrollPositionOnLocationChange ) { if ( !scrollableContainerEntry._shouldSetScrollPositionOnLocationChange( this._location, this._prevLocation, ) ) { return Promise.resolve(); } } // Scroll position (or anchor) to set. let scrollPositionOrAnchorToSet; // This function is only used in tests. // There seems to be no use of it in real life, hence it's not public API. // It's only used in tests. if (scrollableContainerEntry._getSavedScrollPositionOnLocationChange) { scrollPositionOrAnchorToSet = scrollableContainerEntry._getSavedScrollPositionOnLocationChange( this._location, this._prevLocation, ); } // Get scroll position (or anchor) to set. if (!scrollPositionOrAnchorToSet) { scrollPositionOrAnchorToSet = scrollableContainerKey === PAGE_SCROLLABLE_CONTAINER_KEY ? this._getPageScrollPositionOrAnchorToSet(this._location) : this._getScrollableContainerScrollPositionToSet( this._location, scrollableContainerKey, ); } debug( 'restore scroll position', this._location.pathname, scrollableContainerKey, scrollPositionOrAnchorToSet, ); // Set scroll position of scrollable container. return scrollableContainerEntry.scrollPositionSetter.set( scrollableContainerEntry.scrollableContainer, scrollPositionOrAnchorToSet, this._scrollPosition, ); }), ); } // Overrides the default `window.history.scrollRestoration` value. // This prevents the web browser from interfering by disabling its // automatic scroll position restoration on "Back"/"Forward" navigation. // Instead, the application will have to do it manually. // The reason is that when the web browser performs "Back" or "Forward" navigation, // it updates the URL in the address bar immediately, and it also attempts to // automatically restore scroll position immediately, but the thing is that // the application might have delayed rendering of the page due to various reasons // such as performance considerations or the architecture of the rendering framework. // For example, React framework by design renders pages in "asynchronous" fashion. // Hence, by the time the web browser attempts to restore the scroll position, // the page might not yet be rendered which would result in incorrect scroll position restoration. // That's why the application has to take over this functionality from the web browser. _disableAutomaticScrollRestoration = () => { try { this._scrollPosition.disableAutomaticScrollRestoration(); } catch (error) { // eslint-disable-next-line no-console console.error( '[navigation-stack] could not disable default scroll restoration mode', ); } }; _enableAutomaticScrollRestoration = () => { try { this._scrollPosition.enableAutomaticScrollRestoration(); } catch (error) { // eslint-disable-next-line no-console console.error( '[navigation-stack] could not enable default scroll restoration mode', ); } }; _getSavedScrollPositionForLocation( location, scrollableContainerKey = PAGE_SCROLLABLE_CONTAINER_KEY, ) { return this._locationDataStorage.get(location, scrollableContainerKey); } _saveScrollPositionForLocation = ( location, scrollableContainerKey, scrollPosition, ) => { this._locationDataStorage.set( location, scrollableContainerKey || PAGE_SCROLLABLE_CONTAINER_KEY, scrollPosition, ); }; // Returns scroll position coordinates or an anchor name. _getPageScrollPositionOrAnchorToSet(location) { // If it's a return to a previously-visited location, // read the saved scroll position from session data store. return ( this._getSavedScrollPositionForLocation(location) || this._getAnchor(location) || this._getDefaultScrollPosition() ); } // Returns scroll position coordinates. _getScrollableContainerScrollPositionToSet( location, scrollableContainerKey, ) { // If it's a return to a previously-visited location, // read the saved scroll position from session data store. return ( this._getSavedScrollPositionForLocation( location, scrollableContainerKey, ) || this._getDefaultScrollPosition() ); } _getAnchor(location) { const { hash } = location; if (hash && hash !== '#') { return hash.slice('#'.length); } return undefined; } _getDefaultScrollPosition() { return [0, 0]; } // `_enableSavingScrollPosition()` and `_disableSavingScrollPosition()` // aren't used in real life and are not part of the public API. // They're only used in tests. _enableSavingScrollPosition() { this._doNotSaveScrollPosition = undefined; } // `_enableSavingScrollPosition()` and `_disableSavingScrollPosition()` // aren't used in real life and are not part of the public API. // They're only used in tests. _disableSavingScrollPosition() { this._doNotSaveScrollPosition = true; } }