diff --git a/e2e-tests/development-runtime/cypress/integration/head-function-export/scripts.js b/e2e-tests/development-runtime/cypress/integration/head-function-export/scripts.js
new file mode 100644
index 0000000000000..e078130b28e1b
--- /dev/null
+++ b/e2e-tests/development-runtime/cypress/integration/head-function-export/scripts.js
@@ -0,0 +1,20 @@
+import { page } from "../../../shared-data/head-function-export.js"
+
+describe("Scripts", () => {
+ beforeEach(() => {
+ cy.visit(page.basic).waitForRouteChange()
+ })
+
+ // This tests that we don't append elements to the document head more than once
+ // A script will get called more than once it that happens
+ it(`Inline script work and get called only once`, () => {
+
+ // Head export seem to be appending the tags after waitForRouteChange()
+ // We need to find a way to make waitForRouteChange() catch Head export too
+ cy.wait(3000)
+
+ cy.window().then(win => {
+ expect(win.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__).to.equal(1)
+ })
+ })
+})
diff --git a/e2e-tests/development-runtime/src/pages/head-function-export/basic.js b/e2e-tests/development-runtime/src/pages/head-function-export/basic.js
index af5204c87420a..a79609b1f240d 100644
--- a/e2e-tests/development-runtime/src/pages/head-function-export/basic.js
+++ b/e2e-tests/development-runtime/src/pages/head-function-export/basic.js
@@ -12,7 +12,10 @@ export default function HeadFunctionExportBasic() {
Navigate to page-query via Gatsby Link
-
+
Navigate to without head export
>
@@ -28,7 +31,7 @@ export function Head() {
style,
link,
extraMeta,
- jsonLD
+ jsonLD,
} = data.static
return (
@@ -54,6 +57,9 @@ export function Head() {
+
>
)
}
diff --git a/e2e-tests/production-runtime/cypress/integration/head-function-export/scripts.js b/e2e-tests/production-runtime/cypress/integration/head-function-export/scripts.js
new file mode 100644
index 0000000000000..e078130b28e1b
--- /dev/null
+++ b/e2e-tests/production-runtime/cypress/integration/head-function-export/scripts.js
@@ -0,0 +1,20 @@
+import { page } from "../../../shared-data/head-function-export.js"
+
+describe("Scripts", () => {
+ beforeEach(() => {
+ cy.visit(page.basic).waitForRouteChange()
+ })
+
+ // This tests that we don't append elements to the document head more than once
+ // A script will get called more than once it that happens
+ it(`Inline script work and get called only once`, () => {
+
+ // Head export seem to be appending the tags after waitForRouteChange()
+ // We need to find a way to make waitForRouteChange() catch Head export too
+ cy.wait(3000)
+
+ cy.window().then(win => {
+ expect(win.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__).to.equal(1)
+ })
+ })
+})
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
index 1132b579f8cda..e2bad20c6174f 100644
--- a/e2e-tests/production-runtime/src/pages/head-function-export/basic.js
+++ b/e2e-tests/production-runtime/src/pages/head-function-export/basic.js
@@ -38,6 +38,9 @@ export function Head() {
+
>
)
}
diff --git a/integration-tests/head-function-export/__tests__/ssr-html-output.js b/integration-tests/head-function-export/__tests__/ssr-html-output.js
index 74ab79ac30afb..dfe81e001462f 100644
--- a/integration-tests/head-function-export/__tests__/ssr-html-output.js
+++ b/integration-tests/head-function-export/__tests__/ssr-html-output.js
@@ -34,7 +34,7 @@ describe(`Head function export SSR'ed HTML output`, () => {
expect(noscript.text).toEqual(data.static.noscript)
expect(style.text).toContain(data.static.style)
expect(link.attributes.href).toEqual(data.static.link)
- expect(jsonLD.text).toEqual(data.static.jsonLD)
+ expect(jsonLD.innerHTML).toEqual(data.static.jsonLD)
})
it(`should work with data from a page query`, () => {
diff --git a/packages/gatsby/cache-dir/head/__tests__/utils.ts b/packages/gatsby/cache-dir/head/__tests__/utils.ts
new file mode 100644
index 0000000000000..c4557d2898d86
--- /dev/null
+++ b/packages/gatsby/cache-dir/head/__tests__/utils.ts
@@ -0,0 +1,74 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { diffNodes } from "../utils"
+
+function createElement(
+ type: string,
+ attributes: Record | undefined = undefined,
+ innerHTML: string | undefined = undefined
+): Element {
+ const element: Element = document.createElement(type)
+ if (attributes) {
+ for (const [key, value] of Object.entries(attributes)) {
+ if (value === `string`) {
+ element.setAttribute(key, value)
+ }
+ }
+ }
+ if (innerHTML) {
+ element.innerHTML = innerHTML
+ }
+ return element
+}
+
+describe(`diffNodes`, () => {
+ it(`should keep same nodes, remove nodes that were not re-created, and add new nodes`, () => {
+ const oldNodes = [
+ createElement(`title`, {}, `to remove`),
+ createElement(`script`, {}, `stable`),
+ createElement(`script`, {}, `to remove`),
+ ]
+
+ const newNodes = [
+ createElement(`title`, {}, `to add`),
+ createElement(`script`, {}, `stable`),
+ createElement(`script`, {}, `to add`),
+ ]
+
+ const onStale = jest.fn()
+ const onNew = jest.fn()
+
+ diffNodes({ oldNodes, newNodes, onStale, onNew })
+
+ expect(onStale.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+
+ to remove
+ ,
+ ],
+ Array [
+ ,
+ ],
+ ]
+ `)
+ expect(onNew.mock.calls).toMatchInlineSnapshot(`
+ Array [
+ Array [
+
+ to add
+ ,
+ ],
+ Array [
+ ,
+ ],
+ ]
+ `)
+ })
+})
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
index 94d42f4ee7b02..7f1866fbd5c40 100644
--- a/packages/gatsby/cache-dir/head/head-export-handler-for-browser.js
+++ b/packages/gatsby/cache-dir/head/head-export-handler-for-browser.js
@@ -9,6 +9,7 @@ import {
headExportValidator,
filterHeadProps,
warnForInvalidTags,
+ diffNodes,
} from "./utils"
const hiddenRoot = document.createElement(`div`)
@@ -21,8 +22,6 @@ const removePrevHeadElements = () => {
const onHeadRendered = () => {
const validHeadNodes = []
- removePrevHeadElements()
-
const seenIds = new Map()
for (const node of hiddenRoot.childNodes) {
const nodeName = node.nodeName.toLowerCase()
@@ -31,8 +30,19 @@ const onHeadRendered = () => {
if (!VALID_NODE_NAMES.includes(nodeName)) {
warnForInvalidTags(nodeName)
} else {
- const clonedNode = node.cloneNode(true)
+ let clonedNode = node.cloneNode(true)
clonedNode.setAttribute(`data-gatsby-head`, true)
+
+ // Create an element for scripts to make script work
+ if (clonedNode.nodeName.toLowerCase() === `script`) {
+ const script = document.createElement(`script`)
+ for (const attr of clonedNode.attributes) {
+ script.setAttribute(attr.name, attr.value)
+ }
+ script.innerHTML = clonedNode.innerHTML
+ clonedNode = script
+ }
+
if (id) {
if (!seenIds.has(id)) {
validHeadNodes.push(clonedNode)
@@ -48,7 +58,24 @@ const onHeadRendered = () => {
}
}
- document.head.append(...validHeadNodes)
+ const existingHeadElements = [
+ ...document.querySelectorAll(`[data-gatsby-head]`),
+ ]
+
+ if (existingHeadElements.length === 0) {
+ document.head.append(...validHeadNodes)
+ return
+ }
+
+ const newHeadNodes = []
+ diffNodes({
+ oldNodes: existingHeadElements,
+ newNodes: validHeadNodes,
+ onStale: node => node.remove(),
+ onNew: node => newHeadNodes.push(node),
+ })
+
+ document.head.append(...newHeadNodes)
}
if (process.env.BUILD_STAGE === `develop`) {
diff --git a/packages/gatsby/cache-dir/head/utils.js b/packages/gatsby/cache-dir/head/utils.js
index 833226a37f90b..1897c81595551 100644
--- a/packages/gatsby/cache-dir/head/utils.js
+++ b/packages/gatsby/cache-dir/head/utils.js
@@ -52,3 +52,53 @@ export function warnForInvalidTags(tagName) {
warnOnce(warning)
}
}
+
+/**
+ * When a `nonce` is present on an element, browsers such as Chrome and Firefox strip it out of the
+ * actual HTML attributes for security reasons *when the element is added to the document*. Thus,
+ * given two equivalent elements that have nonces, `Element,isEqualNode()` will return false if one
+ * of those elements gets added to the document. Although the `element.nonce` property will be the
+ * same for both elements, the one that was added to the document will return an empty string for
+ * its nonce HTML attribute value.
+ *
+ * This custom `isEqualNode()` function therefore removes the nonce value from the `newTag` before
+ * comparing it to `oldTag`, restoring it afterwards.
+ *
+ * For more information, see:
+ * https://bugs.chromium.org/p/chromium/issues/detail?id=1211471#c12
+ */
+export function isEqualNode(oldTag, newTag) {
+ if (oldTag instanceof HTMLElement && newTag instanceof HTMLElement) {
+ const nonce = newTag.getAttribute(`nonce`)
+ // Only strip the nonce if `oldTag` has had it stripped. An element's nonce attribute will not
+ // be stripped if there is no content security policy response header that includes a nonce.
+ if (nonce && !oldTag.getAttribute(`nonce`)) {
+ const cloneTag = newTag.cloneNode(true)
+ cloneTag.setAttribute(`nonce`, ``)
+ cloneTag.nonce = nonce
+ return nonce === oldTag.nonce && oldTag.isEqualNode(cloneTag)
+ }
+ }
+
+ return oldTag.isEqualNode(newTag)
+}
+
+export function diffNodes({ oldNodes, newNodes, onStale, onNew }) {
+ for (const existingHeadElement of oldNodes) {
+ const indexInNewNodes = newNodes.findIndex(e =>
+ isEqualNode(e, existingHeadElement)
+ )
+
+ if (indexInNewNodes === -1) {
+ onStale(existingHeadElement)
+ } else {
+ // this node is re-created as-is, so we keep old node, and remove it from list of new nodes (as we handled it already here)
+ newNodes.splice(indexInNewNodes, 1)
+ }
+ }
+
+ // remaing new nodes didn't have matching old node, so need to be added
+ for (const newNode of newNodes) {
+ onNew(newNode)
+ }
+}