diff --git a/e2e-tests/development-runtime/content/error-recovery/page-query.json b/e2e-tests/development-runtime/content/error-recovery/page-query.json new file mode 100644 index 0000000000000..8d7de1d131960 --- /dev/null +++ b/e2e-tests/development-runtime/content/error-recovery/page-query.json @@ -0,0 +1,4 @@ +{ + "selector": "page-query", + "hasError": false +} diff --git a/e2e-tests/development-runtime/content/error-recovery/static-query.json b/e2e-tests/development-runtime/content/error-recovery/static-query.json new file mode 100644 index 0000000000000..c7378bd85b5d5 --- /dev/null +++ b/e2e-tests/development-runtime/content/error-recovery/static-query.json @@ -0,0 +1,4 @@ +{ + "selector": "static-query", + "hasError": false +} diff --git a/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/compile-error.js b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/compile-error.js new file mode 100644 index 0000000000000..6358db2a7df7e --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/compile-error.js @@ -0,0 +1,41 @@ +before(() => { + cy.exec( + `npm run update -- --file src/pages/error-handling/compile-error.js --restore` + ) +}) + +after(() => { + cy.exec( + `npm run update -- --file src/pages/error-handling/compile-error.js --restore` + ) +}) + +const errorPlaceholder = `// compile-error` +const errorReplacement = `a b` + +describe(`testing error overlay and ability to automatically recover from webpack compile errors`, () => { + it(`displays content initially (no errors yet)`, () => { + cy.visit(`/error-handling/compile-error/`).waitForRouteChange() + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Working`) + }) + + it(`displays error with overlay on compilation errors`, () => { + cy.exec( + `npm run update -- --file src/pages/error-handling/compile-error.js --replacements "${errorPlaceholder}:${errorReplacement}" --exact` + ) + + cy.getOverlayIframe().contains(`Failed to compile`) + cy.getOverlayIframe().contains(`Parsing error: Unexpected token`) + cy.screenshot() + }) + + it(`can recover without need to refresh manually`, () => { + cy.exec( + `npm run update -- --file src/pages/error-handling/compile-error.js --replacements "Working:Updated" --replacements "${errorReplacement}:${errorPlaceholder}" --exact` + ) + + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Updated`) + cy.assertNoOverlayIframe() + cy.screenshot() + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/page-query-result-runtime-error.js b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/page-query-result-runtime-error.js new file mode 100644 index 0000000000000..1faaae5204fd2 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/page-query-result-runtime-error.js @@ -0,0 +1,69 @@ +Cypress.on("uncaught:exception", (err, runnable) => { + // returning false here prevents Cypress from + // failing the test + return false +}) + +before(() => { + cy.exec( + `npm run update -- --file content/error-recovery/page-query.json --restore` + ) + cy.exec( + `npm run update -- --file src/pages/error-handling/page-query-result-runtime-error.js --restore` + ) +}) + +after(() => { + cy.exec( + `npm run update -- --file content/error-recovery/page-query.json --restore` + ) + cy.exec( + `npm run update -- --file src/pages/error-handling/page-query-result-runtime-error.js --restore` + ) +}) + +const errorPlaceholder = `false` +const errorReplacement = `true` + +describe(`testing error overlay and ability to automatically recover runtime errors cause by content changes (page queries variant)`, () => { + it(`displays content initially (no errors yet)`, () => { + cy.visit( + `/error-handling/page-query-result-runtime-error/` + ).waitForRouteChange() + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Working`) + cy.getTestElement(`results`) + .invoke(`text`) + .should(`contain`, `"hasError": false`) + }) + + it(`displays error with overlay on runtime errors`, () => { + cy.exec( + `npm run update -- --file content/error-recovery/page-query.json --replacements "${errorPlaceholder}:${errorReplacement}" --exact` + ) + + // that's the exact error we throw and we expect to see that + cy.getOverlayIframe().contains(`Page query results caused runtime error`) + // contains details + cy.getOverlayIframe().contains( + `src/pages/error-handling/page-query-result-runtime-error.js` + ) + cy.screenshot() + }) + + it(`can recover without need to refresh manually`, () => { + cy.exec( + `npm run update -- --file content/error-recovery/page-query.json --replacements "${errorReplacement}:${errorPlaceholder}" --exact` + ) + cy.exec( + `npm run update -- --file src/pages/error-handling/page-query-result-runtime-error.js --replacements "Working:Updated" --exact` + ) + + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Updated`) + cy.getTestElement(`results`) + .invoke(`text`) + .should(`contain`, `"hasError": false`) + + cy.assertNoOverlayIframe() + cy.screenshot() + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/query-validation-error.js b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/query-validation-error.js new file mode 100644 index 0000000000000..1fbbfb91c948b --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/query-validation-error.js @@ -0,0 +1,45 @@ +before(() => { + cy.exec( + `npm run update -- --file src/pages/error-handling/query-validation-error.js --restore` + ) +}) + +after(() => { + cy.exec( + `npm run update -- --file src/pages/error-handling/query-validation-error.js --restore` + ) +}) + +const errorPlaceholder = `# query-validation-error` +const errorReplacement = `fieldThatDoesNotExistOnSiteMapType` + +describe(`testing error overlay and ability to automatically recover from query extraction validation errors`, () => { + it(`displays content initially (no errors yet)`, () => { + cy.visit(`/error-handling/query-validation-error/`).waitForRouteChange() + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Working`) + }) + + it(`displays error with overlay on compilation errors`, () => { + cy.exec( + `npm run update -- --file src/pages/error-handling/query-validation-error.js --replacements "${errorPlaceholder}:${errorReplacement}" --exact` + ) + + cy.getOverlayIframe().contains(`Failed to compile`) + cy.getOverlayIframe().contains(`There was an error in your GraphQL query`) + // make sure we mark location + cy.getOverlayIframe().contains( + `src/pages/error-handling/query-validation-error.js` + ) + cy.screenshot() + }) + + it(`can recover without need to refresh manually`, () => { + cy.exec( + `npm run update -- --file src/pages/error-handling/query-validation-error.js --replacements "Working:Updated" --replacements "${errorReplacement}:${errorPlaceholder}" --exact` + ) + + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Updated`) + cy.assertNoOverlayIframe() + cy.screenshot() + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/runtime-error.js b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/runtime-error.js new file mode 100644 index 0000000000000..d7a91d9e4c2c4 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/runtime-error.js @@ -0,0 +1,48 @@ +Cypress.on("uncaught:exception", (err, runnable) => { + // returning false here prevents Cypress from + // failing the test + return false +}) + +before(() => { + cy.exec( + `npm run update -- --file src/pages/error-handling/runtime-error.js --restore` + ) +}) + +after(() => { + cy.exec( + `npm run update -- --file src/pages/error-handling/runtime-error.js --restore` + ) +}) + +const errorPlaceholder = `// runtime-error` +const errorReplacement = `window.a.b.c.d.e.f.g()` + +describe(`testing error overlay and ability to automatically recover from runtime errors`, () => { + it(`displays content initially (no errors yet)`, () => { + cy.visit(`/error-handling/runtime-error/`).waitForRouteChange() + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Working`) + }) + + it(`displays error with overlay on runtime errors`, () => { + cy.exec( + `npm run update -- --file src/pages/error-handling/runtime-error.js --replacements "${errorPlaceholder}:${errorReplacement}" --exact` + ) + + cy.getOverlayIframe().contains(`Cannot read property`) + // contains details + cy.getOverlayIframe().contains(`src/pages/error-handling/runtime-error.js`) + cy.screenshot() + }) + + it(`can recover without need to refresh manually`, () => { + cy.exec( + `npm run update -- --file src/pages/error-handling/runtime-error.js --replacements "Working:Updated" --replacements "${errorReplacement}:${errorPlaceholder}" --exact` + ) + + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Updated`) + cy.assertNoOverlayIframe() + cy.screenshot() + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/static-query-result-runtime-error.js b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/static-query-result-runtime-error.js new file mode 100644 index 0000000000000..da9f236e9ec61 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/hot-reloading/error-handling/static-query-result-runtime-error.js @@ -0,0 +1,69 @@ +Cypress.on("uncaught:exception", (err, runnable) => { + // returning false here prevents Cypress from + // failing the test + return false +}) + +before(() => { + cy.exec( + `npm run update -- --file content/error-recovery/static-query.json --restore` + ) + cy.exec( + `npm run update -- --file src/pages/error-handling/static-query-result-runtime-error.js --restore` + ) +}) + +after(() => { + cy.exec( + `npm run update -- --file content/error-recovery/static-query.json --restore` + ) + cy.exec( + `npm run update -- --file src/pages/error-handling/static-query-result-runtime-error.js --restore` + ) +}) + +const errorPlaceholder = `false` +const errorReplacement = `true` + +describe(`testing error overlay and ability to automatically recover from runtime errors (static queries variant)`, () => { + it(`displays content initially (no errors yet)`, () => { + cy.visit( + `/error-handling/static-query-result-runtime-error/` + ).waitForRouteChange() + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Working`) + cy.getTestElement(`results`) + .invoke(`text`) + .should(`contain`, `"hasError": false`) + }) + + it(`displays error with overlay on runtime errors`, () => { + cy.exec( + `npm run update -- --file content/error-recovery/static-query.json --replacements "${errorPlaceholder}:${errorReplacement}" --exact` + ) + + // that's the exact error we throw and we expect to see that + cy.getOverlayIframe().contains(`Static query results caused runtime error`) + // contains details + cy.getOverlayIframe().contains( + `src/pages/error-handling/static-query-result-runtime-error.js` + ) + cy.screenshot() + }) + + it(`can recover without need to refresh manually`, () => { + cy.exec( + `npm run update -- --file content/error-recovery/static-query.json --replacements "${errorReplacement}:${errorPlaceholder}" --exact` + ) + cy.exec( + `npm run update -- --file src/pages/error-handling/static-query-result-runtime-error.js --replacements "Working:Updated" --exact` + ) + + cy.getTestElement(`hot`).invoke(`text`).should(`contain`, `Updated`) + cy.getTestElement(`results`) + .invoke(`text`) + .should(`contain`, `"hasError": false`) + + cy.assertNoOverlayIframe() + cy.screenshot() + }) +}) diff --git a/e2e-tests/development-runtime/cypress/support/commands.js b/e2e-tests/development-runtime/cypress/support/commands.js index c30ad592e9874..e17c55a140c7d 100644 --- a/e2e-tests/development-runtime/cypress/support/commands.js +++ b/e2e-tests/development-runtime/cypress/support/commands.js @@ -24,7 +24,7 @@ Cypress.Commands.add(`lifecycleCallOrder`, expectedActionCallOrder => if (expectedActionCallOrderLength > actionsLength) { return false } - + let prevActionIndex = -1 for (let i = 0; i < actionsLength; i += 1) { const nextActionIndex = prevActionIndex + 1 @@ -81,6 +81,29 @@ Cypress.Commands.add( } ) -Cypress.Commands.add(`assertRoute`, (route) => { +Cypress.Commands.add(`assertRoute`, route => { cy.url().should(`equal`, `${window.location.origin}${route}`) }) + +// react-error-overlay is iframe, so this is just convenience helper +// https://www.cypress.io/blog/2020/02/12/working-with-iframes-in-cypress/#custom-command +Cypress.Commands.add(`getOverlayIframe`, () => { + // get the iframe > document > body + // and retry until the body element is not empty + return ( + cy + .get(`iframe`, { log: true, timeout: 150000 }) + .its(`0.contentDocument.body`) + .should(`not.be.empty`) + // wraps "body" DOM element to allow + // chaining more Cypress commands, like ".find(...)" + // https://on.cypress.io/wrap + .then(cy.wrap, { log: true }) + ) +}) + +Cypress.Commands.add(`assertNoOverlayIframe`, () => { + // get the iframe > document > body + // and retry until the body element is not empty + return cy.get(`iframe`, { log: true, timeout: 15000 }).should(`not.exist`) +}) diff --git a/e2e-tests/development-runtime/gatsby-config.js b/e2e-tests/development-runtime/gatsby-config.js index f5839118e7c9a..46c122219fc6d 100644 --- a/e2e-tests/development-runtime/gatsby-config.js +++ b/e2e-tests/development-runtime/gatsby-config.js @@ -29,6 +29,7 @@ module.exports = { }, `gatsby-source-fake-data`, `gatsby-transformer-sharp`, + `gatsby-transformer-json`, { resolve: `gatsby-transformer-remark`, options: { diff --git a/e2e-tests/development-runtime/package.json b/e2e-tests/development-runtime/package.json index 71e957a6424e9..baf0df72febfd 100644 --- a/e2e-tests/development-runtime/package.json +++ b/e2e-tests/development-runtime/package.json @@ -13,6 +13,7 @@ "gatsby-plugin-sharp": "^2.0.37", "gatsby-seo": "^0.1.0", "gatsby-source-filesystem": "^2.0.33", + "gatsby-transformer-json": "^2.4.14", "gatsby-transformer-remark": "^2.3.12", "gatsby-transformer-sharp": "^2.1.19", "isomorphic-fetch": "^2.2.1", diff --git a/e2e-tests/development-runtime/scripts/update.js b/e2e-tests/development-runtime/scripts/update.js index 87307b4516a00..8d41f188a8635 100644 --- a/e2e-tests/development-runtime/scripts/update.js +++ b/e2e-tests/development-runtime/scripts/update.js @@ -34,13 +34,29 @@ const args = yargs ` ).trim(), type: `string`, + }) + .option(`restore`, { + default: false, + type: `boolean`, }).argv async function update() { const history = await getHistory() - const { file: fileArg, replacements } = args + const { file: fileArg, replacements, restore } = args const filePath = path.resolve(fileArg) + if (restore) { + const original = history.get(filePath) + if (original) { + await fs.writeFile(filePath, original, `utf-8`) + } else if (original === false) { + await fs.remove(filePath) + } else { + console.log(`Didn't make changes to "${fileArg}". Nothing to restore.`) + } + history.delete(filePath) + return + } let exists = true if (!fs.existsSync(filePath)) { exists = false diff --git a/e2e-tests/development-runtime/src/pages/error-handling/compile-error.js b/e2e-tests/development-runtime/src/pages/error-handling/compile-error.js new file mode 100644 index 0000000000000..ff414984b6d2e --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/error-handling/compile-error.js @@ -0,0 +1,8 @@ +import React from "react" + +function CompileError() { + // compile-error + return

Working

+} + +export default CompileError diff --git a/e2e-tests/development-runtime/src/pages/error-handling/page-query-result-runtime-error.js b/e2e-tests/development-runtime/src/pages/error-handling/page-query-result-runtime-error.js new file mode 100644 index 0000000000000..151e250ae84cf --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/error-handling/page-query-result-runtime-error.js @@ -0,0 +1,25 @@ +import React from "react" +import { graphql } from "gatsby" + +function PageQueryRuntimeError({ data }) { + console.log(data.errorRecoveryJson) + if (data.errorRecoveryJson.hasError) { + throw new Error(`Page query results caused runtime error`) + } + return ( + <> +

Working

+
{JSON.stringify(data, null, 2)}
+ + ) +} + +export default PageQueryRuntimeError + +export const query = graphql` + { + errorRecoveryJson(selector: { eq: "page-query" }) { + hasError + } + } +` diff --git a/e2e-tests/development-runtime/src/pages/error-handling/query-validation-error.js b/e2e-tests/development-runtime/src/pages/error-handling/query-validation-error.js new file mode 100644 index 0000000000000..5f430dde4c865 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/error-handling/query-validation-error.js @@ -0,0 +1,19 @@ +import React from "react" +import { graphql } from "gatsby" + +function QueryValidationError() { + return

Working

+} + +export default QueryValidationError + +export const query = graphql` + { + site { + siteMetadata { + title + # query-validation-error + } + } + } +` diff --git a/e2e-tests/development-runtime/src/pages/error-handling/runtime-error.js b/e2e-tests/development-runtime/src/pages/error-handling/runtime-error.js new file mode 100644 index 0000000000000..05b81b19d2c98 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/error-handling/runtime-error.js @@ -0,0 +1,8 @@ +import React from "react" + +function RuntimeError() { + // runtime-error + return

Working

+} + +export default RuntimeError diff --git a/e2e-tests/development-runtime/src/pages/error-handling/static-query-result-runtime-error.js b/e2e-tests/development-runtime/src/pages/error-handling/static-query-result-runtime-error.js new file mode 100644 index 0000000000000..98456cc464327 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/error-handling/static-query-result-runtime-error.js @@ -0,0 +1,24 @@ +import React from "react" +import { graphql, useStaticQuery } from "gatsby" + +function StaticQueryRuntimeError() { + const data = useStaticQuery(graphql` + { + errorRecoveryJson(selector: { eq: "static-query" }) { + hasError + } + } + `) + console.log(data.errorRecoveryJson) + if (data.errorRecoveryJson.hasError) { + throw new Error(`Static query results caused runtime error`) + } + return ( + <> +

Working

+
{JSON.stringify(data, null, 2)}
+ + ) +} + +export default StaticQueryRuntimeError diff --git a/packages/gatsby/cache-dir/error-overlay-handler.js b/packages/gatsby/cache-dir/error-overlay-handler.js index f02b70a2c0cc1..62ac7e3d18882 100644 --- a/packages/gatsby/cache-dir/error-overlay-handler.js +++ b/packages/gatsby/cache-dir/error-overlay-handler.js @@ -14,10 +14,44 @@ const ErrorOverlay = { if (process.env.GATSBY_HOT_LOADER !== `fast-refresh`) { // Report runtime errors + let registeredReloadListeners = false + function onError() { + if (registeredReloadListeners) { + return + } + + // Inspired by `react-dev-utils` HMR client: + // If there was unhandled error, reload browser + // on next HMR update + module.hot.addStatusHandler(status => { + if (status === `apply` || status === `idle`) { + window.location.reload() + } + }) + + // Additionally in Gatsby case query result updates can cause + // runtime error and also fix them, so reload on data updates + // as well + ___emitter.on(`pageQueryResult`, () => { + window.location.reload() + }) + ___emitter.on(`staticQueryResult`, () => { + window.location.reload() + }) + + registeredReloadListeners = true + } ReactErrorOverlay.startReportingRuntimeErrors({ - onError: () => {}, + onError, filename: `/commons.js`, }) + + // ReactErrorOverlay `onError` handler is triggered pretty late + // so we attach same error/unhandledrejection as ReactErrorOverlay + // to be able to detect runtime error and setup listeners faster + window.addEventListener(`error`, onError) + window.addEventListener(`unhandledrejection`, onError) + ReactErrorOverlay.setEditorHandler(errorLocation => window.fetch( `/__open-stack-frame-in-editor?fileName=` + diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 9045f72a3ee9f..4583a6c26e51e 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -126,7 +126,7 @@ "query-string": "^6.13.1", "raw-loader": "^0.5.1", "react-dev-utils": "^4.2.3", - "react-error-overlay": "^3.0.0", + "react-error-overlay": "^6.0.7", "react-hot-loader": "^4.12.21", "react-refresh": "^0.8.3", "redux": "^4.0.5", diff --git a/yarn.lock b/yarn.lock index 595695c42f0ef..945ce7e72a2c3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19698,6 +19698,11 @@ react-error-overlay@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-3.0.0.tgz#c2bc8f4d91f1375b3dad6d75265d51cd5eeaf655" +react-error-overlay@^6.0.7: + version "6.0.7" + resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108" + integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA== + react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"