Skip to content

Commit 6c0316e

Browse files
gatsbybotpiehmarvinjude
authored
fix(gatsby): Make <script> in Head behave correctly (#36212) (#36299)
Co-authored-by: pieh <[email protected]> Co-authored-by: Jude Agboola <[email protected]>
1 parent a6ff9e9 commit 6c0316e

File tree

8 files changed

+207
-7
lines changed

8 files changed

+207
-7
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { page } from "../../../shared-data/head-function-export.js"
2+
3+
describe("Scripts", () => {
4+
beforeEach(() => {
5+
cy.visit(page.basic).waitForRouteChange()
6+
})
7+
8+
// This tests that we don't append elements to the document head more than once
9+
// A script will get called more than once it that happens
10+
it(`Inline script work and get called only once`, () => {
11+
12+
// Head export seem to be appending the tags after waitForRouteChange()
13+
// We need to find a way to make waitForRouteChange() catch Head export too
14+
cy.wait(3000)
15+
16+
cy.window().then(win => {
17+
expect(win.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__).to.equal(1)
18+
})
19+
})
20+
})

e2e-tests/development-runtime/src/pages/head-function-export/basic.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ export default function HeadFunctionExportBasic() {
1212
<Link data-testid="gatsby-link" to="/head-function-export/page-query">
1313
Navigate to page-query via Gatsby Link
1414
</Link>
15-
<Link data-testid="navigate-to-page-without-head-export" to="/without-head">
15+
<Link
16+
data-testid="navigate-to-page-without-head-export"
17+
to="/without-head"
18+
>
1619
Navigate to without head export
1720
</Link>
1821
</>
@@ -28,7 +31,7 @@ export function Head() {
2831
style,
2932
link,
3033
extraMeta,
31-
jsonLD
34+
jsonLD,
3235
} = data.static
3336

3437
return (
@@ -54,6 +57,9 @@ export function Head() {
5457
<script data-testid="jsonLD" type="application/ld+json">
5558
{jsonLD}
5659
</script>
60+
<script type="text/javascript">
61+
{`window.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__ = (window.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__ || 0 ) + 1`}
62+
</script>
5763
</>
5864
)
5965
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { page } from "../../../shared-data/head-function-export.js"
2+
3+
describe("Scripts", () => {
4+
beforeEach(() => {
5+
cy.visit(page.basic).waitForRouteChange()
6+
})
7+
8+
// This tests that we don't append elements to the document head more than once
9+
// A script will get called more than once it that happens
10+
it(`Inline script work and get called only once`, () => {
11+
12+
// Head export seem to be appending the tags after waitForRouteChange()
13+
// We need to find a way to make waitForRouteChange() catch Head export too
14+
cy.wait(3000)
15+
16+
cy.window().then(win => {
17+
expect(win.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__).to.equal(1)
18+
})
19+
})
20+
})

e2e-tests/production-runtime/src/pages/head-function-export/basic.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export function Head() {
3838
<script data-testid="jsonLD" type="application/ld+json">
3939
{jsonLD}
4040
</script>
41+
<script type="text/javascript">
42+
{`window.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__ = (window.__SOME_GLOBAL_TO_CHECK_CALL_COUNT__ || 0 ) + 1`}
43+
</script>
4144
</>
4245
)
4346
}

integration-tests/head-function-export/__tests__/ssr-html-output.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe(`Head function export SSR'ed HTML output`, () => {
3434
expect(noscript.text).toEqual(data.static.noscript)
3535
expect(style.text).toContain(data.static.style)
3636
expect(link.attributes.href).toEqual(data.static.link)
37-
expect(jsonLD.text).toEqual(data.static.jsonLD)
37+
expect(jsonLD.innerHTML).toEqual(data.static.jsonLD)
3838
})
3939

4040
it(`should work with data from a page query`, () => {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
5+
import { diffNodes } from "../utils"
6+
7+
function createElement(
8+
type: string,
9+
attributes: Record<string, string> | undefined = undefined,
10+
innerHTML: string | undefined = undefined
11+
): Element {
12+
const element: Element = document.createElement(type)
13+
if (attributes) {
14+
for (const [key, value] of Object.entries(attributes)) {
15+
if (value === `string`) {
16+
element.setAttribute(key, value)
17+
}
18+
}
19+
}
20+
if (innerHTML) {
21+
element.innerHTML = innerHTML
22+
}
23+
return element
24+
}
25+
26+
describe(`diffNodes`, () => {
27+
it(`should keep same nodes, remove nodes that were not re-created, and add new nodes`, () => {
28+
const oldNodes = [
29+
createElement(`title`, {}, `to remove`),
30+
createElement(`script`, {}, `stable`),
31+
createElement(`script`, {}, `to remove`),
32+
]
33+
34+
const newNodes = [
35+
createElement(`title`, {}, `to add`),
36+
createElement(`script`, {}, `stable`),
37+
createElement(`script`, {}, `to add`),
38+
]
39+
40+
const onStale = jest.fn()
41+
const onNew = jest.fn()
42+
43+
diffNodes({ oldNodes, newNodes, onStale, onNew })
44+
45+
expect(onStale.mock.calls).toMatchInlineSnapshot(`
46+
Array [
47+
Array [
48+
<title>
49+
to remove
50+
</title>,
51+
],
52+
Array [
53+
<script>
54+
to remove
55+
</script>,
56+
],
57+
]
58+
`)
59+
expect(onNew.mock.calls).toMatchInlineSnapshot(`
60+
Array [
61+
Array [
62+
<title>
63+
to add
64+
</title>,
65+
],
66+
Array [
67+
<script>
68+
to add
69+
</script>,
70+
],
71+
]
72+
`)
73+
})
74+
})

packages/gatsby/cache-dir/head/head-export-handler-for-browser.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
headExportValidator,
1010
filterHeadProps,
1111
warnForInvalidTags,
12+
diffNodes,
1213
} from "./utils"
1314

1415
const hiddenRoot = document.createElement(`div`)
@@ -21,8 +22,6 @@ const removePrevHeadElements = () => {
2122
const onHeadRendered = () => {
2223
const validHeadNodes = []
2324

24-
removePrevHeadElements()
25-
2625
const seenIds = new Map()
2726
for (const node of hiddenRoot.childNodes) {
2827
const nodeName = node.nodeName.toLowerCase()
@@ -31,8 +30,19 @@ const onHeadRendered = () => {
3130
if (!VALID_NODE_NAMES.includes(nodeName)) {
3231
warnForInvalidTags(nodeName)
3332
} else {
34-
const clonedNode = node.cloneNode(true)
33+
let clonedNode = node.cloneNode(true)
3534
clonedNode.setAttribute(`data-gatsby-head`, true)
35+
36+
// Create an element for scripts to make script work
37+
if (clonedNode.nodeName.toLowerCase() === `script`) {
38+
const script = document.createElement(`script`)
39+
for (const attr of clonedNode.attributes) {
40+
script.setAttribute(attr.name, attr.value)
41+
}
42+
script.innerHTML = clonedNode.innerHTML
43+
clonedNode = script
44+
}
45+
3646
if (id) {
3747
if (!seenIds.has(id)) {
3848
validHeadNodes.push(clonedNode)
@@ -48,7 +58,24 @@ const onHeadRendered = () => {
4858
}
4959
}
5060

51-
document.head.append(...validHeadNodes)
61+
const existingHeadElements = [
62+
...document.querySelectorAll(`[data-gatsby-head]`),
63+
]
64+
65+
if (existingHeadElements.length === 0) {
66+
document.head.append(...validHeadNodes)
67+
return
68+
}
69+
70+
const newHeadNodes = []
71+
diffNodes({
72+
oldNodes: existingHeadElements,
73+
newNodes: validHeadNodes,
74+
onStale: node => node.remove(),
75+
onNew: node => newHeadNodes.push(node),
76+
})
77+
78+
document.head.append(...newHeadNodes)
5279
}
5380

5481
if (process.env.BUILD_STAGE === `develop`) {

packages/gatsby/cache-dir/head/utils.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,53 @@ export function warnForInvalidTags(tagName) {
5252
warnOnce(warning)
5353
}
5454
}
55+
56+
/**
57+
* When a `nonce` is present on an element, browsers such as Chrome and Firefox strip it out of the
58+
* actual HTML attributes for security reasons *when the element is added to the document*. Thus,
59+
* given two equivalent elements that have nonces, `Element,isEqualNode()` will return false if one
60+
* of those elements gets added to the document. Although the `element.nonce` property will be the
61+
* same for both elements, the one that was added to the document will return an empty string for
62+
* its nonce HTML attribute value.
63+
*
64+
* This custom `isEqualNode()` function therefore removes the nonce value from the `newTag` before
65+
* comparing it to `oldTag`, restoring it afterwards.
66+
*
67+
* For more information, see:
68+
* https://bugs.chromium.org/p/chromium/issues/detail?id=1211471#c12
69+
*/
70+
export function isEqualNode(oldTag, newTag) {
71+
if (oldTag instanceof HTMLElement && newTag instanceof HTMLElement) {
72+
const nonce = newTag.getAttribute(`nonce`)
73+
// Only strip the nonce if `oldTag` has had it stripped. An element's nonce attribute will not
74+
// be stripped if there is no content security policy response header that includes a nonce.
75+
if (nonce && !oldTag.getAttribute(`nonce`)) {
76+
const cloneTag = newTag.cloneNode(true)
77+
cloneTag.setAttribute(`nonce`, ``)
78+
cloneTag.nonce = nonce
79+
return nonce === oldTag.nonce && oldTag.isEqualNode(cloneTag)
80+
}
81+
}
82+
83+
return oldTag.isEqualNode(newTag)
84+
}
85+
86+
export function diffNodes({ oldNodes, newNodes, onStale, onNew }) {
87+
for (const existingHeadElement of oldNodes) {
88+
const indexInNewNodes = newNodes.findIndex(e =>
89+
isEqualNode(e, existingHeadElement)
90+
)
91+
92+
if (indexInNewNodes === -1) {
93+
onStale(existingHeadElement)
94+
} else {
95+
// 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)
96+
newNodes.splice(indexInNewNodes, 1)
97+
}
98+
}
99+
100+
// remaing new nodes didn't have matching old node, so need to be added
101+
for (const newNode of newNodes) {
102+
onNew(newNode)
103+
}
104+
}

0 commit comments

Comments
 (0)