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 => (
+
+
+
+
+
+
+ {routes.map(route => (
+ -
+
+ {route}
+
+
+ ))}
+
+
+)
+
+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,