Skip to content

Commit

Permalink
Pages router: Use attribute-based head tags reconciler
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
eps1lon committed May 6, 2024
1 parent 581fb0c commit f882a38
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 110 deletions.
80 changes: 39 additions & 41 deletions packages/next/src/client/head-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,62 +51,60 @@ export function isEqualNode(oldTag: Element, newTag: Element) {
let updateElements: (type: string, components: JSX.Element[]) => void

if (process.env.__NEXT_STRICT_NEXT_HEAD) {
updateElements = (type: string, components: JSX.Element[]) => {
const headEl = document.querySelector('head')
if (!headEl) return
updateElements = (type, components) => {
const headElement = document.querySelector('head')
if (headElement === null) {
return
}

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

if (type === 'meta') {
const metaCharset = headEl.querySelector('meta[charset]')
if (metaCharset) {
oldTags.push(metaCharset)
const metaCharset = headElement.querySelector('meta[charset]')
if (metaCharset !== null) {
oldTags.add(metaCharset)
}
}

for (let i = 0; i < headMetaTags.length; i++) {
const metaTag = headMetaTags[i]
const headTag = metaTag.nextSibling as Element

if (headTag?.tagName?.toLowerCase() === type) {
oldTags.push(headTag)
}
}
const newTags = (components.map(reactElementToDOM) as HTMLElement[]).filter(
(newTag) => {
for (let k = 0, len = oldTags.length; k < len; k++) {
const oldTag = oldTags[k]
if (isEqualNode(oldTag, newTag)) {
oldTags.splice(k, 1)
return false
}
const newTags: Element[] = []
for (let i = 0; i < components.length; i++) {
const component = components[i]
const newTag = reactElementToDOM(component)
newTag.setAttribute('data-next-head', '')

let isNew = true
for (const oldTag of oldTags) {
if (isEqualNode(oldTag, newTag)) {
oldTags.delete(oldTag)
isNew = false
break
}
return true
}
)

oldTags.forEach((t) => {
const metaTag = t.previousSibling as Element
if (metaTag && metaTag.getAttribute('name') === 'next-head') {
t.parentNode?.removeChild(metaTag)
if (isNew) {
newTags.push(newTag)
}
t.parentNode?.removeChild(t)
})
newTags.forEach((t) => {
const meta = document.createElement('meta')
meta.name = 'next-head'
meta.content = '1'
}

for (const oldTag of oldTags) {
oldTag.parentNode?.removeChild(oldTag)
}

for (const newTag of newTags) {
// meta[charset] must be first element so special case
if (!(t.tagName?.toLowerCase() === 'meta' && t.getAttribute('charset'))) {
headEl.appendChild(meta)
if (
newTag.tagName.toLowerCase() === 'meta' &&
newTag.getAttribute('charset') !== null
) {
headElement.prepend(newTag)
}
headEl.appendChild(t)
})
headElement.appendChild(newTag)
}
}
} else {
updateElements = (type: string, components: JSX.Element[]) => {
updateElements = (type, components) => {
const headEl = document.getElementsByTagName('head')[0]
const headCountEl: HTMLMetaElement = headEl.querySelector(
'meta[name=next-head-count]'
Expand Down
39 changes: 19 additions & 20 deletions packages/next/src/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -682,30 +682,29 @@ export class Head extends React.Component<HeadProps> {
let cssPreloads: Array<JSX.Element> = []
let otherHeadElements: Array<JSX.Element> = []
if (head) {
head.forEach((c) => {
let metaTag

if (this.context.strictNextHead) {
metaTag = React.createElement('meta', {
name: 'next-head',
content: '1',
})
}

head.forEach((child) => {
if (
c &&
c.type === 'link' &&
c.props['rel'] === 'preload' &&
c.props['as'] === 'style'
child &&
child.type === 'link' &&
child.props['rel'] === 'preload' &&
child.props['as'] === 'style'
) {
metaTag && cssPreloads.push(metaTag)
cssPreloads.push(c)
if (this.context.strictNextHead) {
cssPreloads.push(
React.cloneElement(child, { 'data-next-head': '' })
)
} else {
cssPreloads.push(child)
}
} else {
if (c) {
if (metaTag && (c.type !== 'meta' || !c.props['charSet'])) {
otherHeadElements.push(metaTag)
if (child) {
if (this.context.strictNextHead) {
otherHeadElements.push(
React.cloneElement(child, { 'data-next-head': '' })
)
} else {
otherHeadElements.push(child)
}
otherHeadElements.push(c)
}
}
})
Expand Down
6 changes: 6 additions & 0 deletions test/development/pages-dir/client-navigation/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,13 +1683,19 @@ describe.each([[false], [true]])(
expect(
Number(await browser.eval('window.__test_async_executions'))
).toBe(1)
expect(
Number(await browser.eval('window.__test_defer_executions'))
).toBe(1)

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

expect(
Number(await browser.eval('window.__test_async_executions'))
).toBe(1)
expect(
Number(await browser.eval('window.__test_defer_executions'))
).toBe(1)
} finally {
if (browser) {
await browser.close()
Expand Down
Loading

0 comments on commit f882a38

Please sign in to comment.