From 40810c46d18753ea10c8f60ebdabc164482e7983 Mon Sep 17 00:00:00 2001 From: Jude Agboola Date: Thu, 14 Jul 2022 13:05:52 +0100 Subject: [PATCH] feat(gatsby): Gatsby Head API (#35980) Co-authored-by: Michal Piechowiak Co-authored-by: pieh Co-authored-by: tyhopp Co-authored-by: Lennart Co-authored-by: Ty Hopp --- .circleci/config.yml | 9 ++ .../limited-exports-page-templates.js | 6 +- .../head-function-export/correct-props.js | 32 ++++ .../head-function-export/fs-route-api.js | 6 + .../head-function-export/html-insertion.js | 99 ++++++++++++ .../head-function-export/invalid-elements.js | 13 ++ .../head-function-export/navigation.js | 114 ++++++++++++++ .../head-function-export/typescript.js | 11 ++ .../head-function-export/warnings.js | 28 ++++ .../integration/hot-reloading/head-export.js | 28 ++++ .../hot-reloading/page-component.js | 32 +++- .../development-runtime/gatsby-config.js | 6 + e2e-tests/development-runtime/gatsby-node.js | 29 ++++ .../shared-data/head-function-export.js | 63 ++++++++ .../src/components/head-function-export.js | 67 ++++++++ .../src/pages/head-function-export/basic.js | 55 +++++++ .../src/pages/head-function-export/dsg.js | 35 +++++ .../head-function-export/invalid-elements.js | 22 +++ .../pages/head-function-export/page-query.js | 61 ++++++++ .../re-exported-function.js | 12 ++ .../src/pages/head-function-export/ssr.js | 37 +++++ .../static-query-component.js | 15 ++ .../pages/head-function-export/tsx-page.tsx | 23 +++ .../pages/head-function-export/warnings.js | 20 +++ .../head-function-export/without-head.js | 9 ++ .../{HeadFunctionExportFsRouteApi.slug}.js | 16 ++ .../head-function-export/correct-props.js | 58 +++++++ .../used-by-head-function-export-basic.css | 3 + .../used-by-head-function-export-dsg.css | 3 + .../used-by-head-function-export-query.css | 3 + .../used-by-head-function-export-ssr.css | 3 + .../head-function-export/fs-route-api.js | 6 + .../head-function-export/html-insertion.js | 99 ++++++++++++ .../head-function-export/invalid-elements.js | 13 ++ .../head-function-export/navigation.js | 114 ++++++++++++++ .../head-function-export/typescript.js | 11 ++ e2e-tests/production-runtime/gatsby-config.js | 6 + e2e-tests/production-runtime/gatsby-node.ts | 20 +++ .../shared-data/head-function-export.js | 59 +++++++ .../src/components/head-function-export.js | 67 ++++++++ .../src/pages/head-function-export/basic.js | 41 +++++ .../src/pages/head-function-export/dsg.js | 35 +++++ .../head-function-export/invalid-elements.js | 22 +++ .../pages/head-function-export/page-query.js | 61 ++++++++ .../re-exported-function.js | 12 ++ .../src/pages/head-function-export/ssr.js | 37 +++++ .../static-query-component.js | 15 ++ .../pages/head-function-export/tsx-page.tsx | 23 +++ .../head-function-export/without-head.js | 9 ++ .../{HeadFunctionExportFsRouteApi.slug}.js | 15 ++ .../used-by-head-function-export-basic.css | 3 + .../used-by-head-function-export-dsg.css | 3 + .../used-by-head-function-export-query.css | 3 + .../used-by-head-function-export-ssr.css | 3 + .../head-function-export/.gitignore | 3 + .../head-function-export/README.md | 15 ++ .../__tests__/ssr-html-output.js | 76 +++++++++ .../head-function-export/gatsby-config.js | 11 ++ .../head-function-export/jest-transformer.js | 5 + .../head-function-export/jest.config.js | 13 ++ .../head-function-export/package.json | 29 ++++ .../shared-data/head-function-export.js | 31 ++++ .../src/components/head-function-export.js | 67 ++++++++ .../head-function-export/src/pages/404.js | 13 ++ .../src/pages/head-function-export/basic.js | 36 +++++ .../pages/head-function-export/page-query.js | 54 +++++++ .../re-exported-function.js | 12 ++ .../static-query-component.js | 15 ++ .../head-function-export/src/pages/index.js | 11 ++ integration-tests/ssr/__tests__/ssr.js | 23 +++ .../ssr/src/pages/head-function-export.js | 9 ++ .../src/babel-preset-react.js | 3 + .../src/__tests__/gatsby-node.js | 7 +- .../src/gatsby-node.js | 11 +- .../__snapshots__/dev-loader.js.snap | 7 +- .../__tests__/__snapshots__/loader.js.snap | 1 + .../gatsby/cache-dir/__tests__/dev-loader.js | 2 + .../gatsby/cache-dir/__tests__/head/utils.js | 145 ++++++++++++++++++ packages/gatsby/cache-dir/__tests__/loader.js | 1 + packages/gatsby/cache-dir/dev-loader.js | 9 +- .../components/runtime-errors.js | 7 +- .../components/fire-callback-in-effect.js | 12 ++ packages/gatsby/cache-dir/head/constants.js | 8 + .../head/head-export-handler-for-browser.js | 82 ++++++++++ .../head/head-export-handler-for-ssr.js | 78 ++++++++++ packages/gatsby/cache-dir/head/utils.js | 59 +++++++ packages/gatsby/cache-dir/loader.js | 54 ++++--- packages/gatsby/cache-dir/page-renderer.js | 62 +++++--- packages/gatsby/cache-dir/production-app.js | 19 +-- packages/gatsby/cache-dir/react-dom-utils.js | 33 ++++ .../cache-dir/ssr-develop-static-entry.js | 14 +- packages/gatsby/cache-dir/static-entry.js | 29 ++-- packages/gatsby/index.d.ts | 29 ++++ packages/gatsby/package.json | 1 + .../gatsby/src/bootstrap/requires-writer.ts | 57 +++++-- packages/gatsby/src/query/file-parser.js | 4 +- .../schema/graphql-engine/bundle-webpack.ts | 21 ++- .../webpack-remove-apis-loader.ts | 26 ---- .../__snapshots__/webpack-utils.ts.snap | 1 + .../gatsby/src/utils/babel-loader-helpers.js | 23 ++- packages/gatsby/src/utils/babel-loader.js | 8 +- .../fixtures/remove-apis/options.json | 3 +- .../remove-apis/re-exported/input.mjs | 7 + .../remove-apis/re-exported/output.mjs | 4 + .../utils/babel/babel-plugin-remove-api.ts | 21 ++- .../limited-exports-page-templates.ts | 17 ++ .../limited-exports-page-templates.ts | 28 +++- packages/gatsby/src/utils/webpack-utils.ts | 3 +- packages/gatsby/src/utils/webpack.config.js | 31 ++-- .../loaders/webpack-remove-exports-loader.ts | 61 ++++++++ .../webpack/plugins/static-query-mapper.ts | 92 +++++++---- yarn.lock | 49 +++++- 112 files changed, 2943 insertions(+), 204 deletions(-) create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/correct-props.js create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/fs-route-api.js create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/html-insertion.js create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/invalid-elements.js create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/navigation.js create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/typescript.js create mode 100644 e2e-tests/development-runtime/cypress/integration/head-function-export/warnings.js create mode 100644 e2e-tests/development-runtime/cypress/integration/hot-reloading/head-export.js create mode 100644 e2e-tests/development-runtime/shared-data/head-function-export.js create mode 100644 e2e-tests/development-runtime/src/components/head-function-export.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/basic.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/dsg.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/invalid-elements.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/page-query.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/re-exported-function.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/ssr.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/static-query-component.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/tsx-page.tsx create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/warnings.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/without-head.js create mode 100644 e2e-tests/development-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js create mode 100644 e2e-tests/development-runtime/src/templates/head-function-export/correct-props.js create mode 100644 e2e-tests/development-runtime/static/used-by-head-function-export-basic.css create mode 100644 e2e-tests/development-runtime/static/used-by-head-function-export-dsg.css create mode 100644 e2e-tests/development-runtime/static/used-by-head-function-export-query.css create mode 100644 e2e-tests/development-runtime/static/used-by-head-function-export-ssr.css create mode 100644 e2e-tests/production-runtime/cypress/integration/head-function-export/fs-route-api.js create mode 100644 e2e-tests/production-runtime/cypress/integration/head-function-export/html-insertion.js create mode 100644 e2e-tests/production-runtime/cypress/integration/head-function-export/invalid-elements.js create mode 100644 e2e-tests/production-runtime/cypress/integration/head-function-export/navigation.js create mode 100644 e2e-tests/production-runtime/cypress/integration/head-function-export/typescript.js create mode 100644 e2e-tests/production-runtime/shared-data/head-function-export.js create mode 100644 e2e-tests/production-runtime/src/components/head-function-export.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/basic.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/dsg.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/invalid-elements.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/page-query.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/re-exported-function.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/ssr.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/static-query-component.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/tsx-page.tsx create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/without-head.js create mode 100644 e2e-tests/production-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js create mode 100644 e2e-tests/production-runtime/static/used-by-head-function-export-basic.css create mode 100644 e2e-tests/production-runtime/static/used-by-head-function-export-dsg.css create mode 100644 e2e-tests/production-runtime/static/used-by-head-function-export-query.css create mode 100644 e2e-tests/production-runtime/static/used-by-head-function-export-ssr.css create mode 100644 integration-tests/head-function-export/.gitignore create mode 100644 integration-tests/head-function-export/README.md create mode 100644 integration-tests/head-function-export/__tests__/ssr-html-output.js create mode 100644 integration-tests/head-function-export/gatsby-config.js create mode 100644 integration-tests/head-function-export/jest-transformer.js create mode 100644 integration-tests/head-function-export/jest.config.js create mode 100644 integration-tests/head-function-export/package.json create mode 100644 integration-tests/head-function-export/shared-data/head-function-export.js create mode 100644 integration-tests/head-function-export/src/components/head-function-export.js create mode 100644 integration-tests/head-function-export/src/pages/404.js create mode 100644 integration-tests/head-function-export/src/pages/head-function-export/basic.js create mode 100644 integration-tests/head-function-export/src/pages/head-function-export/page-query.js create mode 100644 integration-tests/head-function-export/src/pages/head-function-export/re-exported-function.js create mode 100644 integration-tests/head-function-export/src/pages/head-function-export/static-query-component.js create mode 100644 integration-tests/head-function-export/src/pages/index.js create mode 100644 integration-tests/ssr/src/pages/head-function-export.js create mode 100644 packages/babel-preset-gatsby/src/babel-preset-react.js create mode 100644 packages/gatsby/cache-dir/__tests__/head/utils.js create mode 100644 packages/gatsby/cache-dir/head/components/fire-callback-in-effect.js create mode 100644 packages/gatsby/cache-dir/head/constants.js create mode 100644 packages/gatsby/cache-dir/head/head-export-handler-for-browser.js create mode 100644 packages/gatsby/cache-dir/head/head-export-handler-for-ssr.js create mode 100644 packages/gatsby/cache-dir/head/utils.js create mode 100644 packages/gatsby/cache-dir/react-dom-utils.js delete mode 100644 packages/gatsby/src/schema/graphql-engine/webpack-remove-apis-loader.ts create mode 100644 packages/gatsby/src/utils/babel/__tests__/fixtures/remove-apis/re-exported/input.mjs create mode 100644 packages/gatsby/src/utils/babel/__tests__/fixtures/remove-apis/re-exported/output.mjs create mode 100644 packages/gatsby/src/utils/webpack/loaders/webpack-remove-exports-loader.ts diff --git a/.circleci/config.yml b/.circleci/config.yml index 52332639c16ba..d901e42b3ebf9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -320,6 +320,13 @@ jobs: test_path: integration-tests/functions test_command: yarn test + integration_tests_head_function_export: + executor: node + steps: + - e2e-test: + test_path: integration-tests/head-function-export + test_command: yarn test + e2e_tests_path-prefix: <<: *e2e-executor environment: @@ -626,6 +633,8 @@ workflows: <<: *e2e-test-workflow - integration_tests_functions: <<: *e2e-test-workflow + - integration_tests_head_function_export: + <<: *e2e-test-workflow - integration_tests_gatsby_cli: requires: - bootstrap diff --git a/e2e-tests/development-runtime/cypress/integration/eslint-rules/limited-exports-page-templates.js b/e2e-tests/development-runtime/cypress/integration/eslint-rules/limited-exports-page-templates.js index 886c557b3a855..8e2afcd307d13 100644 --- a/e2e-tests/development-runtime/cypress/integration/eslint-rules/limited-exports-page-templates.js +++ b/e2e-tests/development-runtime/cypress/integration/eslint-rules/limited-exports-page-templates.js @@ -23,7 +23,7 @@ describe(`limited-exports-page-templates`, () => { it(`should initially not log to console`, () => { cy.get(`@hmrConsoleLog`).should( `not.be.calledWithMatch`, - /13:1 {2}warning {2}In page templates only a default export of a valid React component and the named exports of a page query, getServerData or config are allowed./i + /13:1 {2}warning {2}In page templates only a default export of a valid React component and the named exports of a page query, getServerData, Head or config are allowed./i ) }) it(`should log warning to console for invalid export`, () => { @@ -34,11 +34,11 @@ describe(`limited-exports-page-templates`, () => { cy.get(`@hmrConsoleLog`).should( `be.calledWithMatch`, - /13:1 {2}warning {2}In page templates only a default export of a valid React component and the named exports of a page query, getServerData or config are allowed./i + /13:1 {2}warning {2}In page templates only a default export of a valid React component and the named exports of a page query, getServerData, Head or config are allowed./i ) cy.get(`@hmrConsoleLog`).should( `not.be.calledWithMatch`, - /15:1 {2}warning {2}In page templates only a default export of a valid React component and the named exports of a page query, getServerData or config are allowed./i + /15:1 {2}warning {2}In page templates only a default export of a valid React component and the named exports of a page query, getServerData, Head or config are allowed./i ) }) }) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/correct-props.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/correct-props.js new file mode 100644 index 0000000000000..76b81c9f2efa5 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/correct-props.js @@ -0,0 +1,32 @@ +import headFunctionExportSharedData from "../../../shared-data/head-function-export.js" + +it(`Head function export receive correct props`, () => { + cy.visit(headFunctionExportSharedData.page.correctProps).waitForRouteChange() + + const data = { + site: { + siteMetadata: { + headFunctionExport: { + ...headFunctionExportSharedData.data.queried, + }, + }, + }, + } + const location = { + pathname: headFunctionExportSharedData.page.correctProps, + } + + const pageContext = headFunctionExportSharedData.data.context + + cy.getTestElement(`pageContext`) + .invoke(`attr`, `content`) + .should(`equal`, JSON.stringify(pageContext, null, 2)) + + cy.getTestElement(`location`) + .invoke(`attr`, `content`) + .should(`equal`, JSON.stringify(location, null, 2)) + + cy.getTestElement(`data`) + .invoke(`attr`, `content`) + .should(`equal`, JSON.stringify(data, null, 2)) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/fs-route-api.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/fs-route-api.js new file mode 100644 index 0000000000000..946f191128f17 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/fs-route-api.js @@ -0,0 +1,6 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +it(`Head function export with FS Route API should work`, () => { + cy.visit(page.fsRouteApi).waitForRouteChange() + cy.getTestElement(`title`).should(`have.text`, data.fsRouteApi.slug) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/html-insertion.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/html-insertion.js new file mode 100644 index 0000000000000..d1783554c22a2 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/html-insertion.js @@ -0,0 +1,99 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +describe(`Head function export html insertion`, () => { + it(`should work with static data`, () => { + cy.visit(page.basic).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + }) + + it(`should work with data from a page query`, () => { + cy.visit(page.pageQuery).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.base) + cy.getTestElement(`title`).should(`have.text`, data.queried.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.queried.noscript) + cy.getTestElement(`style`).should(`contain`, data.queried.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.link) + }) + + it(`should work when a head function with static data is re-exported from the page`, () => { + cy.visit(page.reExport).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + }) + + it(`should work when an imported Head component with queried data is used`, () => { + cy.visit(page.staticQuery).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.base) + cy.getTestElement(`title`).should(`have.text`, data.queried.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.queried.noscript) + cy.getTestElement(`style`).should(`contain`, data.queried.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.link) + }) + + it(`should work in a DSG page (exporting function named config)`, () => { + cy.visit(page.dsg).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.dsg.base) + cy.getTestElement(`title`).should(`have.text`, data.dsg.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.dsg.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.dsg.noscript) + cy.getTestElement(`style`).should(`contain`, data.dsg.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.dsg.link) + }) + + it(`should work in an SSR page (exporting function named getServerData)`, () => { + cy.visit(page.ssr).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.ssr.base) + cy.getTestElement(`title`).should(`have.text`, data.ssr.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.ssr.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.ssr.noscript) + cy.getTestElement(`style`).should(`contain`, data.ssr.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.ssr.link) + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/invalid-elements.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/invalid-elements.js new file mode 100644 index 0000000000000..26ab3b6fff7a1 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/invalid-elements.js @@ -0,0 +1,13 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +it(`Head function export should not include invalid elements`, () => { + cy.visit(page.invalidElements).waitForRouteChange() + + cy.get(`head > h1`).should(`not.exist`) + cy.get(`head > div`).should(`not.exist`) + cy.get(`head > audio`).should(`not.exist`) + cy.get(`head > video`).should(`not.exist`) + cy.get(`head > title`) + .should(`exist`) + .and(`have.text`, data.invalidElements.title) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/navigation.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/navigation.js new file mode 100644 index 0000000000000..dc855eb015808 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/navigation.js @@ -0,0 +1,114 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +// No need to test SSR navigation (anchor tags) because it's effectively covered in the html insertion tests + +describe(`Head function export behavior during CSR navigation (Gatsby Link)`, () => { + it(`should remove tags not on next page`, () => { + cy.visit(page.basic).waitForRouteChange() + + cy.getTestElement(`extra-meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.extraMeta) + + cy.getTestElement(`gatsby-link`).click().waitForRouteChange() + + cy.get(`[data-testid="extra-meta"]`).should(`not.exist`) + }) + + it(`should add tags not on next page`, () => { + cy.visit(page.basic).waitForRouteChange() + + cy.get(`[data-testid="extra-meta-2"]`).should(`not.exist`) + + cy.getTestElement(`gatsby-link`).click().waitForRouteChange() + + cy.getTestElement(`extra-meta-2`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.extraMeta2) + }) + + it(`should not contain tags from old tags when we navigate to page without Head export`, () => { + cy.visit(page.basic).waitForRouteChange() + + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + + cy.getTestElement(`navigate-to-page-without-head-export`) + .click() + .waitForRouteChange() + + cy.getTestElement(`base`).should(`not.exist`) + cy.getTestElement(`title`).should(`not.exist`) + cy.getTestElement(`meta`).should(`not.exist`) + cy.getTestElement(`noscript`).should(`not.exist`) + cy.getTestElement(`style`).should(`not.exist`) + cy.getTestElement(`link`).should(`not.exist`) + }) + + /** + * Technically nodes are always removed from the DOM and new ones added (in other words nodes are not reused with different data), + * but since this is an implementation detail we'll still test the behavior we expect as if we didn't know that. + */ + it(`should change meta tag values`, () => { + // Initial load + cy.visit(page.basic).waitForRouteChange() + + // Validate data from initial load + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + + // Navigate to a different page via Gatsby Link + cy.getTestElement(`gatsby-link`).click() + + // Validate data on navigated-to page + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.base) + cy.getTestElement(`title`).should(`have.text`, data.queried.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.queried.noscript) + cy.getTestElement(`style`).should(`contain`, data.queried.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.link) + + // Navigate back to original page via Gatsby Link + cy.getTestElement(`gatsby-link`).click().waitForRouteChange() + + // Validate data is same as initial load + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/typescript.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/typescript.js new file mode 100644 index 0000000000000..af764ae395007 --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/typescript.js @@ -0,0 +1,11 @@ +describe(`Tsx Pages`, () => { + it(`Works with Head export`, () => { + cy.visit(`/head-function-export/tsx-page`) + + cy.getTestElement(`title`).should(`contain`, `TypeScript`) + + cy.getTestElement(`name`) + .invoke(`attr`, `content`) + .should(`equal`, `TypeScript`) + }) +}) diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/warnings.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/warnings.js new file mode 100644 index 0000000000000..a06a256dcb07c --- /dev/null +++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/warnings.js @@ -0,0 +1,28 @@ +import { VALID_NODE_NAMES } from "gatsby/cache-dir/head/constants" +import { page } from "../../../shared-data/head-function-export.js" + +describe(`Head function export should warn`, () => { + beforeEach(() => { + cy.visit(page.warnings, { + onBeforeLoad(win) { + cy.stub(win.console, `warn`).as(`consoleWarn`) + }, + }).waitForRouteChange() + }) + + it(`for elements that belong in the body`, () => { + cy.get(`@consoleWarn`).should( + `be.calledWith`, + `

is not a valid head element. Please use one of the following: ${VALID_NODE_NAMES.join( + `, ` + )}` + ) + }) + + it(`for scripts that could use the script component`, () => { + cy.get(`@consoleWarn`).should( + `be.calledWith`, + `Do not add scripts here. Please use the + + ) +} diff --git a/e2e-tests/development-runtime/src/pages/head-function-export/without-head.js b/e2e-tests/development-runtime/src/pages/head-function-export/without-head.js new file mode 100644 index 0000000000000..8da3c37a2f8ef --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/head-function-export/without-head.js @@ -0,0 +1,9 @@ +import * as React from "react" + +export default function WithoutHead() { + return ( + <> +

I am used to test cases where we navigae to a page without Head export

+ + ) +} diff --git a/e2e-tests/development-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js b/e2e-tests/development-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js new file mode 100644 index 0000000000000..4a56503da7a67 --- /dev/null +++ b/e2e-tests/development-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js @@ -0,0 +1,16 @@ +import * as React from "react" + +export default function HeadFunctionExportFsRouteApi(props) { + return ( + <> +

I test usage for the Head function export with the FS Route API

+
{JSON.stringify(props, null, 2)}
+ + ) +} + +export function Head(props) { + const { pageContext } = props || {} + + return {pageContext.slug} +} diff --git a/e2e-tests/development-runtime/src/templates/head-function-export/correct-props.js b/e2e-tests/development-runtime/src/templates/head-function-export/correct-props.js new file mode 100644 index 0000000000000..cc5c4bb5ae5df --- /dev/null +++ b/e2e-tests/development-runtime/src/templates/head-function-export/correct-props.js @@ -0,0 +1,58 @@ +import * as React from "react" +import { graphql } from "gatsby" + +export default function CorrectProps() { + return ( + <> +

+ I test usage for the Head function export to make sure all props are + received +

+

+ I am created with the createPage API and I receive some + context +

+ + ) +} + +export const pageQuery = graphql` + query MetaDataPageQuery { + site { + siteMetadata { + headFunctionExport { + base + title + meta + noscript + style + link + extraMeta2 + } + } + } + } +` + +export function Head(props) { + const { data, pageContext, location } = props + return ( + <> + + + + + ) +} diff --git a/e2e-tests/development-runtime/static/used-by-head-function-export-basic.css b/e2e-tests/development-runtime/static/used-by-head-function-export-basic.css new file mode 100644 index 0000000000000..0dfaff5b37990 --- /dev/null +++ b/e2e-tests/development-runtime/static/used-by-head-function-export-basic.css @@ -0,0 +1,3 @@ +p { + color: rebeccapurple; +} diff --git a/e2e-tests/development-runtime/static/used-by-head-function-export-dsg.css b/e2e-tests/development-runtime/static/used-by-head-function-export-dsg.css new file mode 100644 index 0000000000000..c72abd677bf50 --- /dev/null +++ b/e2e-tests/development-runtime/static/used-by-head-function-export-dsg.css @@ -0,0 +1,3 @@ +p { + color: orange; +} diff --git a/e2e-tests/development-runtime/static/used-by-head-function-export-query.css b/e2e-tests/development-runtime/static/used-by-head-function-export-query.css new file mode 100644 index 0000000000000..ce7da04630a36 --- /dev/null +++ b/e2e-tests/development-runtime/static/used-by-head-function-export-query.css @@ -0,0 +1,3 @@ +p { + color: blue; +} diff --git a/e2e-tests/development-runtime/static/used-by-head-function-export-ssr.css b/e2e-tests/development-runtime/static/used-by-head-function-export-ssr.css new file mode 100644 index 0000000000000..87002430abc45 --- /dev/null +++ b/e2e-tests/development-runtime/static/used-by-head-function-export-ssr.css @@ -0,0 +1,3 @@ +p { + color: green; +} diff --git a/e2e-tests/production-runtime/cypress/integration/head-function-export/fs-route-api.js b/e2e-tests/production-runtime/cypress/integration/head-function-export/fs-route-api.js new file mode 100644 index 0000000000000..946f191128f17 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/head-function-export/fs-route-api.js @@ -0,0 +1,6 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +it(`Head function export with FS Route API should work`, () => { + cy.visit(page.fsRouteApi).waitForRouteChange() + cy.getTestElement(`title`).should(`have.text`, data.fsRouteApi.slug) +}) diff --git a/e2e-tests/production-runtime/cypress/integration/head-function-export/html-insertion.js b/e2e-tests/production-runtime/cypress/integration/head-function-export/html-insertion.js new file mode 100644 index 0000000000000..f518afe0c3572 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/head-function-export/html-insertion.js @@ -0,0 +1,99 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +describe(`Head function export html insertion`, () => { + it(`should work with static data`, () => { + cy.visit(page.basic).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + }) + + it(`should work with data from a page query`, () => { + cy.visit(page.pageQuery).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.base) + cy.getTestElement(`title`).should(`have.text`, data.queried.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.queried.noscript) + cy.getTestElement(`style`).should(`contain`, data.queried.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.link) + }) + + it(`should work when a Head function with static data is re-exported from the page`, () => { + cy.visit(page.reExport).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + }) + + it(`should work when an imported Head component with queried data is used`, () => { + cy.visit(page.staticQuery).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.base) + cy.getTestElement(`title`).should(`have.text`, data.queried.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.queried.noscript) + cy.getTestElement(`style`).should(`contain`, data.queried.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.link) + }) + + it(`should work in a DSG page (exporting function named config)`, () => { + cy.visit(page.dsg).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.dsg.base) + cy.getTestElement(`title`).should(`have.text`, data.dsg.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.dsg.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.dsg.noscript) + cy.getTestElement(`style`).should(`contain`, data.dsg.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.dsg.link) + }) + + it(`should work in an SSR page (exporting function named getServerData)`, () => { + cy.visit(page.ssr).waitForRouteChange() + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.ssr.base) + cy.getTestElement(`title`).should(`have.text`, data.ssr.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.ssr.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.ssr.noscript) + cy.getTestElement(`style`).should(`contain`, data.ssr.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.ssr.link) + }) +}) diff --git a/e2e-tests/production-runtime/cypress/integration/head-function-export/invalid-elements.js b/e2e-tests/production-runtime/cypress/integration/head-function-export/invalid-elements.js new file mode 100644 index 0000000000000..26ab3b6fff7a1 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/head-function-export/invalid-elements.js @@ -0,0 +1,13 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +it(`Head function export should not include invalid elements`, () => { + cy.visit(page.invalidElements).waitForRouteChange() + + cy.get(`head > h1`).should(`not.exist`) + cy.get(`head > div`).should(`not.exist`) + cy.get(`head > audio`).should(`not.exist`) + cy.get(`head > video`).should(`not.exist`) + cy.get(`head > title`) + .should(`exist`) + .and(`have.text`, data.invalidElements.title) +}) diff --git a/e2e-tests/production-runtime/cypress/integration/head-function-export/navigation.js b/e2e-tests/production-runtime/cypress/integration/head-function-export/navigation.js new file mode 100644 index 0000000000000..a25e7519a3926 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/head-function-export/navigation.js @@ -0,0 +1,114 @@ +import { page, data } from "../../../shared-data/head-function-export.js" + +// No need to test SSR navigation (anchor tags) because it's effectively covered in the html insertion tests + +describe(`Head function export behavior during CSR navigation (Gatsby Link)`, () => { + it(`should remove tags not on next page`, () => { + cy.visit(page.basic).waitForRouteChange() + + cy.getTestElement(`extra-meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.extraMeta) + + cy.getTestElement(`gatsby-link`).click().waitForRouteChange() + + cy.get(`[data-testid="extra-meta"]`).should(`not.exist`) + }) + + it(`should add tags not on next page`, () => { + cy.visit(page.basic).waitForRouteChange() + + cy.get(`[data-testid="extra-meta-2"]`).should(`not.exist`) + + cy.getTestElement(`gatsby-link`).click() + + cy.getTestElement(`extra-meta-2`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.extraMeta2) + }) + + it(`should not contain tags from old tags when we navigate to page without Head export`, () => { + cy.visit(page.basic).waitForRouteChange() + + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + + cy.getTestElement(`navigate-to-page-without-head-export`) + .click() + .waitForRouteChange() + + cy.getTestElement(`base`).should(`not.exist`) + cy.getTestElement(`title`).should(`not.exist`) + cy.getTestElement(`meta`).should(`not.exist`) + cy.getTestElement(`noscript`).should(`not.exist`) + cy.getTestElement(`style`).should(`not.exist`) + cy.getTestElement(`link`).should(`not.exist`) + }) + + /** + * Technically nodes are always removed from the DOM and new ones added (in other words nodes are not reused with different data), + * but since this is an implementation detail we'll still test the behavior we expect as if we didn't know that. + */ + it(`should change meta tag values`, () => { + // Initial load + cy.visit(page.basic).waitForRouteChange() + + // Validate data from initial load + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + + // Navigate to a different page via Gatsby Link + cy.getTestElement(`gatsby-link`).click().waitForRouteChange() + + // Validate data on navigated-to page + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.base) + cy.getTestElement(`title`).should(`have.text`, data.queried.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.queried.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.queried.noscript) + cy.getTestElement(`style`).should(`contain`, data.queried.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.queried.link) + + // Navigate back to original page via Gatsby Link + cy.getTestElement(`gatsby-link`).click().waitForRouteChange() + + // Validate data is same as initial load + cy.getTestElement(`base`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.base) + cy.getTestElement(`title`).should(`have.text`, data.static.title) + cy.getTestElement(`meta`) + .invoke(`attr`, `content`) + .should(`equal`, data.static.meta) + cy.getTestElement(`noscript`).should(`have.text`, data.static.noscript) + cy.getTestElement(`style`).should(`contain`, data.static.style) + cy.getTestElement(`link`) + .invoke(`attr`, `href`) + .should(`equal`, data.static.link) + }) +}) diff --git a/e2e-tests/production-runtime/cypress/integration/head-function-export/typescript.js b/e2e-tests/production-runtime/cypress/integration/head-function-export/typescript.js new file mode 100644 index 0000000000000..af764ae395007 --- /dev/null +++ b/e2e-tests/production-runtime/cypress/integration/head-function-export/typescript.js @@ -0,0 +1,11 @@ +describe(`Tsx Pages`, () => { + it(`Works with Head export`, () => { + cy.visit(`/head-function-export/tsx-page`) + + cy.getTestElement(`title`).should(`contain`, `TypeScript`) + + cy.getTestElement(`name`) + .invoke(`attr`, `content`) + .should(`equal`, `TypeScript`) + }) +}) diff --git a/e2e-tests/production-runtime/gatsby-config.js b/e2e-tests/production-runtime/gatsby-config.js index 1838a357d1862..e494d09979334 100644 --- a/e2e-tests/production-runtime/gatsby-config.js +++ b/e2e-tests/production-runtime/gatsby-config.js @@ -1,8 +1,14 @@ +const { + data: headFunctionExportData, +} = require(`./shared-data/head-function-export.js`) + module.exports = { siteMetadata: { title: `Gatsby Default Starter`, author: `Kyle Mathews`, description: `This is site for production runtime e2e tests`, + // Separate to avoid needing to change other tests that rely on site metadata + headFunctionExport: headFunctionExportData.queried, }, plugins: [ `gatsby-plugin-react-helmet`, diff --git a/e2e-tests/production-runtime/gatsby-node.ts b/e2e-tests/production-runtime/gatsby-node.ts index b15cbc324be7a..775be229ff9b5 100644 --- a/e2e-tests/production-runtime/gatsby-node.ts +++ b/e2e-tests/production-runtime/gatsby-node.ts @@ -36,6 +36,14 @@ export const createSchemaCustomization: GatsbyNode["createSchemaCustomization"] } ) ) + + actions.createTypes(`#graphql + type HeadFunctionExportFsRouteApi implements Node { + id: ID! + slug: String! + content: String! + } + `) } const products = ["Burger", "Chicken"] @@ -101,6 +109,18 @@ export const sourceNodes: GatsbyNode["sourceNodes"] = ({ }, }) }) + + actions.createNode({ + id: createNodeId(`head-function-export-fs-route-api`), + slug: `/fs-route-api`, + parent: null, + children: [], + internal: { + type: `HeadFunctionExportFsRouteApi`, + content: `Some words`, + contentDigest: createContentDigest(`Some words`), + }, + }) } export const createPages: GatsbyNode["createPages"] = ({ diff --git a/e2e-tests/production-runtime/shared-data/head-function-export.js b/e2e-tests/production-runtime/shared-data/head-function-export.js new file mode 100644 index 0000000000000..c20460ae662ff --- /dev/null +++ b/e2e-tests/production-runtime/shared-data/head-function-export.js @@ -0,0 +1,59 @@ +const path = `/head-function-export` + +const page = { + basic: `${path}/basic/`, + pageQuery: `${path}/page-query/`, + reExport: `${path}/re-exported-function/`, + staticQuery: `${path}/static-query-component/`, + warnings: `${path}/warnings/`, + allProps: `${path}/all-props/`, + dsg: `${path}/dsg/`, + ssr: `${path}/ssr/`, + invalidElements: `${path}/invalid-elements/`, + fsRouteApi: `${path}/fs-route-api/`, +} + +const data = { + static: { + base: `http://localhost:9000`, + title: `Ella Fitzgerald's Page`, + meta: `Ella Fitzgerald`, + noscript: `You take romance - I will take Jell-O!`, + style: `rebeccapurple`, + link: `/used-by-head-function-export-basic.css`, + extraMeta: `Extra meta tag that should be removed during navigation`, + }, + queried: { + base: `http://localhost:9000`, + title: `Nat King Cole's Page`, + meta: `Nat King Cole`, + noscript: `There is just one thing I cannot figure out. My income tax!`, + style: `blue`, + link: `/used-by-head-function-export-query.css`, + extraMeta2: `Extra meta tag that should be added during navigation`, + }, + dsg: { + base: `http://localhost:9000`, + title: `Louis Armstrong's Page`, + meta: `Louis Armstrong`, + noscript: `What we play is life`, + style: `orange`, + link: `/used-by-head-function-export-dsg.css`, + }, + ssr: { + base: `http://localhost:9000`, + title: `Frank Sinatra's Page`, + meta: `Frank Sinatra`, + noscript: `You may be a puzzle, but I like the way the parts fit`, + style: `green`, + link: `/used-by-head-function-export-ssr.css`, + }, + invalidElements: { + title: `I should actually be inserted, unlike the others`, + }, + fsRouteApi: { + slug: `/fs-route-api`, + }, +} + +module.exports = { page, data } diff --git a/e2e-tests/production-runtime/src/components/head-function-export.js b/e2e-tests/production-runtime/src/components/head-function-export.js new file mode 100644 index 0000000000000..c261601b6c99f --- /dev/null +++ b/e2e-tests/production-runtime/src/components/head-function-export.js @@ -0,0 +1,67 @@ +import * as React from "react" +import { useStaticQuery, graphql } from "gatsby" +import { data } from "../../shared-data/head-function-export.js" + +function HeadComponent({ children }) { + const data = useStaticQuery(graphql` + query SiteMetaDataStaticQuery { + site { + siteMetadata { + headFunctionExport { + base + title + meta + noscript + style + link + } + } + } + } + `) + + const { base, title, meta, noscript, style, link } = + data?.site?.siteMetadata?.headFunctionExport || {} + + return ( + <> + + {title} + + + + + {children} + + ) +} + +function Head() { + const { base, title, meta, noscript, style, link } = data.static + + return ( + <> + + {title} + + + + + + ) +} + +export { Head } +export default HeadComponent diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/basic.js b/e2e-tests/production-runtime/src/pages/head-function-export/basic.js new file mode 100644 index 0000000000000..6845115bef687 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/basic.js @@ -0,0 +1,41 @@ +import * as React from "react" +import { Link } from "gatsby" +import { data } from "../../../shared-data/head-function-export" + +export default function HeadFunctionExportBasic() { + return ( + <> +

