Skip to content

Commit 56b1fb3

Browse files
committed
fix: ensure inserting <title> in <head> updates document.title
In #39306 we introduced a workaround that wraps the Gatsby Head API elements in an `<svg>` wrapper. React under the hood creates these children elements in the "SVG" namespace. In the case of `<title>`, this is problematic because when we insert it into the real `<head>`, browsers (as far as we can tell) do not trigger an update of `document.title` because this only occurs for `<title>` nodes in the HTML namespace. A node's namespace is immutable, even when using `cloneNode()` or `importNode()`. The only way to "reset" the namespace is to recreate a node.
1 parent 5d88806 commit 56b1fb3

File tree

5 files changed

+39
-8
lines changed

5 files changed

+39
-8
lines changed

e2e-tests/development-runtime/cypress/integration/functionality/queries-in-packages.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,8 @@ describe(`queries in packages`, () => {
44
})
55

66
it(`Should extract and run query from gatsby component`, () => {
7-
// Note: in dev this may take a few ms to be populated due to the way the Gatsby Head API is
8-
// implemented
9-
cy.get("head > title").should(
10-
`have.text`,
7+
cy.title().should(
8+
`eq`,
119
`Testing queries in packages | Gatsby Default Starter`
1210
)
1311
})

e2e-tests/production-runtime/cypress/integration/head-function-export/head-with-wrap-root-element.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ describe(`Head with wrapRootElement`, () => {
3939
cy.getTestElement(`jsonLD`).should(`have.text`, data.static.jsonLD)
4040
})
4141

42+
it(`updates document title`, () => {
43+
cy.visit(
44+
headFunctionExportSharedData.page.headWithWrapRooElement
45+
).waitForRouteChange()
46+
47+
cy.title().should(`eq`, contextValue.posts[0].title)
48+
})
49+
4250
it(`can use context values provided in wrapRootElement`, () => {
4351
cy.visit(
4452
headFunctionExportSharedData.page.headWithWrapRooElement

e2e-tests/production-runtime/cypress/integration/head-function-export/html-insertion.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ Cypress.on("uncaught:exception", err => {
1414
describe(`Head function export html insertion`, () => {
1515
it(`should work with static data`, () => {
1616
cy.visit(page.basic).waitForRouteChange()
17+
1718
cy.getTestElement(`base`)
1819
.invoke(`attr`, `href`)
1920
.should(`equal`, data.static.base)
2021
cy.getTestElement(`title`).should(`have.text`, data.static.title)
22+
cy.title().should(`eq`, data.static.title)
2123
cy.getTestElement(`meta`)
2224
.invoke(`attr`, `content`)
2325
.should(`equal`, data.static.meta)
@@ -31,10 +33,12 @@ describe(`Head function export html insertion`, () => {
3133

3234
it(`should work with data from a page query`, () => {
3335
cy.visit(page.pageQuery).waitForRouteChange()
36+
3437
cy.getTestElement(`base`)
3538
.invoke(`attr`, `href`)
3639
.should(`equal`, data.queried.base)
3740
cy.getTestElement(`title`).should(`have.text`, data.queried.title)
41+
cy.title().should(`eq`, data.queried.title)
3842
cy.getTestElement(`meta`)
3943
.invoke(`attr`, `content`)
4044
.should(`equal`, data.queried.meta)
@@ -47,10 +51,12 @@ describe(`Head function export html insertion`, () => {
4751

4852
it(`should work when a Head function with static data is re-exported from the page`, () => {
4953
cy.visit(page.reExport).waitForRouteChange()
54+
5055
cy.getTestElement(`base`)
5156
.invoke(`attr`, `href`)
5257
.should(`equal`, data.static.base)
5358
cy.getTestElement(`title`).should(`have.text`, data.static.title)
59+
cy.title().should(`eq`, data.static.title)
5460
cy.getTestElement(`meta`)
5561
.invoke(`attr`, `content`)
5662
.should(`equal`, data.static.meta)
@@ -64,10 +70,12 @@ describe(`Head function export html insertion`, () => {
6470

6571
it(`should work when an imported Head component with queried data is used`, () => {
6672
cy.visit(page.staticQuery).waitForRouteChange()
73+
6774
cy.getTestElement(`base`)
6875
.invoke(`attr`, `href`)
6976
.should(`equal`, data.queried.base)
7077
cy.getTestElement(`title`).should(`have.text`, data.queried.title)
78+
cy.title().should(`eq`, data.queried.title)
7179
cy.getTestElement(`meta`)
7280
.invoke(`attr`, `content`)
7381
.should(`equal`, data.queried.meta)
@@ -80,10 +88,12 @@ describe(`Head function export html insertion`, () => {
8088

8189
it(`should work in a DSG page (exporting function named config)`, () => {
8290
cy.visit(page.dsg).waitForRouteChange()
91+
8392
cy.getTestElement(`base`)
8493
.invoke(`attr`, `href`)
8594
.should(`equal`, data.dsg.base)
8695
cy.getTestElement(`title`).should(`have.text`, data.dsg.title)
96+
cy.title().should(`eq`, data.dsg.title)
8797
cy.getTestElement(`meta`)
8898
.invoke(`attr`, `content`)
8999
.should(`equal`, data.dsg.meta)
@@ -96,10 +106,12 @@ describe(`Head function export html insertion`, () => {
96106

97107
it(`should work in an SSR page (exporting function named getServerData)`, () => {
98108
cy.visit(page.ssr).waitForRouteChange()
109+
99110
cy.getTestElement(`base`)
100111
.invoke(`attr`, `href`)
101112
.should(`equal`, data.ssr.base)
102113
cy.getTestElement(`title`).should(`have.text`, data.ssr.title)
114+
cy.title().should(`eq`, data.ssr.title)
103115
cy.getTestElement(`meta`)
104116
.invoke(`attr`, `content`)
105117
.should(`equal`, data.ssr.meta)

e2e-tests/production-runtime/cypress/integration/head-function-export/typescript.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ describe(`Tsx Pages`, () => {
33
cy.visit(`/head-function-export/tsx-page`)
44

55
cy.getTestElement(`title`).should(`contain`, `TypeScript`)
6+
cy.title().should(`eq`, `TypeScript`)
67

78
cy.getTestElement(`name`)
89
.invoke(`attr`, `content`)

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,16 @@ export function getValidHeadNodesAndAttributes(
156156
let clonedNode = node.cloneNode(true)
157157
clonedNode.setAttribute(`data-gatsby-head`, true)
158158

159-
// // This is hack to make script tags work
159+
// This is a hack to make script tags work
160+
// TODO(serhalp): Explain what this is solving
160161
if (clonedNode.nodeName.toLowerCase() === `script`) {
161-
clonedNode = massageScript(clonedNode)
162+
clonedNode = cloneNodeWithoutNS(clonedNode)
163+
}
164+
// Recreate <title> elements in the HTML namespace to ensure `document.title` updates. When
165+
// rendered inside an SVG (React 19 workaround), <title> elements inherit the SVG namespace
166+
// (immutable on DOM nodes) and won't trigger an update of `document.title` when inserted in <head>.
167+
if (clonedNode.nodeName.toLowerCase() === `title`) {
168+
clonedNode = cloneNodeWithoutNS(clonedNode)
162169
}
163170
// Duplicate ids are not allowed in the head, so we need to dedupe them
164171
if (id) {
@@ -195,8 +202,13 @@ export function getValidHeadNodesAndAttributes(
195202
return { validHeadNodes, htmlAndBodyAttributes }
196203
}
197204

198-
function massageScript(node) {
199-
const script = document.createElement(`script`)
205+
/**
206+
* Recreate an element in the HTML namespace.
207+
*
208+
* This is similar to `cloneNode()` but reinitializes immutable properties like the namespace.
209+
*/
210+
function cloneNodeWithoutNS(node) {
211+
const script = document.createElement(node.nodeName)
200212
for (const attr of node.attributes) {
201213
script.setAttribute(attr.name, attr.value)
202214
}

0 commit comments

Comments
 (0)