Skip to content

Commit fe65c29

Browse files
marvinjudepieh
andauthored
feat(gatsby): Allow <html> and <body> attributes to be updated from Head (#37449)
* allow usage of html and body tags in head * add integration test for html and body attrs * get rid of debug logs * setBody/HtmlAttributes doesn't have second arg * drop another console.log * add test to e2e/dev * add test to e2e/prod * sigh ... silence invalid nesting of html and body elements * add comment about order of onRenderBody vs Head * consistent return * dev ssr tests * offline ... * offline ... vol2 * fix tracking body attributes * fix deduplication Co-authored-by: pieh <[email protected]>
1 parent e4f841f commit fe65c29

File tree

17 files changed

+431
-75
lines changed

17 files changed

+431
-75
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import headFunctionExportSharedData from "../../../shared-data/head-function-export.js"
2+
3+
describe(`Html and body attributes`, () => {
4+
it(`Page has body and html attributes on direct visit`, () => {
5+
cy.visit(
6+
headFunctionExportSharedData.page.htmlAndBodyAttributes
7+
).waitForRouteChange()
8+
9+
cy.get(`body`).should(`have.attr`, `data-foo`, `baz`)
10+
cy.get(`body`).should(`have.attr`, `class`, `foo`)
11+
cy.get(`html`).should(`have.attr`, `data-foo`, `bar`)
12+
cy.get(`html`).should(`have.attr`, `lang`, `fr`)
13+
})
14+
15+
it(`Page has body and html attributes on client-side navigation`, () => {
16+
cy.visit(headFunctionExportSharedData.page.basic).waitForRouteChange()
17+
18+
cy.get(`body`).should(`not.have.attr`, `data-foo`, `baz`)
19+
cy.get(`body`).should(`not.have.attr`, `class`, `foo`)
20+
cy.get(`html`).should(`not.have.attr`, `data-foo`, `bar`)
21+
cy.get(`html`).should(`not.have.attr`, `lang`, `fr`)
22+
23+
cy.visit(
24+
headFunctionExportSharedData.page.htmlAndBodyAttributes
25+
).waitForRouteChange()
26+
27+
cy.get(`body`).should(`have.attr`, `data-foo`, `baz`)
28+
cy.get(`body`).should(`have.attr`, `class`, `foo`)
29+
cy.get(`html`).should(`have.attr`, `data-foo`, `bar`)
30+
cy.get(`html`).should(`have.attr`, `lang`, `fr`)
31+
})
32+
33+
it(`Body and html attributes are removed on client-side navigation when new page doesn't set them`, () => {
34+
cy.visit(
35+
headFunctionExportSharedData.page.htmlAndBodyAttributes
36+
).waitForRouteChange()
37+
38+
cy.get(`body`).should(`have.attr`, `data-foo`, `baz`)
39+
cy.get(`body`).should(`have.attr`, `class`, `foo`)
40+
cy.get(`html`).should(`have.attr`, `data-foo`, `bar`)
41+
cy.get(`html`).should(`have.attr`, `lang`, `fr`)
42+
43+
cy.visit(headFunctionExportSharedData.page.basic).waitForRouteChange()
44+
45+
cy.get(`body`).should(`not.have.attr`, `data-foo`, `baz`)
46+
cy.get(`body`).should(`not.have.attr`, `class`, `foo`)
47+
cy.get(`html`).should(`not.have.attr`, `data-foo`, `bar`)
48+
cy.get(`html`).should(`not.have.attr`, `lang`, `fr`)
49+
})
50+
})

e2e-tests/development-runtime/shared-data/head-function-export.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const page = {
1212
invalidElements: `${path}/invalid-elements/`,
1313
fsRouteApi: `${path}/fs-route-api/`,
1414
deduplication: `${path}/deduplication/`,
15+
htmlAndBodyAttributes: `${path}/html-and-body-attributes/`,
1516
}
1617

1718
const data = {
@@ -23,7 +24,7 @@ const data = {
2324
style: `rebeccapurple`,
2425
link: `/used-by-head-function-export-basic.css`,
2526
extraMeta: `Extra meta tag that should be removed during navigation`,
26-
jsonLD: `{"@context":"https://schema.org","@type":"Organization","url":"https://www.spookytech.com","name":"Spookytechnologies","contactPoint":{"@type":"ContactPoint","telephone":"+5-601-785-8543","contactType":"CustomerSupport"}}`
27+
jsonLD: `{"@context":"https://schema.org","@type":"Organization","url":"https://www.spookytech.com","name":"Spookytechnologies","contactPoint":{"@type":"ContactPoint","telephone":"+5-601-785-8543","contactType":"CustomerSupport"}}`,
2728
},
2829
queried: {
2930
base: `http://localhost:8000`,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from "react"
2+
3+
export default function HeadFunctionHtmlAndBodyAttributes() {
4+
return (
5+
<>
6+
<h1>I have html and body attributes</h1>
7+
</>
8+
)
9+
}
10+
11+
function Indirection({ children }) {
12+
return (
13+
<>
14+
<html lang="fr" />
15+
<body className="foo" />
16+
{children}
17+
</>
18+
)
19+
}
20+
21+
export function Head() {
22+
return (
23+
<Indirection>
24+
<html data-foo="bar" />
25+
<body data-foo="baz" />
26+
</Indirection>
27+
)
28+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import headFunctionExportSharedData from "../../../shared-data/head-function-export.js"
2+
3+
Cypress.on("uncaught:exception", err => {
4+
if (
5+
(err.message.includes("Minified React error #418") ||
6+
err.message.includes("Minified React error #423") ||
7+
err.message.includes("Minified React error #425")) &&
8+
Cypress.env(`TEST_PLUGIN_OFFLINE`)
9+
) {
10+
return false
11+
}
12+
})
13+
14+
describe(`Html and body attributes`, () => {
15+
it(`Page has body and html attributes on direct visit`, () => {
16+
cy.visit(
17+
headFunctionExportSharedData.page.htmlAndBodyAttributes
18+
).waitForRouteChange()
19+
20+
cy.get(`body`).should(`have.attr`, `data-foo`, `baz`)
21+
cy.get(`body`).should(`have.attr`, `class`, `foo`)
22+
cy.get(`html`).should(`have.attr`, `data-foo`, `bar`)
23+
cy.get(`html`).should(`have.attr`, `lang`, `fr`)
24+
})
25+
26+
it(`Page has body and html attributes on client-side navigation`, () => {
27+
cy.visit(headFunctionExportSharedData.page.basic).waitForRouteChange()
28+
29+
cy.get(`body`).should(`not.have.attr`, `data-foo`, `baz`)
30+
cy.get(`body`).should(`not.have.attr`, `class`, `foo`)
31+
cy.get(`html`).should(`not.have.attr`, `data-foo`, `bar`)
32+
cy.get(`html`).should(`not.have.attr`, `lang`, `fr`)
33+
34+
cy.visit(
35+
headFunctionExportSharedData.page.htmlAndBodyAttributes
36+
).waitForRouteChange()
37+
38+
cy.get(`body`).should(`have.attr`, `data-foo`, `baz`)
39+
cy.get(`body`).should(`have.attr`, `class`, `foo`)
40+
cy.get(`html`).should(`have.attr`, `data-foo`, `bar`)
41+
cy.get(`html`).should(`have.attr`, `lang`, `fr`)
42+
})
43+
44+
it(`Body and html attributes are removed on client-side navigation when new page doesn't set them`, () => {
45+
cy.visit(
46+
headFunctionExportSharedData.page.htmlAndBodyAttributes
47+
).waitForRouteChange()
48+
49+
cy.get(`body`).should(`have.attr`, `data-foo`, `baz`)
50+
cy.get(`body`).should(`have.attr`, `class`, `foo`)
51+
cy.get(`html`).should(`have.attr`, `data-foo`, `bar`)
52+
cy.get(`html`).should(`have.attr`, `lang`, `fr`)
53+
54+
cy.visit(headFunctionExportSharedData.page.basic).waitForRouteChange()
55+
56+
cy.get(`body`).should(`not.have.attr`, `data-foo`, `baz`)
57+
cy.get(`body`).should(`not.have.attr`, `class`, `foo`)
58+
cy.get(`html`).should(`not.have.attr`, `data-foo`, `bar`)
59+
cy.get(`html`).should(`not.have.attr`, `lang`, `fr`)
60+
})
61+
})

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import { page, data } from "../../../shared-data/head-function-export.js"
22

3+
Cypress.on("uncaught:exception", err => {
4+
if (
5+
(err.message.includes("Minified React error #418") ||
6+
err.message.includes("Minified React error #423") ||
7+
err.message.includes("Minified React error #425")) &&
8+
Cypress.env(`TEST_PLUGIN_OFFLINE`)
9+
) {
10+
return false
11+
}
12+
})
13+
314
describe(`Head function export html insertion`, () => {
415
it(`should work with static data`, () => {
516
cy.visit(page.basic).waitForRouteChange()

e2e-tests/production-runtime/shared-data/head-function-export.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const page = {
1313
fsRouteApi: `${path}/fs-route-api/`,
1414
deduplication: `${path}/deduplication/`,
1515
pageWithUseLocation: `${path}/page-with-uselocation/`,
16+
htmlAndBodyAttributes: `${path}/html-and-body-attributes/`,
1617
}
1718

1819
const data = {
@@ -24,7 +25,7 @@ const data = {
2425
style: `rebeccapurple`,
2526
link: `/used-by-head-function-export-basic.css`,
2627
extraMeta: `Extra meta tag that should be removed during navigation`,
27-
jsonLD: `{"@context":"https://schema.org","@type":"Organization","url":"https://www.spookytech.com","name":"Spookytechnologies","contactPoint":{"@type":"ContactPoint","telephone":"+5-601-785-8543","contactType":"CustomerSupport"}}`
28+
jsonLD: `{"@context":"https://schema.org","@type":"Organization","url":"https://www.spookytech.com","name":"Spookytechnologies","contactPoint":{"@type":"ContactPoint","telephone":"+5-601-785-8543","contactType":"CustomerSupport"}}`,
2829
},
2930
queried: {
3031
base: `http://localhost:9000`,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from "react"
2+
3+
export default function HeadFunctionHtmlAndBodyAttributes() {
4+
return (
5+
<>
6+
<h1>I have html and body attributes</h1>
7+
</>
8+
)
9+
}
10+
11+
function Indirection({ children }) {
12+
return (
13+
<>
14+
<html lang="fr" />
15+
<body className="foo" />
16+
{children}
17+
</>
18+
)
19+
}
20+
21+
export function Head() {
22+
return (
23+
<Indirection>
24+
<html data-foo="bar" />
25+
<body data-foo="baz" />
26+
</Indirection>
27+
)
28+
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,24 @@ describe(`Head function export SSR'ed HTML output`, () => {
105105
// alternate links are not using id, so should have multiple instances
106106
expect(dom.querySelectorAll(`link[rel=alternate]`)?.length).toEqual(2)
107107
})
108+
109+
it(`should allow setting html and body attributes`, () => {
110+
const html = readFileSync(
111+
`${publicDir}${page.bodyAndHtmlAttributes}/index.html`
112+
)
113+
const dom = parse(html)
114+
expect(dom.querySelector(`html`).attributes).toMatchInlineSnapshot(`
115+
{
116+
"data-foo": "bar",
117+
"lang": "fr",
118+
}
119+
`)
120+
121+
expect(dom.querySelector(`body`).attributes).toMatchInlineSnapshot(`
122+
{
123+
"class": "foo",
124+
"data-foo": "baz",
125+
}
126+
`)
127+
})
108128
})

integration-tests/head-function-export/shared-data/head-function-export.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const page = {
88
warnings: `${path}/warnings/`,
99
allProps: `${path}/all-props/`,
1010
deduplication: `${path}/deduplication/`,
11+
bodyAndHtmlAttributes: `${path}/html-and-body-attributes/`,
1112
}
1213

1314
const data = {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as React from "react"
2+
3+
export default function HeadFunctionHtmlAndBodyAttributes() {
4+
return (
5+
<>
6+
<h1>I have html and body attributes</h1>
7+
</>
8+
)
9+
}
10+
11+
function Indirection({ children }) {
12+
return (
13+
<>
14+
<html lang="fr" />
15+
<body className="foo" />
16+
{children}
17+
</>
18+
)
19+
}
20+
21+
export function Head() {
22+
return (
23+
<Indirection>
24+
<html data-foo="bar" />
25+
<body data-foo="baz" />
26+
</Indirection>
27+
)
28+
}

integration-tests/ssr/__tests__/ssr.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ describe(`SSR`, () => {
6464
const ssrHead = ssrDom.querySelector(`[data-testid=title]`)
6565

6666
expect(devSsrHead.textContent).toEqual(ssrHead.textContent)
67+
expect(devSsrDom.querySelector(`html`).attributes).toEqual(
68+
ssrDom.querySelector(`html`).attributes
69+
)
70+
expect(devSsrDom.querySelector(`html`).attributes).toMatchInlineSnapshot(`
71+
Object {
72+
"data-foo": "bar",
73+
"lang": "fr",
74+
}
75+
`)
76+
77+
expect(devSsrDom.querySelector(`body`).attributes).toEqual(
78+
ssrDom.querySelector(`body`).attributes
79+
)
80+
expect(devSsrDom.querySelector(`body`).attributes).toMatchInlineSnapshot(`
81+
Object {
82+
"data-foo": "baz",
83+
}
84+
`)
6785
})
6886

6987
describe(`it generates an error page correctly`, () => {

integration-tests/ssr/src/pages/head-function-export.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,11 @@ export default function PageWithHeadFunctionExport() {
55
}
66

77
export function Head() {
8-
return <title data-testid="title">Hello world</title>
8+
return (
9+
<>
10+
<html lang="fr" data-foo="bar" />
11+
<body data-foo="baz" />
12+
<title data-testid="title">Hello world</title>
13+
</>
14+
)
915
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ export const VALID_NODE_NAMES = [
66
`base`,
77
`noscript`,
88
`script`,
9+
`html`,
10+
`body`,
911
]

0 commit comments

Comments
 (0)