Skip to content

Commit e7ee686

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 e7ee686

File tree

5 files changed

+228
-107
lines changed

5 files changed

+228
-107
lines changed

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

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -51,62 +51,56 @@ 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[]) => {
54+
updateElements = (type, components) => {
5555
const headEl = document.querySelector('head')
5656
if (!headEl) return
5757

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

6160
if (type === 'meta') {
6261
const metaCharset = headEl.querySelector('meta[charset]')
63-
if (metaCharset) {
64-
oldTags.push(metaCharset)
62+
if (metaCharset !== null) {
63+
oldTags.add(metaCharset)
6564
}
6665
}
6766

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-
}
67+
const newTags: Element[] = []
68+
for (let i = 0; i < components.length; i++) {
69+
const component = components[i]
70+
const newTag = reactElementToDOM(component)
71+
newTag.setAttribute('data-next-head', '')
72+
73+
let isNew = true
74+
for (const oldTag of oldTags) {
75+
if (isEqualNode(oldTag, newTag)) {
76+
oldTags.delete(oldTag)
77+
isNew = false
78+
break
8479
}
85-
return true
8680
}
87-
)
8881

89-
oldTags.forEach((t) => {
90-
const metaTag = t.previousSibling as Element
91-
if (metaTag && metaTag.getAttribute('name') === 'next-head') {
92-
t.parentNode?.removeChild(metaTag)
82+
if (isNew) {
83+
newTags.push(newTag)
9384
}
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'
85+
}
86+
87+
for (const oldTag of oldTags) {
88+
oldTag.parentNode?.removeChild(oldTag)
89+
}
10090

91+
for (const newTag of newTags) {
10192
// meta[charset] must be first element so special case
102-
if (!(t.tagName?.toLowerCase() === 'meta' && t.getAttribute('charset'))) {
103-
headEl.appendChild(meta)
93+
if (
94+
newTag.tagName.toLowerCase() === 'meta' &&
95+
newTag.getAttribute('charset') !== null
96+
) {
97+
headEl.prepend(newTag)
10498
}
105-
headEl.appendChild(t)
106-
})
99+
headEl.appendChild(newTag)
100+
}
107101
}
108102
} else {
109-
updateElements = (type: string, components: JSX.Element[]) => {
103+
updateElements = (type, components) => {
110104
const headEl = document.getElementsByTagName('head')[0]
111105
const headCountEl: HTMLMetaElement = headEl.querySelector(
112106
'meta[name=next-head-count]'

packages/next/src/pages/_document.tsx

Lines changed: 19 additions & 20 deletions
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

Lines changed: 6 additions & 0 deletions
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)