Skip to content

Commit 451e425

Browse files
piehserhalp
authored andcommitted
fix: work around React 19's handling of html, body, and children of head
1 parent e81053c commit 451e425

File tree

3 files changed

+67
-8
lines changed

3 files changed

+67
-8
lines changed

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

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import React from "react"
22
import { useEffect } from "react"
3+
import semver from "semver"
34
import { StaticQueryContext } from "gatsby"
45
import { LocationProvider } from "@gatsbyjs/reach-router"
6+
57
import { reactDOMUtils } from "../react-dom-utils"
68
import { FireCallbackInEffect } from "./components/fire-callback-in-effect"
79
import {
@@ -32,7 +34,7 @@ const onHeadRendered = () => {
3234

3335
/**
3436
* The rest of the code block below is a diffing mechanism to ensure that
35-
* the head elements aren't duplicted on every re-render.
37+
* the head elements aren't duplicated on every re-render.
3638
*/
3739
const existingHeadElements = document.querySelectorAll(`[data-gatsby-head]`)
3840

@@ -84,6 +86,40 @@ if (process.env.BUILD_STAGE === `develop`) {
8486
})
8587
}
8688

89+
// TODO(serhalp): Expose these as a new public API to allows us to gradually phase out
90+
// the existing `<html>` and `<body>` features in the Head API, allowing us to remove
91+
// the various workounds we have in place for React 19.
92+
function Html(props) {
93+
return <div data-original-tag="html" {...props} />
94+
}
95+
function Body(props) {
96+
return <div data-original-tag="body" {...props} />
97+
}
98+
99+
// React 19 does not allow rendering a second `<html>` or `<body>` anywhere in the tree:
100+
// https://github.com/facebook/react/blob/50e7ec8a694072fd6fcd52182df8a75211bf084d/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js#L3739
101+
// Unfortunately, our Head API relies on users being able to render these elements, which we then
102+
// render in a hidden `<div>` to extract attributes to apply to the real `<html>` and `<body>`.
103+
// We explored several alternatives, but none were viable given React's constraints.
104+
// To work around this, we intercept attempts to render these elements inside of
105+
// Head and replace them with custom components that render as `<div>`s with a
106+
// `data-original-tag` attribute. We can then use this attribute later to apply
107+
// attributes to the real `<html>` and `<body>` elements.
108+
const IsHeadRenderContext = React.createContext(false)
109+
// De-risk monkey patch by only applying it when needed
110+
if (semver.gte(React.version, `19.0.0`)) {
111+
const originalCreateElement = React.createElement
112+
React.createElement = (type, ...rest) => {
113+
if (type === `html` || type === `body`) {
114+
const isHeadRender = React.useContext(IsHeadRenderContext)
115+
// De-risk monkey patch by only applying it within a `Head()` render.
116+
if (isHeadRender) {
117+
type = type === `html` ? Html : Body
118+
}
119+
}
120+
return originalCreateElement(type, ...rest)
121+
}
122+
}
87123
export function headHandlerForBrowser({
88124
pageComponent,
89125
staticQueryResults,
@@ -96,7 +132,13 @@ export function headHandlerForBrowser({
96132
const { render } = reactDOMUtils()
97133

98134
const HeadElement = (
99-
<pageComponent.Head {...filterHeadProps(pageComponentProps)} />
135+
// Wrap in an SVG to work around conflict between React 19's new document metadata tag
136+
// hoisting and Gatsby's own preexisting Head API. React will leave metadata tags (like
137+
// `<title>`) with an `<svg>` ancestor intact, letting Gatsby process them as usual. See
138+
// https://github.com/facebook/react/blob/fd524fe02a86c3e92a207d90da970941320f337f/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js#L765-L779.
139+
<svg data-gatsby-head-react-19-workaround="true">
140+
<pageComponent.Head {...filterHeadProps(pageComponentProps)} />
141+
</svg>
100142
)
101143

102144
const WrapHeadElement = apiRunner(
@@ -113,9 +155,11 @@ export function headHandlerForBrowser({
113155
// Note: In dev, we call onHeadRendered twice( in FireCallbackInEffect and after mutualution observer dectects initail render into hiddenRoot) this is for hot reloading
114156
// In Prod we only call onHeadRendered in FireCallbackInEffect to render to head
115157
<FireCallbackInEffect callback={onHeadRendered}>
116-
<StaticQueryContext.Provider value={staticQueryResults}>
117-
<LocationProvider>{WrapHeadElement}</LocationProvider>
118-
</StaticQueryContext.Provider>
158+
<IsHeadRenderContext.Provider value={true}>
159+
<StaticQueryContext.Provider value={staticQueryResults}>
160+
<LocationProvider>{WrapHeadElement}</LocationProvider>
161+
</StaticQueryContext.Provider>
162+
</IsHeadRenderContext.Provider>
119163
</FireCallbackInEffect>,
120164
hiddenRoot
121165
)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,16 @@ export function headHandlerForSSR({
132132
},
133133
}
134134

135-
const HeadElement = <pageComponent.Head {...filterHeadProps(_props)} />
135+
const HeadElement = (
136+
// Wrap in an SVG to work around conflict between React 19's new document metadata tag
137+
// hoisting and Gatsby's own preexisting Head API. React will leave metadata tags (like
138+
// `<title>`) with an `<svg>` ancestor intact, letting Gatsby process them as usual.
139+
// See
140+
// https://github.com/facebook/react/blob/fd524fe02a86c3e92a207d90da970941320f337f/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js#L765-L779.
141+
<svg data-gatsby-head-react-19-workaround="true">
142+
<pageComponent.Head {...filterHeadProps(_props)} />
143+
</svg>
144+
)
136145

137146
const headWithWrapRootElement = apiRunner(
138147
`wrapRootElement`,

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,9 @@ export function getValidHeadNodesAndAttributes(
119119

120120
// Filter out non-element nodes before looping since we don't care about them
121121
for (const node of rootNode.childNodes) {
122-
const nodeName = node.nodeName.toLowerCase()
122+
const nodeName =
123+
node.attributes?.getNamedItem(`data-original-tag`)?.value ??
124+
node.nodeName.toLowerCase()
123125
const id = node.attributes?.id?.value
124126

125127
if (!isElementType(node)) continue
@@ -128,6 +130,8 @@ export function getValidHeadNodesAndAttributes(
128130
// <html> and <body> tags are treated differently, in that we don't render them, we only extract the attributes and apply them separetely
129131
if (nodeName === `html` || nodeName === `body`) {
130132
for (const attribute of node.attributes) {
133+
if (attribute.name === `data-original-tag`) continue
134+
131135
const isStyleAttribute = attribute.name === `style`
132136

133137
// Merge attributes for same nodeName from previous loop iteration
@@ -174,7 +178,9 @@ export function getValidHeadNodesAndAttributes(
174178
validHeadNodes.push(clonedNode)
175179
}
176180
}
177-
} else {
181+
} else if (
182+
!node.attributes.getNamedItem(`data-gatsby-head-react-19-workaround`)
183+
) {
178184
warnForInvalidTag(nodeName)
179185
}
180186

0 commit comments

Comments
 (0)