diff --git a/e2e-tests/development-runtime/cypress/integration/functionality/accessibility.js b/e2e-tests/development-runtime/cypress/integration/functionality/accessibility.js new file mode 100644 index 0000000000000..96cbb3e75abd9 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/functionality/accessibility.js @@ -0,0 +1,85 @@ +describe(`focus managment`, () => { + it(`Focus router wrapper after navigation to regular page (from index)`, () => { + cy.visit(`/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/page-2/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation to regular page (to index)`, () => { + cy.visit(`/page-2/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/`) + cy.assertRouterWrapperFocus(true) + }) + + // Navigating to 404 and from 404 doesn't properly trigger focusing + // router wrapper. That's because dev 404 is special case and new router + // is being used when transitioning to/from 404 page and @reach/router + // doesn't meddle with focus on initial router component mount. + + it.skip(`Focus router wrapper after navigation to 404`, () => { + cy.visit(`/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/broken-path/`) + cy.assertRouterWrapperFocus(true) + }) + + it.skip(`Focus router wrapper after navigation from 404`, () => { + cy.visit(`/broken-path`, { failOnStatusCode: false }).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation from one 404 path to another 404 path`, () => { + cy.visit(`/broken-path`, { failOnStatusCode: false }).waitForRouteChange() + + // navigating to different not existing page should also trigger + // router wrapper focus as this is different page + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/another-broken-path/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation to client-only page`, () => { + cy.visit(`/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/client-only-paths/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation from client-only page`, () => { + cy.visit(`/client-only-paths/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus subrouter inside client-only page`, () => { + cy.visit(`/client-only-paths`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/client-only-paths/profile`) + + // inner paths are handled by router instance defined in client-only-paths page + // which means that navigating inside those should be handled by that router + // (not main router provided by gatsby) + cy.assertRouterWrapperFocus(false) + cy.focused().should(`have.attr`, `id`, `client-only-paths-sub-router`) + }) +}) diff --git a/e2e-tests/development-runtime/cypress/support/commands.js b/e2e-tests/development-runtime/cypress/support/commands.js index 10c6cb5606307..c537bcef9e30a 100644 --- a/e2e-tests/development-runtime/cypress/support/commands.js +++ b/e2e-tests/development-runtime/cypress/support/commands.js @@ -10,3 +10,40 @@ Cypress.Commands.add(`lifecycleCallCount`, action => ).length ) ) + +Cypress.Commands.add(`assertRouterWrapperFocus`, (shouldBeFocused = true) => + cy + .focused() + .should( + shouldBeFocused ? `have.attr` : `not.have.attr`, + `id`, + `gatsby-focus-wrapper` + ) +) + +Cypress.Commands.add( + `navigateAndWaitForRouteChange`, + { + prevSubject: `optional`, + }, + (subject, pathname) => { + cy.window().then(win => { + win.___navigate(pathname) + }) + + return cy.waitForAPI(`onRouteUpdate`).then(() => subject) + } +) + +Cypress.Commands.add( + `changeFocus`, + { + prevSubject: `optional`, + }, + subject => { + cy.get(`a`) + .first() + .focus() + .then(() => subject) + } +) diff --git a/e2e-tests/development-runtime/gatsby-node.js b/e2e-tests/development-runtime/gatsby-node.js index 6883584018835..10c07c4c41730 100644 --- a/e2e-tests/development-runtime/gatsby-node.js +++ b/e2e-tests/development-runtime/gatsby-node.js @@ -84,7 +84,9 @@ exports.onCreatePage = async ({ page, actions }) => { if (page.path.match(/^\/paths/)) { page.matchPath = `/paths/*` + createPage(page) + } else if (page.path.match(/^\/client-only-paths/)) { + page.matchPath = `/client-only-paths/*` + createPage(page) } - - createPage(page) } diff --git a/e2e-tests/development-runtime/src/pages/client-only-paths.js b/e2e-tests/development-runtime/src/pages/client-only-paths.js new file mode 100644 index 0000000000000..a0e1bcefd8ba4 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/client-only-paths.js @@ -0,0 +1,38 @@ +import React from "react" +import { Router } from "@reach/router" +import { Link } from "gatsby" + +import Layout from "../components/layout" +import InstrumentPage from "../utils/instrument-page" + +const Page = props => ( +
[client-only-path] {props.page}
+) + +const routes = [`/`, `/profile`, `/dashboard`] + +const basePath = `/client-only-paths` + +const ClientOnlyPathPage = props => ( + + + + + + + +) + +export default InstrumentPage(ClientOnlyPathPage) diff --git a/e2e-tests/development-runtime/src/pages/paths.js b/e2e-tests/development-runtime/src/pages/paths.js index 5b55a47068a57..fff6c60565106 100644 --- a/e2e-tests/development-runtime/src/pages/paths.js +++ b/e2e-tests/development-runtime/src/pages/paths.js @@ -7,7 +7,7 @@ const NotFound = () =>

Not Found in App

const AppPage = () => ( - + ) diff --git a/e2e-tests/production-runtime/cypress/integration/accessibility.js b/e2e-tests/production-runtime/cypress/integration/accessibility.js new file mode 100644 index 0000000000000..b6ab948a57437 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/accessibility.js @@ -0,0 +1,80 @@ +describe(`focus managment`, () => { + it(`Focus router wrapper after navigation to regular page (from index)`, () => { + cy.visit(`/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/page-2/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation to regular page (to index)`, () => { + cy.visit(`/page-2/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation to 404`, () => { + cy.visit(`/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/broken-path/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation from 404`, () => { + cy.visit(`/broken-path`, { failOnStatusCode: false }).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation from one 404 path to another 404 path`, () => { + cy.visit(`/broken-path`, { failOnStatusCode: false }).waitForRouteChange() + + // navigating to different not existing page should also trigger + // router wrapper focus as this is different page + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/another-broken-path/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation to client-only page`, () => { + cy.visit(`/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/client-only-paths/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus router wrapper after navigation from client-only page`, () => { + cy.visit(`/client-only-paths/`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/`) + cy.assertRouterWrapperFocus(true) + }) + + it(`Focus subrouter inside client-only page`, () => { + cy.visit(`/client-only-paths`).waitForRouteChange() + + cy.changeFocus() + cy.assertRouterWrapperFocus(false) + cy.navigateAndWaitForRouteChange(`/client-only-paths/profile`) + + // inner paths are handled by router instance defined in client-only-paths page + // which means that navigating inside those should be handled by that router + // (not main router provided by gatsby) + cy.assertRouterWrapperFocus(false) + cy.focused().should(`have.attr`, `id`, `client-only-paths-sub-router`) + }) +}) diff --git a/e2e-tests/production-runtime/cypress/support/commands.js b/e2e-tests/production-runtime/cypress/support/commands.js index ce8a91d48cdfc..6ae2406486766 100644 --- a/e2e-tests/production-runtime/cypress/support/commands.js +++ b/e2e-tests/production-runtime/cypress/support/commands.js @@ -33,3 +33,40 @@ Cypress.Commands.add(`shouldNotMatchScrollPosition`, id => expect(scrollPosition).not.to.be.closeTo(storedScrollPositions[id], 100) }) ) + +Cypress.Commands.add(`assertRouterWrapperFocus`, (shouldBeFocused = true) => + cy + .focused() + .should( + shouldBeFocused ? `have.attr` : `not.have.attr`, + `id`, + `gatsby-focus-wrapper` + ) +) + +Cypress.Commands.add( + `navigateAndWaitForRouteChange`, + { + prevSubject: `optional`, + }, + (subject, pathname) => { + cy.window().then(win => { + win.___navigate(pathname) + }) + + return cy.waitForAPI(`onRouteUpdate`).then(() => subject) + } +) + +Cypress.Commands.add( + `changeFocus`, + { + prevSubject: `optional`, + }, + subject => { + cy.get(`a`) + .first() + .focus() + .then(() => subject) + } +) diff --git a/e2e-tests/production-runtime/src/pages/client-only-paths.js b/e2e-tests/production-runtime/src/pages/client-only-paths.js index 1a54ddca88921..af41439bac88d 100644 --- a/e2e-tests/production-runtime/src/pages/client-only-paths.js +++ b/e2e-tests/production-runtime/src/pages/client-only-paths.js @@ -15,7 +15,11 @@ const basePath = `/client-only-paths` const ClientOnlyPathPage = props => ( - + diff --git a/e2e-tests/production-runtime/src/pages/paths.js b/e2e-tests/production-runtime/src/pages/paths.js index 88c722215ae70..4c8cb683797b7 100644 --- a/e2e-tests/production-runtime/src/pages/paths.js +++ b/e2e-tests/production-runtime/src/pages/paths.js @@ -7,7 +7,7 @@ const NotFound = () =>

Not Found in App

const AppPage = () => ( - + ) diff --git a/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap b/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap index aac886a8e227e..50a020c4331a3 100644 --- a/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap +++ b/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap @@ -8,6 +8,7 @@ Object { }, "page": Object { "componentChunkName": "chunk", + "matchPath": undefined, "path": "/mypage/", "webpackCompilationHash": "123", }, diff --git a/packages/gatsby/cache-dir/__tests__/__snapshots__/static-entry.js.snap b/packages/gatsby/cache-dir/__tests__/__snapshots__/static-entry.js.snap index 9b381e0865bc3..b15686a2cb527 100644 --- a/packages/gatsby/cache-dir/__tests__/__snapshots__/static-entry.js.snap +++ b/packages/gatsby/cache-dir/__tests__/__snapshots__/static-entry.js.snap @@ -6,8 +6,8 @@ exports[`develop-static-entry onPreRenderHTML can be used to replace postBodyCom exports[`develop-static-entry onPreRenderHTML can be used to replace preBodyComponents 1`] = `"
div3
div2
div1
"`; -exports[`static-entry onPreRenderHTML can be used to replace headComponents 1`] = `"
"`; +exports[`static-entry onPreRenderHTML can be used to replace headComponents 1`] = `"
"`; -exports[`static-entry onPreRenderHTML can be used to replace postBodyComponents 1`] = `"
div3
div2
div1
"`; +exports[`static-entry onPreRenderHTML can be used to replace postBodyComponents 1`] = `"
div3
div2
div1
"`; -exports[`static-entry onPreRenderHTML can be used to replace preBodyComponents 1`] = `"
div3
div2
div1
"`; +exports[`static-entry onPreRenderHTML can be used to replace preBodyComponents 1`] = `"
div3
div2
div1
"`; diff --git a/packages/gatsby/cache-dir/loader.js b/packages/gatsby/cache-dir/loader.js index 4f199564d47ca..786129db59213 100644 --- a/packages/gatsby/cache-dir/loader.js +++ b/packages/gatsby/cache-dir/loader.js @@ -98,6 +98,7 @@ const toPageResources = (pageData, component = null) => { componentChunkName: pageData.componentChunkName, path: pageData.path, webpackCompilationHash: pageData.webpackCompilationHash, + matchPath: pageData.matchPath, } return { diff --git a/packages/gatsby/cache-dir/page-renderer.js b/packages/gatsby/cache-dir/page-renderer.js index 403787fbbe1db..8f95fafe23616 100644 --- a/packages/gatsby/cache-dir/page-renderer.js +++ b/packages/gatsby/cache-dir/page-renderer.js @@ -20,7 +20,7 @@ class PageRenderer extends React.Component { replacementElement || createElement(this.props.pageResources.component, { ...props, - key: this.props.pageResources.page.path, + key: this.props.path || this.props.pageResources.page.path, }) const wrappedPage = apiRunner( diff --git a/packages/gatsby/cache-dir/production-app.js b/packages/gatsby/cache-dir/production-app.js index 2a21984cbf383..5377f4c833c00 100644 --- a/packages/gatsby/cache-dir/production-app.js +++ b/packages/gatsby/cache-dir/production-app.js @@ -1,7 +1,7 @@ import { apiRunner, apiRunnerAsync } from "./api-runner-browser" -import React, { createElement } from "react" +import React from "react" import ReactDOM from "react-dom" -import { Router, navigate } from "@reach/router" +import { Router, navigate, Location } from "@reach/router" import { ScrollContext } from "gatsby-react-router-scroll" import domReady from "@mikaelkristiansson/domready" import { @@ -37,7 +37,7 @@ apiRunnerAsync(`onClientEntry`).then(() => { require(`./register-service-worker`) } - class RouteHandler extends React.Component { + class LocationHandler extends React.Component { render() { let { location } = this.props return ( @@ -48,12 +48,24 @@ apiRunnerAsync(`onClientEntry`).then(() => { location={location} shouldUpdateScroll={shouldUpdateScroll} > - + id="gatsby-focus-wrapper" + > + +
)} @@ -96,14 +108,11 @@ apiRunnerAsync(`onClientEntry`).then(() => { } not found. Not rendering React` ) } - const Root = () => - createElement( - Router, - { - basepath: __BASE_PATH__, - }, - createElement(RouteHandler, { path: `/*` }) - ) + const Root = () => ( + + {locationContext => } + + ) const WrappedRoot = apiRunner( `wrapRootElement`, diff --git a/packages/gatsby/cache-dir/root.js b/packages/gatsby/cache-dir/root.js index 0ea2c01c6bbbf..8f3e32cf8974f 100644 --- a/packages/gatsby/cache-dir/root.js +++ b/packages/gatsby/cache-dir/root.js @@ -1,5 +1,5 @@ -import React, { createElement } from "react" -import { Router } from "@reach/router" +import React from "react" +import { Router, Location } from "@reach/router" import { ScrollContext } from "gatsby-react-router-scroll" import { @@ -33,7 +33,7 @@ if (window.__webpack_hot_middleware_reporter__ !== undefined) { navigationInit() -class RouteHandler extends React.Component { +class LocationHandler extends React.Component { render() { let { location } = this.props @@ -46,7 +46,20 @@ class RouteHandler extends React.Component { location={location} shouldUpdateScroll={shouldUpdateScroll} > - + + + )} @@ -65,24 +78,28 @@ class RouteHandler extends React.Component { return ( - + id="gatsby-focus-wrapper" + > + + ) } } -const Root = () => - createElement( - Router, - { - basepath: __BASE_PATH__, - }, - createElement(RouteHandler, { path: `/*` }) - ) +const Root = () => ( + + {locationContext => } + +) // Let site, plugins wrap the site e.g. for Redux. const WrappedRoot = apiRunner( diff --git a/packages/gatsby/cache-dir/static-entry.js b/packages/gatsby/cache-dir/static-entry.js index 3cc4b04c2a05f..ec56c40370eeb 100644 --- a/packages/gatsby/cache-dir/static-entry.js +++ b/packages/gatsby/cache-dir/static-entry.js @@ -180,6 +180,7 @@ export default (pagePath, callback) => { createElement( Router, { + id: `gatsby-focus-wrapper`, baseuri: `${__BASE_PATH__}`, }, createElement(RouteHandler, { path: `/*` }) diff --git a/packages/gatsby/src/utils/page-data.js b/packages/gatsby/src/utils/page-data.js index 1237db7468cf3..a435c62182b3c 100644 --- a/packages/gatsby/src/utils/page-data.js +++ b/packages/gatsby/src/utils/page-data.js @@ -18,6 +18,7 @@ const write = async ({ publicDir }, page, result, webpackCompilationHash) => { const body = { componentChunkName: page.componentChunkName, path: page.path, + matchPath: page.matchPath, webpackCompilationHash, result, } diff --git a/packages/gatsby/src/utils/webpack.config.js b/packages/gatsby/src/utils/webpack.config.js index 873711a7010e7..a81c0832bb4c9 100644 --- a/packages/gatsby/src/utils/webpack.config.js +++ b/packages/gatsby/src/utils/webpack.config.js @@ -350,9 +350,9 @@ module.exports = async (program, directory, suppliedStage) => { return { rules: configRules } } - function getResolve() { + function getResolve(stage) { const { program } = store.getState() - return { + const resolve = { // Use the program's extension list (generated via the // 'resolvableExtensions' API hook). extensions: [...program.extensions], @@ -382,6 +382,19 @@ module.exports = async (program, directory, suppliedStage) => { PnpWebpackPlugin, ], } + + const target = + stage === `build-html` || stage === `develop-html` ? `node` : `web` + if (target === `web`) { + // force to use es modules when importing internals of @reach.router + // for browser bundles + resolve.alias[`@reach/router`] = path.join( + path.dirname(require.resolve(`@reach/router/package.json`)), + `es` + ) + } + + return resolve } function getResolveLoader() { @@ -429,7 +442,7 @@ module.exports = async (program, directory, suppliedStage) => { mode: getMode(), resolveLoader: getResolveLoader(), - resolve: getResolve(), + resolve: getResolve(stage), node: { __filename: true,