Skip to content

Commit f882a38

Browse files
committed
Pages router: Use attribute-based head tags reconciler
In React 19, tags within `<head>` may be reordered to improve performance e.g. the viewport is floated earlier into the head. This breaks the current mechanism of `<Head>` managing its children. Every child of `Head` used to be prefixed with another `<meta>` tag that indicated that the next sibling would be managed by Next.js. Since React now reorders tags, that sibling relationship is broken. Client-side reconciliation by the `head-manager` during navigation would be broken resulting in orphaned and dupliated `<meta>` tags. We no longer prefix `<Head>` managed tags with a `<meta>` tag and instead mark them as owned via `data-next-head`. The old algorithm was also O(n*m) and ignored reordering so we can do the same thing here.
1 parent 581fb0c commit f882a38

File tree

5 files changed

+235
-110
lines changed

5 files changed

+235
-110
lines changed

packages/next/src/client/head-manager.ts

+39-41
Original file line numberDiff line numberDiff line change
@@ -51,62 +51,60 @@ export function isEqualNode(oldTag: Element, newTag: Element) {
5151
let updateElements: (type: string, components: JSX.Element[]) => void
5252

5353
if (process.env.__NEXT_STRICT_NEXT_HEAD) {
54-
updateElements = (type: string, components: JSX.Element[]) => {
55-
const headEl = document.querySelector('head')
56-
if (!headEl) return
54+
updateElements = (type, components) => {
55+
const headElement = document.querySelector('head')
56+
if (headElement === null) {
57+
return
58+
}
5759

58-
const headMetaTags = headEl.querySelectorAll('meta[name="next-head"]') || []
59-
const oldTags: Element[] = []
60+
const oldTags = new Set(
61+
headElement.querySelectorAll(`${type}[data-next-head]:not(title)`)
62+
)
6063

6164
if (type === 'meta') {
62-
const metaCharset = headEl.querySelector('meta[charset]')
63-
if (metaCharset) {
64-
oldTags.push(metaCharset)
65+
const metaCharset = headElement.querySelector('meta[charset]')
66+
if (metaCharset !== null) {
67+
oldTags.add(metaCharset)
6568
}
6669
}
6770

68-
for (let i = 0; i < headMetaTags.length; i++) {
69-
const metaTag = headMetaTags[i]
70-
const headTag = metaTag.nextSibling as Element
71-
72-
if (headTag?.tagName?.toLowerCase() === type) {
73-
oldTags.push(headTag)
74-
}
75-
}
76-
const newTags = (components.map(reactElementToDOM) as HTMLElement[]).filter(
77-
(newTag) => {
78-
for (let k = 0, len = oldTags.length; k < len; k++) {
79-
const oldTag = oldTags[k]
80-
if (isEqualNode(oldTag, newTag)) {
81-
oldTags.splice(k, 1)
82-
return false
83-
}
71+
const newTags: Element[] = []
72+
for (let i = 0; i < components.length; i++) {
73+
const component = components[i]
74+
const newTag = reactElementToDOM(component)
75+
newTag.setAttribute('data-next-head', '')
76+
77+
let isNew = true
78+
for (const oldTag of oldTags) {
79+
if (isEqualNode(oldTag, newTag)) {
80+
oldTags.delete(oldTag)
81+
isNew = false
82+
break
8483
}
85-
return true
8684
}
87-
)
8885

89-
oldTags.forEach((t) => {
90-
const metaTag = t.previousSibling as Element
91-
if (metaTag && metaTag.getAttribute('name') === 'next-head') {
92-
t.parentNode?.removeChild(metaTag)
86+
if (isNew) {
87+
newTags.push(newTag)
9388
}
94-
t.parentNode?.removeChild(t)
95-
})
96-
newTags.forEach((t) => {
97-
const meta = document.createElement('meta')
98-
meta.name = 'next-head'
99-
meta.content = '1'
89+
}
10090

91+
for (const oldTag of oldTags) {
92+
oldTag.parentNode?.removeChild(oldTag)
93+
}
94+
95+
for (const newTag of newTags) {
10196
// meta[charset] must be first element so special case
102-
if (!(t.tagName?.toLowerCase() === 'meta' && t.getAttribute('charset'))) {
103-
headEl.appendChild(meta)
97+
if (
98+
newTag.tagName.toLowerCase() === 'meta' &&
99+
newTag.getAttribute('charset') !== null
100+
) {
101+
headElement.prepend(newTag)
104102
}
105-
headEl.appendChild(t)
106-
})
103+
headElement.appendChild(newTag)
104+
}
107105
}
108106
} else {
109-
updateElements = (type: string, components: JSX.Element[]) => {
107+
updateElements = (type, components) => {
110108
const headEl = document.getElementsByTagName('head')[0]
111109
const headCountEl: HTMLMetaElement = headEl.querySelector(
112110
'meta[name=next-head-count]'

packages/next/src/pages/_document.tsx

+19-20
Original file line numberDiff line numberDiff line change
@@ -682,30 +682,29 @@ export class Head extends React.Component<HeadProps> {
682682
let cssPreloads: Array<JSX.Element> = []
683683
let otherHeadElements: Array<JSX.Element> = []
684684
if (head) {
685-
head.forEach((c) => {
686-
let metaTag
687-
688-
if (this.context.strictNextHead) {
689-
metaTag = React.createElement('meta', {
690-
name: 'next-head',
691-
content: '1',
692-
})
693-
}
694-
685+
head.forEach((child) => {
695686
if (
696-
c &&
697-
c.type === 'link' &&
698-
c.props['rel'] === 'preload' &&
699-
c.props['as'] === 'style'
687+
child &&
688+
child.type === 'link' &&
689+
child.props['rel'] === 'preload' &&
690+
child.props['as'] === 'style'
700691
) {
701-
metaTag && cssPreloads.push(metaTag)
702-
cssPreloads.push(c)
692+
if (this.context.strictNextHead) {
693+
cssPreloads.push(
694+
React.cloneElement(child, { 'data-next-head': '' })
695+
)
696+
} else {
697+
cssPreloads.push(child)
698+
}
703699
} else {
704-
if (c) {
705-
if (metaTag && (c.type !== 'meta' || !c.props['charSet'])) {
706-
otherHeadElements.push(metaTag)
700+
if (child) {
701+
if (this.context.strictNextHead) {
702+
otherHeadElements.push(
703+
React.cloneElement(child, { 'data-next-head': '' })
704+
)
705+
} else {
706+
otherHeadElements.push(child)
707707
}
708-
otherHeadElements.push(c)
709708
}
710709
}
711710
})

test/development/pages-dir/client-navigation/index.test.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1683,13 +1683,19 @@ describe.each([[false], [true]])(
16831683
expect(
16841684
Number(await browser.eval('window.__test_async_executions'))
16851685
).toBe(1)
1686+
expect(
1687+
Number(await browser.eval('window.__test_defer_executions'))
1688+
).toBe(1)
16861689

16871690
await browser.elementByCss('#toggleScript').click()
16881691
await waitFor(2000)
16891692

16901693
expect(
16911694
Number(await browser.eval('window.__test_async_executions'))
16921695
).toBe(1)
1696+
expect(
1697+
Number(await browser.eval('window.__test_defer_executions'))
1698+
).toBe(1)
16931699
} finally {
16941700
if (browser) {
16951701
await browser.close()

0 commit comments

Comments
 (0)