diff --git a/e2e-tests/production-runtime/cypress/integration/scroll-behavior.js b/e2e-tests/production-runtime/cypress/integration/scroll-behavior.js index 2c6a87c569113..e1f135df5169d 100644 --- a/e2e-tests/production-runtime/cypress/integration/scroll-behavior.js +++ b/e2e-tests/production-runtime/cypress/integration/scroll-behavior.js @@ -34,6 +34,25 @@ describe(`Scroll behaviour`, () => { cy.getTestElement(`index-link`).click().waitForRouteChange() }) + it(`should scroll to hashes - even with encoded characters`, () => { + cy.visit(`/`).waitForRouteChange() + cy.getTestElement(`long-page-id`).click().waitForRouteChange() + + // UI should auto scroll to the id with a matching hash + cy.window().then(win => { + let idScrollY = win.scrollY + expect(win.scrollY).not.to.eq(0, 0) + + cy.scrollTo(`bottom`) + cy.go(`back`).waitForRouteChange() + cy.go(`forward`).waitForRouteChange() + + cy.window().then(updatedWindow => { + expect(updatedWindow.scrollY).not.to.eq(idScrollY) + }) + }) + }) + it(`should keep track of location.key`, () => { cy.visit(`/`).waitForRouteChange() diff --git a/e2e-tests/production-runtime/src/pages/index.js b/e2e-tests/production-runtime/src/pages/index.js index 422f65a76a86b..ef187e8010896 100644 --- a/e2e-tests/production-runtime/src/pages/index.js +++ b/e2e-tests/production-runtime/src/pages/index.js @@ -26,6 +26,11 @@ const IndexPage = ({ pageContext }) => ( To long page +
  • + + To long page (at id) + +
  • Another page using Index template diff --git a/e2e-tests/production-runtime/src/pages/long-page.js b/e2e-tests/production-runtime/src/pages/long-page.js index eada4b812c664..13cf9b803a6b9 100644 --- a/e2e-tests/production-runtime/src/pages/long-page.js +++ b/e2e-tests/production-runtime/src/pages/long-page.js @@ -12,6 +12,8 @@ const LongPage = () => ( Go back to the homepage - middle of the page
    +

    Special Hash ID

    +
    Go back to the homepage - bottom of the page diff --git a/packages/gatsby-react-router-scroll/package.json b/packages/gatsby-react-router-scroll/package.json index 8179e4583575c..4e8c2eab079ed 100644 --- a/packages/gatsby-react-router-scroll/package.json +++ b/packages/gatsby-react-router-scroll/package.json @@ -7,9 +7,7 @@ "url": "https://github.com/gatsbyjs/gatsby/issues" }, "dependencies": { - "@babel/runtime": "^7.10.2", - "scroll-behavior": "^0.9.12", - "warning": "^3.0.0" + "@babel/runtime": "^7.10.2" }, "devDependencies": { "@babel/cli": "^7.10.1", @@ -35,9 +33,9 @@ "directory": "packages/gatsby-react-router-scroll" }, "scripts": { - "build": "babel src --out-dir . --ignore \"**/__tests__\"", + "build": "babel src --out-dir . --ignore \"**/__tests__\" --extensions \".ts,.tsx\"", "prepare": "cross-env NODE_ENV=production npm run build", - "watch": "babel -w src --out-dir . --ignore \"**/__tests__\"" + "watch": "babel -w src --out-dir . --ignore \"**/__tests__\" --extensions \".ts,.tsx\"" }, "engines": { "node": ">=10.13.0" diff --git a/packages/gatsby-react-router-scroll/src/ScrollBehaviorContext.js b/packages/gatsby-react-router-scroll/src/ScrollBehaviorContext.js deleted file mode 100644 index 8cb69e53fbd0b..0000000000000 --- a/packages/gatsby-react-router-scroll/src/ScrollBehaviorContext.js +++ /dev/null @@ -1,89 +0,0 @@ -import React from "react" -import ScrollBehavior from "scroll-behavior" -import PropTypes from "prop-types" -import { globalHistory as history } from "@reach/router/lib/history" -import SessionStorage from "./StateStorage" - -export const ScrollBehaviorContext = React.createContext() - -const propTypes = { - shouldUpdateScroll: PropTypes.func, - children: PropTypes.element.isRequired, - location: PropTypes.object.isRequired, -} - -class ScrollContext extends React.Component { - constructor(props, context) { - super(props, context) - - this.scrollBehavior = new ScrollBehavior({ - addTransitionHook: history.listen, - stateStorage: new SessionStorage(), - getCurrentLocation: () => this.props.location, - shouldUpdateScroll: this.shouldUpdateScroll, - }) - } - - componentDidUpdate(prevProps) { - const { location } = this.props - const prevLocation = prevProps.location - - if (location === prevLocation) { - return - } - - const prevRouterProps = { - location: prevProps.location, - } - - this.scrollBehavior.updateScroll(prevRouterProps, { history, location }) - } - - componentWillUnmount() { - this.scrollBehavior.stop() - } - - getRouterProps() { - const { location } = this.props - return { location, history } - } - - shouldUpdateScroll = (prevRouterProps, routerProps) => { - const { shouldUpdateScroll } = this.props - if (!shouldUpdateScroll) { - return true - } - - // Hack to allow accessing scrollBehavior._stateStorage. - return shouldUpdateScroll.call( - this.scrollBehavior, - prevRouterProps, - routerProps - ) - } - - registerElement = (key, element, shouldUpdateScroll) => { - this.scrollBehavior.registerElement( - key, - element, - shouldUpdateScroll, - this.getRouterProps() - ) - } - - unregisterElement = key => { - this.scrollBehavior.unregisterElement(key) - } - - render() { - return ( - - {React.Children.only(this.props.children)} - - ) - } -} - -ScrollContext.propTypes = propTypes - -export default ScrollContext diff --git a/packages/gatsby-react-router-scroll/src/ScrollContainer.js b/packages/gatsby-react-router-scroll/src/ScrollContainer.js deleted file mode 100644 index 1373b8c41791c..0000000000000 --- a/packages/gatsby-react-router-scroll/src/ScrollContainer.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react" -import ReactDOM from "react-dom" -import warning from "warning" -import PropTypes from "prop-types" -import { ScrollBehaviorContext } from "./ScrollBehaviorContext" - -const propTypes = { - scrollKey: PropTypes.string.isRequired, - shouldUpdateScroll: PropTypes.func, - children: PropTypes.element.isRequired, -} - -class ScrollContainer extends React.Component { - constructor(props) { - super(props) - - // We don't re-register if the scroll key changes, so make sure we - // unregister with the initial scroll key just in case the user changes it. - this.scrollKey = props.scrollKey - } - - componentDidMount() { - this.props.context.registerElement( - this.props.scrollKey, - ReactDOM.findDOMNode(this), // eslint-disable-line react/no-find-dom-node - this.shouldUpdateScroll - ) - - // Only keep around the current DOM node in development, as this is only - // for emitting the appropriate warning. - if (process.env.NODE_ENV !== `production`) { - this.domNode = ReactDOM.findDOMNode(this) // eslint-disable-line react/no-find-dom-node - } - } - - componentDidUpdate(prevProps) { - warning( - prevProps.scrollKey === this.props.scrollKey, - ` does not support changing scrollKey.` - ) - if (process.env.NODE_ENV !== `production`) { - const prevDomNode = this.domNode - this.domNode = ReactDOM.findDOMNode(this) // eslint-disable-line react/no-find-dom-node - - warning( - this.domNode === prevDomNode, - ` does not support changing DOM node.` - ) - } - } - - componentWillUnmount() { - this.props.context.unregisterElement(this.scrollKey) - } - - shouldUpdateScroll = (prevRouterProps, routerProps) => { - const { shouldUpdateScroll } = this.props - if (!shouldUpdateScroll) { - return true - } - - // Hack to allow accessing scrollBehavior._stateStorage. - return shouldUpdateScroll.call( - this.props.context.scrollBehavior, - prevRouterProps, - routerProps - ) - } - - render() { - return this.props.children - } -} - -const ScrollContainerConsumer = props => ( - - {context => } - -) - -ScrollContainerConsumer.propTypes = propTypes - -export default ScrollContainerConsumer diff --git a/packages/gatsby-react-router-scroll/src/index.js b/packages/gatsby-react-router-scroll/src/index.js deleted file mode 100644 index d0f30139c3da6..0000000000000 --- a/packages/gatsby-react-router-scroll/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import ScrollBehaviorContext from "./ScrollBehaviorContext" -import ScrollContainer from "./ScrollContainer" -exports.ScrollContainer = ScrollContainer -exports.ScrollContext = ScrollBehaviorContext diff --git a/packages/gatsby-react-router-scroll/src/index.ts b/packages/gatsby-react-router-scroll/src/index.ts new file mode 100644 index 0000000000000..fae4680adeb26 --- /dev/null +++ b/packages/gatsby-react-router-scroll/src/index.ts @@ -0,0 +1,3 @@ +export { ScrollHandler as ScrollContext } from "./scroll-handler" +export { ScrollContainer } from "./scroll-container" +export { useScrollRestoration } from "./use-scroll-restoration" diff --git a/packages/gatsby-react-router-scroll/src/scroll-container.tsx b/packages/gatsby-react-router-scroll/src/scroll-container.tsx new file mode 100644 index 0000000000000..5c09e57e777e6 --- /dev/null +++ b/packages/gatsby-react-router-scroll/src/scroll-container.tsx @@ -0,0 +1,96 @@ +// TODO: In Gatsby v3, this file should be removed. +// We are deprecating this in V2 in favor of useScrollRestoration +import * as React from "react" +import ReactDOM from "react-dom" +import PropTypes from "prop-types" +import { ScrollContext } from "./scroll-handler" +import { SessionStorage } from "./session-storage" +import { Location } from "@reach/router" +import { Location as HLocation } from "history" + +const propTypes = { + scrollKey: PropTypes.string.isRequired, + shouldUpdateScroll: PropTypes.func, + children: PropTypes.element.isRequired, +} + +interface IProps { + scrollKey: string + shouldUpdateScroll?: Function + children: React.ReactNode +} + +interface IPropsWithContextAndLocation extends IProps { + context: SessionStorage + location: HLocation +} + +let hasNotWarnedDeprecation = true + +class ScrollContainerImplementation extends React.Component< + IPropsWithContextAndLocation +> { + constructor(props: IPropsWithContextAndLocation) { + super(props) + + if (process.env.NODE_ENV !== `production` && hasNotWarnedDeprecation) { + hasNotWarnedDeprecation = false + console.log( + `Deprecation Warning: + + Gatsby is deprecated in Gatsby v2 and will be removed in Gatsby v3. + Update to the React hook alternative useScrollRestoration, like this:. + + \`\`\` + import React from 'react'; + import { useScrollRestoration } from 'gatsby-react-router-scroll'; + + function Component() { + const scrollRestoration = useScrollRestoration('${this.props.scrollKey}'); + + return
      ; + } + \`\`\` + ` + ) + } + } + + componentDidMount(): void { + // eslint-disable-next-line react/no-find-dom-node + const node = ReactDOM.findDOMNode(this) as Element + const { location, scrollKey } = this.props + + if (!node) return + + node.addEventListener(`scroll`, () => { + this.props.context.save(location, scrollKey, node.scrollTop) + }) + + const position = this.props.context.read(location, scrollKey) + + node.scrollTo(0, position || 0) + } + + render(): React.ReactNode { + return this.props.children + } +} + +export const ScrollContainer = (props: IProps): React.ReactNode => ( + + {({ location }): React.ReactNode => ( + + {(context): React.ReactNode => ( + + )} + + )} + +) + +ScrollContainer.propTypes = propTypes diff --git a/packages/gatsby-react-router-scroll/src/scroll-handler.tsx b/packages/gatsby-react-router-scroll/src/scroll-handler.tsx new file mode 100644 index 0000000000000..bd262232f9f7b --- /dev/null +++ b/packages/gatsby-react-router-scroll/src/scroll-handler.tsx @@ -0,0 +1,111 @@ +import * as React from "react" +import { LocationContext } from "@reach/router" +import PropTypes from "prop-types" +import { SessionStorage } from "./session-storage" + +export const ScrollContext = React.createContext( + new SessionStorage() +) +ScrollContext.displayName = `GatsbyScrollContext` + +type ShouldUpdateScrollFn = ( + prevRouterProps: LocationContext | undefined, + routerProps: LocationContext +) => boolean +type ShouldUpdateScroll = undefined | ShouldUpdateScrollFn + +export class ScrollHandler extends React.Component< + LocationContext & { shouldUpdateScroll: ShouldUpdateScroll } +> { + static propTypes = { + shouldUpdateScroll: PropTypes.func, + children: PropTypes.element.isRequired, + location: PropTypes.object.isRequired, + } + + _stateStorage: SessionStorage = new SessionStorage() + + scrollListener = (): void => { + const { key } = this.props.location + + if (key) { + this._stateStorage.save(this.props.location, key, window.scrollY) + } + } + + componentDidMount(): void { + window.addEventListener(`scroll`, this.scrollListener) + let scrollPosition + const { key, hash } = this.props.location + + if (key) { + scrollPosition = this._stateStorage.read(this.props.location, key) + } + + if (scrollPosition) { + this.windowScroll(scrollPosition, undefined) + } else if (hash) { + this.scrollToHash(decodeURI(hash), undefined) + } + } + + componentWillUnmount(): void { + window.removeEventListener(`scroll`, this.scrollListener) + } + + componentDidUpdate(prevProps: LocationContext): void { + const { hash, key } = this.props.location + let scrollPosition + + if (key) { + scrollPosition = this._stateStorage.read(this.props.location, key) + } + + if (hash && scrollPosition === 0) { + this.scrollToHash(decodeURI(hash), prevProps) + } else { + this.windowScroll(scrollPosition, prevProps) + } + } + + windowScroll = ( + position: number, + prevProps: LocationContext | undefined + ): void => { + if (this.shouldUpdateScroll(prevProps, this.props)) { + window.scrollTo(0, position) + } + } + + scrollToHash = ( + hash: string, + prevProps: LocationContext | undefined + ): void => { + const node = document.querySelector(hash) + + if (node && this.shouldUpdateScroll(prevProps, this.props)) { + node.scrollIntoView() + } + } + + shouldUpdateScroll = ( + prevRouterProps: LocationContext | undefined, + routerProps: LocationContext + ): boolean => { + const { shouldUpdateScroll } = this.props + if (!shouldUpdateScroll) { + return true + } + + // Hack to allow accessing this._stateStorage. + return shouldUpdateScroll.call(this, prevRouterProps, routerProps) + } + + render(): React.ReactNode { + return ( + + {this.props.children} + + ) + } +} diff --git a/packages/gatsby-react-router-scroll/src/StateStorage.js b/packages/gatsby-react-router-scroll/src/session-storage.ts similarity index 84% rename from packages/gatsby-react-router-scroll/src/StateStorage.js rename to packages/gatsby-react-router-scroll/src/session-storage.ts index 79460c8e908d4..74cbcdffc53bf 100644 --- a/packages/gatsby-react-router-scroll/src/StateStorage.js +++ b/packages/gatsby-react-router-scroll/src/session-storage.ts @@ -1,13 +1,14 @@ +import { Location } from "history" const STATE_KEY_PREFIX = `@@scroll|` const GATSBY_ROUTER_SCROLL_STATE = `___GATSBY_REACT_ROUTER_SCROLL` -export default class SessionStorage { - read(location, key) { +export class SessionStorage { + read(location: Location, key: string): number { const stateKey = this.getStateKey(location, key) try { const value = window.sessionStorage.getItem(stateKey) - return JSON.parse(value) + return value ? JSON.parse(value) : 0 } catch (e) { if (process.env.NODE_ENV !== `production`) { console.warn( @@ -23,11 +24,11 @@ export default class SessionStorage { return window[GATSBY_ROUTER_SCROLL_STATE][stateKey] } - return {} + return 0 } } - save(location, key, value) { + save(location: Location, key: string, value: number): void { const stateKey = this.getStateKey(location, key) const storedValue = JSON.stringify(value) @@ -49,7 +50,7 @@ export default class SessionStorage { } } - getStateKey(location, key) { + getStateKey(location: Location, key: string): string { const locationKey = location.key || location.pathname const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}` return key === null || typeof key === `undefined` diff --git a/packages/gatsby-react-router-scroll/src/use-scroll-restoration.ts b/packages/gatsby-react-router-scroll/src/use-scroll-restoration.ts new file mode 100644 index 0000000000000..c7e2fb67eb696 --- /dev/null +++ b/packages/gatsby-react-router-scroll/src/use-scroll-restoration.ts @@ -0,0 +1,32 @@ +import { ScrollContext } from "./scroll-handler" +import { useRef, useContext, useLayoutEffect } from "react" +import { useLocation } from "@reach/router" + +interface IScrollRestorationProps { + ref: React.MutableRefObject + onScroll(): void +} + +export function useScrollRestoration( + identifier: string +): IScrollRestorationProps { + const location = useLocation() + const state = useContext(ScrollContext) + const ref = useRef() + + useLayoutEffect((): void => { + if (ref.current) { + const position = state.read(location, identifier) + ref.current.scrollTo(0, position || 0) + } + }, []) + + return { + ref, + onScroll(): void { + if (ref.current) { + state.save(location, identifier, ref.current.scrollTop) + } + }, + } +} diff --git a/yarn.lock b/yarn.lock index 2ff2046b1fb7a..ae01cfd733cd3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2836,14 +2836,14 @@ dependencies: regenerator-runtime "^0.13.2" -"@babel/runtime@^7.9.2": - version "7.9.2" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.2.tgz#d90df0583a3a252f09aaa619665367bae518db06" - integrity sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q== +"@babel/runtime@^7.7.4", "@babel/runtime@^7.8.4": + version "7.8.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d" + integrity sha512-+AATMUFppJDw6aiR5NVPHqIQBlV/Pj8wY/EZH+lmvRdUo9xBaz/rF3alAwFJQavvKfeOlPE7oaaDHVbcySbCsg== dependencies: regenerator-runtime "^0.13.4" -"@babel/runtime@^7.9.6": +"@babel/runtime@^7.8.7", "@babel/runtime@^7.9.6": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f" integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ== @@ -10875,12 +10875,13 @@ dom-converter@~0.1: dependencies: utila "~0.3" -dom-helpers@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" - integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== +dom-helpers@^5.1.4: + version "5.1.4" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" + integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A== dependencies: - "@babel/runtime" "^7.1.2" + "@babel/runtime" "^7.8.7" + csstype "^2.6.7" dom-serializer@0, dom-serializer@~0.1.0, dom-serializer@~0.1.1: version "0.1.1" @@ -20140,6 +20141,11 @@ package-json@^6.3.0: registry-url "^5.0.0" semver "^6.2.0" +page-lifecycle@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/page-lifecycle/-/page-lifecycle-0.1.2.tgz#f17a083c082bd5ababddd77f1025a4b1c8808012" + integrity sha512-+3uccYgL0CXG0KSXRxZi4uc2E6mqFWV5HqiJJgcnaJCiS0LqiuJ4vB420N21NFuLvuvLB4Jr5drgQ2NXAXF9Iw== + pako@^0.2.5: version "0.2.9" resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75" @@ -24184,13 +24190,14 @@ schemes@^1.0.1: dependencies: extend "^3.0.0" -scroll-behavior@^0.9.12: - version "0.9.12" - resolved "https://registry.yarnpkg.com/scroll-behavior/-/scroll-behavior-0.9.12.tgz#1c22d273ec4ce6cd4714a443fead50227da9424c" - integrity sha512-18sirtyq1P/VsBX6O/vgw20Np+ngduFXEMO4/NDFXabdOKBL2kjPVUpz1y0+jm99EWwFJafxf5/tCyMeXt9Xyg== +scroll-behavior@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/scroll-behavior/-/scroll-behavior-0.11.0.tgz#fff2765b6007341b80a04678fcd314e54d5b03ea" + integrity sha512-wQvNs3Q1TRvEkkwrFd/BkIL+dA4PYQl55/FUlmtjgz63/FtbnyR6MkLyRmjK0Rg3LCZCr0jORsFfMLkeNYdFuA== dependencies: - dom-helpers "^3.4.0" + dom-helpers "^5.1.4" invariant "^2.2.4" + page-lifecycle "^0.1.2" section-matter@^1.0.0: version "1.0.0"