I test basic usage for the head function export

+

Some other words

+ + Navigate to page-query via Gatsby Link + + + Navigate to without head export + + + ) +} + +export function Head() { + const { base, title, meta, noscript, style, link, extraMeta } = data.static + + return ( + <> + + {title} + + + + + + + + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/dsg.js b/e2e-tests/production-runtime/src/pages/head-function-export/dsg.js new file mode 100644 index 0000000000000..4229aa633d39a --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/dsg.js @@ -0,0 +1,35 @@ +import * as React from "react" +import { data } from "../../../shared-data/head-function-export" + +export default function HeadFunctionExportDSG() { + return

I test the Head function export in a DSG page

+} + +export async function config() { + return () => { + return { + defer: true, + } + } +} + +export function Head() { + const { base, title, meta, noscript, style, link } = data.dsg + + return ( + <> + + {title} + + + + + + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/invalid-elements.js b/e2e-tests/production-runtime/src/pages/head-function-export/invalid-elements.js new file mode 100644 index 0000000000000..c85fcdd8c9410 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/invalid-elements.js @@ -0,0 +1,22 @@ +import * as React from "react" +import { data } from "../../../shared-data/head-function-export" + +export default function HeadFunctionExportInvalidElements() { + return ( + <> +

I test usage for the Head function export with invalid elements

+ + ) +} + +export function Head() { + return ( + <> +

Big, big energy

+
A div-ersion
+ + + {data.invalidElements.title} + + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/page-query.js b/e2e-tests/production-runtime/src/pages/head-function-export/page-query.js new file mode 100644 index 0000000000000..5adfbceac43a7 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/page-query.js @@ -0,0 +1,61 @@ +import * as React from "react" +import { graphql } from "gatsby" +import { Link } from "gatsby" + +export default function HeadFunctionExportPageQuery() { + return ( + <> +

I test usage for the Head function export with a page query

+

Some other words

+ + Navigate to basic via Gatsby Link + + + ) +} + +export function Head({ data }) { + const { base, title, meta, noscript, style, link, extraMeta2 } = + data?.site?.siteMetadata?.headFunctionExport || {} + + return ( + <> + + {title} + + + + + + + + ) +} + +export const pageQuery = graphql` + query SiteMetaDataPageQuery { + site { + siteMetadata { + headFunctionExport { + base + title + meta + noscript + style + link + extraMeta2 + } + } + } + } +` diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/re-exported-function.js b/e2e-tests/production-runtime/src/pages/head-function-export/re-exported-function.js new file mode 100644 index 0000000000000..31d17812e2a9f --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/re-exported-function.js @@ -0,0 +1,12 @@ +import * as React from "react" + +export { Head } from "../../components/head-function-export" + +export default function HeadFunctionExportReExported() { + return ( + <> +

I test usage for the Head function export re exported

+

Some other words

+ + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/ssr.js b/e2e-tests/production-runtime/src/pages/head-function-export/ssr.js new file mode 100644 index 0000000000000..8c9490cd12265 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/ssr.js @@ -0,0 +1,37 @@ +import * as React from "react" +import { data } from "../../../shared-data/head-function-export" + +export default function HeadFunctionExportSSR() { + return ( +

+ I test the Head function export in an SSR page (using getServerData) +

+ ) +} + +export async function getServerData() { + return { + hello: `world`, + } +} + +export function Head() { + const { base, title, meta, noscript, style, link } = data.ssr + + return ( + <> + + {title} + + + + + + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/static-query-component.js b/e2e-tests/production-runtime/src/pages/head-function-export/static-query-component.js new file mode 100644 index 0000000000000..8da39645a8789 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/static-query-component.js @@ -0,0 +1,15 @@ +import * as React from "react" +import HeadComponent from "../../components/head-function-export" + +export default function HeadFunctionExportStaticQueryComponent() { + return ( + <> +

I test usage for the Head function export via a common component

+

Some other words

+ + ) +} + +export function Head() { + return +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/tsx-page.tsx b/e2e-tests/production-runtime/src/pages/head-function-export/tsx-page.tsx new file mode 100644 index 0000000000000..ff70bb3c373fc --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/tsx-page.tsx @@ -0,0 +1,23 @@ +import * as React from "react" +import { HeadProps } from "gatsby" + +export default function TSXPageWithHeaExport() { + return ( +

+ I am a TS Page, I am used to test that Ts Pages with Head export work +

+ ) +} + +export function Head(props: HeadProps) { + const text = `TypeScript` + return ( + <> + {text} + + + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/without-head.js b/e2e-tests/production-runtime/src/pages/head-function-export/without-head.js new file mode 100644 index 0000000000000..715748dafb3be --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/without-head.js @@ -0,0 +1,9 @@ +import * as React from "react" + +export default function WithoutHead() { + return ( + <> +

I am used to test cases where we navigate to a page without Head export

+ + ) +} diff --git a/e2e-tests/production-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js b/e2e-tests/production-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js new file mode 100644 index 0000000000000..c784aaf3a8b06 --- /dev/null +++ b/e2e-tests/production-runtime/src/pages/head-function-export/{HeadFunctionExportFsRouteApi.slug}.js @@ -0,0 +1,15 @@ +import * as React from "react" + +export default function HeadFunctionExportFsRouteApi() { + return ( + <> +

I test usage for the Head function export with the FS Route API

+ + ) +} + +export function Head(props) { + const { pageContext } = props || {} + + return {pageContext.slug} +} diff --git a/e2e-tests/production-runtime/static/used-by-head-function-export-basic.css b/e2e-tests/production-runtime/static/used-by-head-function-export-basic.css new file mode 100644 index 0000000000000..0dfaff5b37990 --- /dev/null +++ b/e2e-tests/production-runtime/static/used-by-head-function-export-basic.css @@ -0,0 +1,3 @@ +p { + color: rebeccapurple; +} diff --git a/e2e-tests/production-runtime/static/used-by-head-function-export-dsg.css b/e2e-tests/production-runtime/static/used-by-head-function-export-dsg.css new file mode 100644 index 0000000000000..c72abd677bf50 --- /dev/null +++ b/e2e-tests/production-runtime/static/used-by-head-function-export-dsg.css @@ -0,0 +1,3 @@ +p { + color: orange; +} diff --git a/e2e-tests/production-runtime/static/used-by-head-function-export-query.css b/e2e-tests/production-runtime/static/used-by-head-function-export-query.css new file mode 100644 index 0000000000000..ce7da04630a36 --- /dev/null +++ b/e2e-tests/production-runtime/static/used-by-head-function-export-query.css @@ -0,0 +1,3 @@ +p { + color: blue; +} diff --git a/e2e-tests/production-runtime/static/used-by-head-function-export-ssr.css b/e2e-tests/production-runtime/static/used-by-head-function-export-ssr.css new file mode 100644 index 0000000000000..87002430abc45 --- /dev/null +++ b/e2e-tests/production-runtime/static/used-by-head-function-export-ssr.css @@ -0,0 +1,3 @@ +p { + color: green; +} diff --git a/integration-tests/head-function-export/.gitignore b/integration-tests/head-function-export/.gitignore new file mode 100644 index 0000000000000..557f97c6feb55 --- /dev/null +++ b/integration-tests/head-function-export/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.cache/ +public diff --git a/integration-tests/head-function-export/README.md b/integration-tests/head-function-export/README.md new file mode 100644 index 0000000000000..46c0278682dda --- /dev/null +++ b/integration-tests/head-function-export/README.md @@ -0,0 +1,15 @@ +# Integration Tests + +These are tests triggered via the `test:integration` script. They do not run in the browser, but rather run in a Jest JSDOM environment. This means that they're good for catching regressions, but may not catch _quite_ as much as the [e2e-tests](../e2e-tests) which do run in a real browser via Cypress. + +## Adding a new integration test + +- Create a folder `integration-tests/name-of-the-test` +- Copy structure from an existing test, e.g. [`integration-tests/head-function-export`](./head-function-export) +- Write your tests in `integration-tests/name-of-the-test/__tests__` + +## Running the tests + +Run `yarn test:integration` or `npm run test:integration` to run the suite of integration tests. + +Thanks for contributing to Gatsby! diff --git a/integration-tests/head-function-export/__tests__/ssr-html-output.js b/integration-tests/head-function-export/__tests__/ssr-html-output.js new file mode 100644 index 0000000000000..f60c7cd47a1ad --- /dev/null +++ b/integration-tests/head-function-export/__tests__/ssr-html-output.js @@ -0,0 +1,76 @@ +import { readFileSync } from "fs-extra" +import { parse } from "node-html-parser" +import { page, data } from "../shared-data/head-function-export.js" + +/** + * This test ensures that the head elements actually end up in the SSR'ed HTML. + * + * The production-runtime e2e test does the same but in the browser, and we want to make sure + * that we're not being tricked by the browser runtime inserting head elements. + */ + +const publicDir = `${__dirname}/../public` + +function getNodes(dom) { + const base = dom.querySelector(`[data-testid=base]`) + const title = dom.querySelector(`[data-testid=title]`) + const meta = dom.querySelector(`[data-testid=meta]`) + const noscript = dom.querySelector(`[data-testid=noscript]`) + const style = dom.querySelector(`[data-testid=style]`) + const link = dom.querySelector(`[data-testid=link]`) + return { base, title, meta, noscript, style, link } +} + +describe(`Head function export SSR'ed HTML output`, () => { + it(`should work with static data`, () => { + const html = readFileSync(`${publicDir}${page.basic}/index.html`) + const dom = parse(html) + const { base, title, meta, noscript, style, link } = getNodes(dom) + + expect(base.attributes.href).toEqual(data.static.base) + expect(title.text).toEqual(data.static.title) + expect(meta.attributes.content).toEqual(data.static.meta) + expect(noscript.text).toEqual(data.static.noscript) + expect(style.text).toContain(data.static.style) + expect(link.attributes.href).toEqual(data.static.link) + }) + + it(`should work with data from a page query`, () => { + const html = readFileSync(`${publicDir}${page.pageQuery}/index.html`) + const dom = parse(html) + const { base, title, meta, noscript, style, link } = getNodes(dom) + + expect(base.attributes.href).toEqual(data.queried.base) + expect(title.text).toEqual(data.queried.title) + expect(meta.attributes.content).toEqual(data.queried.meta) + expect(noscript.text).toEqual(data.queried.noscript) + expect(style.text).toContain(data.queried.style) + expect(link.attributes.href).toEqual(data.queried.link) + }) + + it(`should work when a Head function with static data is re-exported from the page`, () => { + const html = readFileSync(`${publicDir}${page.reExport}/index.html`) + const dom = parse(html) + const { base, title, meta, noscript, style, link } = getNodes(dom) + + expect(base.attributes.href).toEqual(data.static.base) + expect(title.text).toEqual(data.static.title) + expect(meta.attributes.content).toEqual(data.static.meta) + expect(noscript.text).toEqual(data.static.noscript) + expect(style.text).toContain(data.static.style) + expect(link.attributes.href).toEqual(data.static.link) + }) + + it(`should work when an imported Head component with queried data is used`, () => { + const html = readFileSync(`${publicDir}${page.staticQuery}/index.html`) + const dom = parse(html) + const { base, title, meta, noscript, style, link } = getNodes(dom) + + expect(base.attributes.href).toEqual(data.queried.base) + expect(title.text).toEqual(data.queried.title) + expect(meta.attributes.content).toEqual(data.queried.meta) + expect(noscript.text).toEqual(data.queried.noscript) + expect(style.text).toContain(data.queried.style) + expect(link.attributes.href).toEqual(data.queried.link) + }) +}) diff --git a/integration-tests/head-function-export/gatsby-config.js b/integration-tests/head-function-export/gatsby-config.js new file mode 100644 index 0000000000000..b0da95ef1d562 --- /dev/null +++ b/integration-tests/head-function-export/gatsby-config.js @@ -0,0 +1,11 @@ +const { + data: headFunctionExportData, +} = require(`./shared-data/head-function-export.js`) + +module.exports = { + siteMetadata: { + title: "head-function-export", + headFunctionExport: headFunctionExportData.queried, + }, + plugins: [], +} diff --git a/integration-tests/head-function-export/jest-transformer.js b/integration-tests/head-function-export/jest-transformer.js new file mode 100644 index 0000000000000..02167a152534c --- /dev/null +++ b/integration-tests/head-function-export/jest-transformer.js @@ -0,0 +1,5 @@ +const babelJest = require(`babel-jest`) + +module.exports = babelJest.default.createTransformer({ + presets: [`babel-preset-gatsby-package`], +}) diff --git a/integration-tests/head-function-export/jest.config.js b/integration-tests/head-function-export/jest.config.js new file mode 100644 index 0000000000000..8ec23e14f3462 --- /dev/null +++ b/integration-tests/head-function-export/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + testPathIgnorePatterns: [ + `/node_modules/`, + `__tests__/fixtures`, + `.cache`, + `src/test`, + `src/api`, + ], + watchPathIgnorePatterns: ["src/api", ".cache"], + transform: { + "^.+\\.[jt]sx?$": `./jest-transformer.js`, + }, +} diff --git a/integration-tests/head-function-export/package.json b/integration-tests/head-function-export/package.json new file mode 100644 index 0000000000000..112e6bb1e8b45 --- /dev/null +++ b/integration-tests/head-function-export/package.json @@ -0,0 +1,29 @@ +{ + "name": "head-function-export-integration-test", + "version": "1.0.0", + "private": true, + "author": "Ty Hopp", + "keywords": [ + "gatsby" + ], + "scripts": { + "clean": "gatsby clean", + "build": "gatsby build", + "develop": "gatsby develop", + "serve": "gatsby serve", + "test:jest": "jest", + "test": "npm-run-all -s build test:jest" + }, + "devDependencies": { + "babel-jest": "^27.4.5", + "babel-preset-gatsby-package": "^2.4.0", + "fs-extra": "^10.0.0", + "jest": "^27.2.1", + "npm-run-all": "4.1.5" + }, + "dependencies": { + "gatsby": "next", + "react": "^17.0.2", + "react-dom": "^17.0.2" + } +} diff --git a/integration-tests/head-function-export/shared-data/head-function-export.js b/integration-tests/head-function-export/shared-data/head-function-export.js new file mode 100644 index 0000000000000..056f993b590c3 --- /dev/null +++ b/integration-tests/head-function-export/shared-data/head-function-export.js @@ -0,0 +1,31 @@ +const path = `/head-function-export` + +const page = { + basic: `${path}/basic/`, + pageQuery: `${path}/page-query/`, + reExport: `${path}/re-exported-function/`, + staticQuery: `${path}/static-query-component/`, + warnings: `${path}/warnings/`, + allProps: `${path}/all-props/`, +} + +const data = { + static: { + base: `http://localhost:8000`, + title: `Ella Fitzgerald's Page`, + meta: `Ella Fitzgerald`, + noscript: `You take romance - I will take Jell-O!`, + style: `rebeccapurple`, + link: `/used-by-head-function-export-basic.css`, + }, + queried: { + base: `http://localhost:8000`, + title: `Nat King Cole's Page`, + meta: `Nat King Cole`, + noscript: `There is just one thing I cannot figure out. My income tax!`, + style: `blue`, + link: `/used-by-head-function-export-query.css`, + }, +} + +module.exports = { page, data } diff --git a/integration-tests/head-function-export/src/components/head-function-export.js b/integration-tests/head-function-export/src/components/head-function-export.js new file mode 100644 index 0000000000000..c261601b6c99f --- /dev/null +++ b/integration-tests/head-function-export/src/components/head-function-export.js @@ -0,0 +1,67 @@ +import * as React from "react" +import { useStaticQuery, graphql } from "gatsby" +import { data } from "../../shared-data/head-function-export.js" + +function HeadComponent({ children }) { + const data = useStaticQuery(graphql` + query SiteMetaDataStaticQuery { + site { + siteMetadata { + headFunctionExport { + base + title + meta + noscript + style + link + } + } + } + } + `) + + const { base, title, meta, noscript, style, link } = + data?.site?.siteMetadata?.headFunctionExport || {} + + return ( + <> + + {title} + + + + + {children} + + ) +} + +function Head() { + const { base, title, meta, noscript, style, link } = data.static + + return ( + <> + + {title} + + + + + + ) +} + +export { Head } +export default HeadComponent diff --git a/integration-tests/head-function-export/src/pages/404.js b/integration-tests/head-function-export/src/pages/404.js new file mode 100644 index 0000000000000..936772aa9d979 --- /dev/null +++ b/integration-tests/head-function-export/src/pages/404.js @@ -0,0 +1,13 @@ +import * as React from "react" +import { Link } from "gatsby" + +const NotFoundPage = () => { + return ( +
+ Not found + Go home +
+ ) +} + +export default NotFoundPage diff --git a/integration-tests/head-function-export/src/pages/head-function-export/basic.js b/integration-tests/head-function-export/src/pages/head-function-export/basic.js new file mode 100644 index 0000000000000..10f71983b1ad3 --- /dev/null +++ b/integration-tests/head-function-export/src/pages/head-function-export/basic.js @@ -0,0 +1,36 @@ +import * as React from "react" +import { Link } from "gatsby" +import { data } from "../../../shared-data/head-function-export" + +export default function HeadFunctionExportBasic() { + return ( + <> +

I test basic usage for the head function export

+

Some other words

+ + Navigate to page-query via Gatsby Link + + + ) +} + +export function Head() { + const { base, title, meta, noscript, style, link } = data.static + + return ( + <> + + {title} + + + + + + ) +} diff --git a/integration-tests/head-function-export/src/pages/head-function-export/page-query.js b/integration-tests/head-function-export/src/pages/head-function-export/page-query.js new file mode 100644 index 0000000000000..a3c553e35acfe --- /dev/null +++ b/integration-tests/head-function-export/src/pages/head-function-export/page-query.js @@ -0,0 +1,54 @@ +import * as React from "react" +import { graphql } from "gatsby" +import { Link } from "gatsby" + +export default function HeadFunctionExportPageQuery() { + return ( + <> +

I test usage for the Head function export with a page query

+

Some other words

+ + Navigate to basic via Gatsby Link + + + ) +} + +export function Head({ data }) { + const { base, title, meta, noscript, style, link } = + data?.site?.siteMetadata?.headFunctionExport || {} + + return ( + <> + + {title} + + + + + + ) +} + +export const pageQuery = graphql` + query SiteMetaDataPageQuery { + site { + siteMetadata { + headFunctionExport { + base + title + meta + noscript + style + link + } + } + } + } +` diff --git a/integration-tests/head-function-export/src/pages/head-function-export/re-exported-function.js b/integration-tests/head-function-export/src/pages/head-function-export/re-exported-function.js new file mode 100644 index 0000000000000..31d17812e2a9f --- /dev/null +++ b/integration-tests/head-function-export/src/pages/head-function-export/re-exported-function.js @@ -0,0 +1,12 @@ +import * as React from "react" + +export { Head } from "../../components/head-function-export" + +export default function HeadFunctionExportReExported() { + return ( + <> +

I test usage for the Head function export re exported

+

Some other words

+ + ) +} diff --git a/integration-tests/head-function-export/src/pages/head-function-export/static-query-component.js b/integration-tests/head-function-export/src/pages/head-function-export/static-query-component.js new file mode 100644 index 0000000000000..8da39645a8789 --- /dev/null +++ b/integration-tests/head-function-export/src/pages/head-function-export/static-query-component.js @@ -0,0 +1,15 @@ +import * as React from "react" +import HeadComponent from "../../components/head-function-export" + +export default function HeadFunctionExportStaticQueryComponent() { + return ( + <> +

I test usage for the Head function export via a common component

+

Some other words

+ + ) +} + +export function Head() { + return +} diff --git a/integration-tests/head-function-export/src/pages/index.js b/integration-tests/head-function-export/src/pages/index.js new file mode 100644 index 0000000000000..3ec43cb08b0e3 --- /dev/null +++ b/integration-tests/head-function-export/src/pages/index.js @@ -0,0 +1,11 @@ +import * as React from "react" + +const IndexPage = () => { + return ( +
+ Head function export integration test +
+ ) +} + +export default IndexPage diff --git a/integration-tests/ssr/__tests__/ssr.js b/integration-tests/ssr/__tests__/ssr.js index 38b7e873d1e7e..ccd17f388d482 100644 --- a/integration-tests/ssr/__tests__/ssr.js +++ b/integration-tests/ssr/__tests__/ssr.js @@ -2,6 +2,7 @@ const fetch = require(`node-fetch`) const execa = require(`execa`) const fs = require(`fs-extra`) const path = require(`path`) +const { parse } = require(`node-html-parser`) function fetchUntil(url, filter, timeout = 1000) { return new Promise(resolve => { @@ -43,6 +44,28 @@ describe(`SSR`, () => { ) }, 180000) + test(`dev & build outputs have matching head elements from Head function export`, async () => { + const devSsrHtml = await fetch( + `http://localhost:8000/head-function-export`, + { + headers: { + "x-gatsby-wait-for-dev-ssr": `1`, + }, + } + ).then(res => res.text()) + const devSsrDom = parse(devSsrHtml) + const devSsrHead = devSsrDom.querySelector(`[data-testid=title]`) + + const ssrHtml = await fs.readFile( + `${__dirname}/../public/head-function-export/index.html`, + `utf8` + ) + const ssrDom = parse(ssrHtml) + const ssrHead = ssrDom.querySelector(`[data-testid=title]`) + + expect(devSsrHead.textContent).toEqual(ssrHead.textContent) + }) + describe(`it generates an error page correctly`, () => { const badPages = [ { diff --git a/integration-tests/ssr/src/pages/head-function-export.js b/integration-tests/ssr/src/pages/head-function-export.js new file mode 100644 index 0000000000000..94813eb0bdffc --- /dev/null +++ b/integration-tests/ssr/src/pages/head-function-export.js @@ -0,0 +1,9 @@ +import React from "react" + +export default function PageWithHeadFunctionExport() { + return

I am a page with a Head function export

+} + +export function Head() { + return Hello world +} diff --git a/packages/babel-preset-gatsby/src/babel-preset-react.js b/packages/babel-preset-gatsby/src/babel-preset-react.js new file mode 100644 index 0000000000000..499f9672ea626 --- /dev/null +++ b/packages/babel-preset-gatsby/src/babel-preset-react.js @@ -0,0 +1,3 @@ +import babelPresetReact from "@babel/preset-react" + +export default babelPresetReact diff --git a/packages/gatsby-plugin-typescript/src/__tests__/gatsby-node.js b/packages/gatsby-plugin-typescript/src/__tests__/gatsby-node.js index 989f0c17446f9..61e8bf8ab7ef2 100644 --- a/packages/gatsby-plugin-typescript/src/__tests__/gatsby-node.js +++ b/packages/gatsby-plugin-typescript/src/__tests__/gatsby-node.js @@ -50,15 +50,14 @@ describe(`gatsby-plugin-typescript`, () => { describe(`onCreateWebpackConfig`, () => { it(`sets the correct webpack config`, () => { const actions = { setWebpackConfig: jest.fn() } - const jsLoader = {} - const loaders = { js: jest.fn(() => jsLoader) } + const loaders = { js: jest.fn(() => {}) } onCreateWebpackConfig({ actions, loaders }) expect(actions.setWebpackConfig).toHaveBeenCalledWith({ module: { rules: [ { test: /\.tsx?$/, - use: jsLoader, + use: expect.toBeFunction(), }, ], }, @@ -67,7 +66,7 @@ describe(`gatsby-plugin-typescript`, () => { it(`does not set the webpack config if there isn't a js loader`, () => { const actions = { setWebpackConfig: jest.fn() } - const loaders = { js: jest.fn() } + const loaders = { js: undefined } onCreateWebpackConfig({ actions, loaders }) expect(actions.setWebpackConfig).not.toHaveBeenCalled() }) diff --git a/packages/gatsby-plugin-typescript/src/gatsby-node.js b/packages/gatsby-plugin-typescript/src/gatsby-node.js index 0ccfe8c41fe28..ac523bca25eb8 100644 --- a/packages/gatsby-plugin-typescript/src/gatsby-node.js +++ b/packages/gatsby-plugin-typescript/src/gatsby-node.js @@ -17,9 +17,7 @@ function onCreateBabelConfig({ actions }, options) { } function onCreateWebpackConfig({ actions, loaders }) { - const jsLoader = loaders.js() - - if (!jsLoader) { + if (typeof loaders?.js !== `function`) { return } @@ -28,7 +26,12 @@ function onCreateWebpackConfig({ actions, loaders }) { rules: [ { test: /\.tsx?$/, - use: jsLoader, + use: ({ resourceQuery, issuer }) => [ + loaders.js({ + isPageTemplate: /async-requires/.test(issuer), + resourceQuery, + }), + ], }, ], }, diff --git a/packages/gatsby/cache-dir/__tests__/__snapshots__/dev-loader.js.snap b/packages/gatsby/cache-dir/__tests__/__snapshots__/dev-loader.js.snap index 5ba039d7796d9..34dd744801c1b 100644 --- a/packages/gatsby/cache-dir/__tests__/__snapshots__/dev-loader.js.snap +++ b/packages/gatsby/cache-dir/__tests__/__snapshots__/dev-loader.js.snap @@ -2,7 +2,12 @@ exports[`Dev loader loadPage should be successful when component can be loaded 1`] = ` Object { - "component": [Function], + "component": Object { + "default": [Function], + }, + "head": Object { + "default": [Function], + }, "json": Object { "pageContext": "something something", }, diff --git a/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap b/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap index 58192c541ddf4..ac23c5f70eeb4 100644 --- a/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap +++ b/packages/gatsby/cache-dir/__tests__/__snapshots__/loader.js.snap @@ -3,6 +3,7 @@ exports[`Production loader loadPage should be successful when component can be loaded 1`] = ` Object { "component": "instance", + "head": "instance", "json": Object { "pageContext": "something something", }, diff --git a/packages/gatsby/cache-dir/__tests__/dev-loader.js b/packages/gatsby/cache-dir/__tests__/dev-loader.js index c4d6a17620105..2d6b4c727b754 100644 --- a/packages/gatsby/cache-dir/__tests__/dev-loader.js +++ b/packages/gatsby/cache-dir/__tests__/dev-loader.js @@ -288,6 +288,7 @@ describe(`Dev loader`, () => { const createAsyncRequires = components => { return { components, + head: components, } } @@ -339,6 +340,7 @@ describe(`Dev loader`, () => { expect(expectation).toMatchSnapshot() expect(Object.keys(expectation)).toEqual([ `component`, + `head`, `json`, `page`, `staticQueryResults`, diff --git a/packages/gatsby/cache-dir/__tests__/head/utils.js b/packages/gatsby/cache-dir/__tests__/head/utils.js new file mode 100644 index 0000000000000..2bacdd76b979b --- /dev/null +++ b/packages/gatsby/cache-dir/__tests__/head/utils.js @@ -0,0 +1,145 @@ +import { filterHeadProps } from "../../head/utils" + +const fullPropsExample = { + path: `/john/`, + location: { + pathname: `/john/`, + search: ``, + hash: ``, + href: `http://localhost:8000/john/`, + origin: `http://localhost:8000`, + protocol: `http:`, + host: `localhost:8000`, + hostname: `localhost`, + port: `8000`, + state: { + key: `1656493073882`, + }, + key: `1656493073882`, + }, + pageResources: { + component: {}, + json: { + pageContext: { + id: `502c6522-6bf1-57de-ad0a-a1a0899d46b8`, + name: `John`, + __params: { + name: `john`, + }, + }, + serverData: null, + }, + page: { + componentChunkName: `component---src-pages-person-name-tsx`, + path: `/john/`, + webpackCompilationHash: `123`, + staticQueryHashes: [], + }, + staticQueryResults: {}, + }, + data: { + site: { + siteMetadata: { + title: `gatsby-head`, + }, + }, + }, + uri: `/john`, + children: null, + pageContext: { + id: `502c6522-6bf1-57de-ad0a-a1a0899d46b8`, + name: `John`, + __params: { + name: `john`, + }, + }, + serverData: null, + params: { + name: `john`, + }, +} + +const minimalExample = { + path: `/john/`, + location: { + pathname: `/john/`, + search: ``, + hash: ``, + href: `http://localhost:8000/john/`, + origin: `http://localhost:8000`, + protocol: `http:`, + host: `localhost:8000`, + hostname: `localhost`, + port: `8000`, + state: { + key: `1656493073882`, + }, + key: `1656493073882`, + }, + pageResources: { + component: {}, + json: { + pageContext: { + id: `502c6522-6bf1-57de-ad0a-a1a0899d46b8`, + name: `John`, + __params: { + name: `john`, + }, + }, + serverData: null, + }, + page: { + componentChunkName: `component---src-pages-person-name-tsx`, + path: `/john/`, + webpackCompilationHash: `123`, + staticQueryHashes: [], + }, + staticQueryResults: {}, + }, + uri: `/john`, + children: null, + pageContext: {}, + serverData: null, + params: {}, +} + +describe(`head utils`, () => { + describe(`filterHeadProps`, () => { + it(`should return the correct valid props for full example`, () => { + const props = filterHeadProps(fullPropsExample) + expect(props).toStrictEqual({ + location: { + pathname: `/john/`, + }, + params: { + name: `john`, + }, + data: { + site: { + siteMetadata: { + title: `gatsby-head`, + }, + }, + }, + pageContext: { + id: `502c6522-6bf1-57de-ad0a-a1a0899d46b8`, + name: `John`, + __params: { + name: `john`, + }, + }, + }) + }) + it(`should return the correct valid props for minimal example`, () => { + const props = filterHeadProps(minimalExample) + expect(props).toStrictEqual({ + location: { + pathname: `/john/`, + }, + params: {}, + data: {}, + pageContext: {}, + }) + }) + }) +}) diff --git a/packages/gatsby/cache-dir/__tests__/loader.js b/packages/gatsby/cache-dir/__tests__/loader.js index 3b78093c5cdf4..f98d951d309a0 100644 --- a/packages/gatsby/cache-dir/__tests__/loader.js +++ b/packages/gatsby/cache-dir/__tests__/loader.js @@ -346,6 +346,7 @@ describe(`Production loader`, () => { expect(expectation).toMatchSnapshot() expect(Object.keys(expectation)).toEqual([ `component`, + `head`, `json`, `page`, `staticQueryResults`, diff --git a/packages/gatsby/cache-dir/dev-loader.js b/packages/gatsby/cache-dir/dev-loader.js index ddc6dad90f491..7cc77f262653d 100644 --- a/packages/gatsby/cache-dir/dev-loader.js +++ b/packages/gatsby/cache-dir/dev-loader.js @@ -7,8 +7,6 @@ import normalizePagePath from "./normalize-page-path" // TODO move away from lodash import isEqual from "lodash/isEqual" -const preferDefault = m => (m && m.default) || m - function mergePageEntry(cachedPage, newPageData) { return { ...cachedPage, @@ -31,16 +29,15 @@ function mergePageEntry(cachedPage, newPageData) { class DevLoader extends BaseLoader { constructor(asyncRequires, matchPaths) { - const loadComponent = chunkName => { - if (!this.asyncRequires.components[chunkName]) { + const loadComponent = (chunkName, exportType = `components`) => { + if (!this.asyncRequires[exportType][chunkName]) { throw new Error( `We couldn't find the correct component chunk with the name "${chunkName}"` ) } return ( - this.asyncRequires.components[chunkName]() - .then(preferDefault) + this.asyncRequires[exportType][chunkName]() // loader will handle the case when component is error .catch(err => err) ) diff --git a/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js b/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js index 78ca54146778d..6957e435603ae 100644 --- a/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js +++ b/packages/gatsby/cache-dir/fast-refresh-overlay/components/runtime-errors.js @@ -9,12 +9,15 @@ import { Accordion, AccordionItem } from "./accordion" function WrappedAccordionItem({ error, open }) { const stacktrace = StackTrace.parse(error) const codeFrameInformation = getCodeFrameInformation(stacktrace) - const filePath = codeFrameInformation?.moduleId + + const modulePath = codeFrameInformation?.moduleId const lineNumber = codeFrameInformation?.lineNumber const columnNumber = codeFrameInformation?.columnNumber const name = codeFrameInformation?.functionName + // With the introduction of Metadata management the modulePath can have a resourceQuery that needs to be removed first + const filePath = modulePath.replace(/\?export=(default|head)$/, ``) - const res = useStackFrame({ moduleId: filePath, lineNumber, columnNumber }) + const res = useStackFrame({ moduleId: modulePath, lineNumber, columnNumber }) const line = res.sourcePosition?.line const Title = () => { diff --git a/packages/gatsby/cache-dir/head/components/fire-callback-in-effect.js b/packages/gatsby/cache-dir/head/components/fire-callback-in-effect.js new file mode 100644 index 0000000000000..0a17d8fc4e879 --- /dev/null +++ b/packages/gatsby/cache-dir/head/components/fire-callback-in-effect.js @@ -0,0 +1,12 @@ +import { useEffect } from "react" + +/* + * Calls callback in an effect and renders children + */ +export function FireCallbackInEffect({ children, callback }) { + useEffect(() => { + callback() + }) + + return children +} diff --git a/packages/gatsby/cache-dir/head/constants.js b/packages/gatsby/cache-dir/head/constants.js new file mode 100644 index 0000000000000..05ccf7fdd5c7b --- /dev/null +++ b/packages/gatsby/cache-dir/head/constants.js @@ -0,0 +1,8 @@ +export const VALID_NODE_NAMES = [ + `link`, + `meta`, + `style`, + `title`, + `base`, + `noscript`, +] diff --git a/packages/gatsby/cache-dir/head/head-export-handler-for-browser.js b/packages/gatsby/cache-dir/head/head-export-handler-for-browser.js new file mode 100644 index 0000000000000..758e6011e1ada --- /dev/null +++ b/packages/gatsby/cache-dir/head/head-export-handler-for-browser.js @@ -0,0 +1,82 @@ +import React from "react" +import { useEffect } from "react" +import { StaticQueryContext } from "gatsby" +import { reactDOMUtils } from "../react-dom-utils" +import { FireCallbackInEffect } from "./components/fire-callback-in-effect" +import { VALID_NODE_NAMES } from "./constants" +import { + headExportValidator, + filterHeadProps, + warnForInvalidTags, +} from "./utils" + +const hiddenRoot = document.createElement(`div`) + +const removePrevHeadElements = () => { + const prevHeadNodes = [...document.querySelectorAll(`[data-gatsby-head]`)] + prevHeadNodes.forEach(e => e.remove()) +} + +const onHeadRendered = () => { + const validHeadNodes = [] + + removePrevHeadElements() + + for (const node of hiddenRoot.childNodes) { + const nodeName = node.nodeName.toLowerCase() + + if (!VALID_NODE_NAMES.includes(nodeName)) { + warnForInvalidTags(nodeName) + } else { + const clonedNode = node.cloneNode(true) + clonedNode.setAttribute(`data-gatsby-head`, true) + validHeadNodes.push(clonedNode) + } + } + + document.head.append(...validHeadNodes) +} + +if (process.env.BUILD_STAGE === `develop`) { + // We set up observer to be able to regenerate after react-refresh + // updates our hidden element. + const observer = new MutationObserver(onHeadRendered) + observer.observe(hiddenRoot, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + }) +} + +export function headHandlerForBrowser({ + pageComponent, + staticQueryResults, + pageComponentProps, +}) { + useEffect(() => { + if (pageComponent?.Head) { + headExportValidator(pageComponent.Head) + + const { render } = reactDOMUtils() + + const Head = pageComponent.Head + + render( + // just a hack to call the callback after react has done first render + // Note: In dev, we call onHeadRendered twice( in FireCallbackInEffect and after mutualution observer dectects initail render into hiddenRoot) this is for hot reloading + // In Prod we only call onHeadRendered in FireCallbackInEffect to render to head + + + + + , + hiddenRoot + ) + } + + return () => { + removePrevHeadElements() + } + }) +} diff --git a/packages/gatsby/cache-dir/head/head-export-handler-for-ssr.js b/packages/gatsby/cache-dir/head/head-export-handler-for-ssr.js new file mode 100644 index 0000000000000..0aa7d53e5dac7 --- /dev/null +++ b/packages/gatsby/cache-dir/head/head-export-handler-for-ssr.js @@ -0,0 +1,78 @@ +const React = require(`react`) +const { grabMatchParams } = require(`../find-path`) +const { createElement } = require(`react`) +const { StaticQueryContext } = require(`gatsby`) +const { + headExportValidator, + filterHeadProps, + warnForInvalidTags, +} = require(`./utils`) +const { ServerLocation, Router } = require(`@gatsbyjs/reach-router`) +const { renderToString } = require(`react-dom/server`) +const { parse } = require(`node-html-parser`) +const { VALID_NODE_NAMES } = require(`./constants`) + +export function headHandlerForSSR({ + pageComponent, + setHeadComponents, + staticQueryContext, + pageData, + pagePath, +}) { + if (pageComponent?.Head) { + headExportValidator(pageComponent.Head) + + function HeadRouteHandler(props) { + const _props = { + ...props, + ...pageData.result, + params: { + ...grabMatchParams(props.location.pathname), + ...(pageData.result?.pageContext?.__params || {}), + }, + } + + return createElement(pageComponent.Head, filterHeadProps(_props)) + } + + const routerElement = ( + + + <>{children}} + > + + + + + ) + + // extract head nodes from string + const rawString = renderToString(routerElement) + const headNodes = parse(rawString).childNodes + + const validHeadNodes = [] + + for (const node of headNodes) { + const { rawTagName, attributes } = node + + if (!VALID_NODE_NAMES.includes(rawTagName)) { + warnForInvalidTags(rawTagName) + } else { + const element = createElement( + rawTagName, + { + ...attributes, + "data-gatsby-head": true, + }, + node.childNodes[0]?.textContent + ) + + validHeadNodes.push(element) + } + } + + setHeadComponents(validHeadNodes) + } +} diff --git a/packages/gatsby/cache-dir/head/utils.js b/packages/gatsby/cache-dir/head/utils.js new file mode 100644 index 0000000000000..4551446c0a793 --- /dev/null +++ b/packages/gatsby/cache-dir/head/utils.js @@ -0,0 +1,59 @@ +import { VALID_NODE_NAMES } from "./constants" + +/** + * Filter the props coming from a page down to just the ones that are relevant for head. + * This e.g. filters out properties that are undefined during SSR. + */ +export function filterHeadProps(input) { + return { + location: { + pathname: input.location.pathname, + }, + params: input.params, + data: input.data || {}, + pageContext: input.pageContext, + } +} + +/** + * Throw error if Head export is not a valid + */ +export function headExportValidator(head) { + if (typeof head !== `function`) + throw new Error( + `Expected "Head" export to be a function got "${typeof head}".` + ) +} + +/** + * Warn once for same messsage + */ +let warnOnce = _ => {} +if (process.env.NODE_ENV !== `production`) { + const warnings = new Set() + warnOnce = msg => { + if (!warnings.has(msg)) { + console.warn(msg) + } + warnings.add(msg) + } +} + +export { warnOnce } + +/** + * Warn for invalid tags in head. + * @param {string} tagName + */ +export function warnForInvalidTags(tagName) { + if (process.env.NODE_ENV !== `production`) { + const warning = + tagName !== `script` + ? `<${tagName}> is not a valid head element. Please use one of the following: ${VALID_NODE_NAMES.join( + `, ` + )}` + : `Do not add scripts here. Please use the