Add next-head-count & remove next-head class (#8020)

Fixes #3494

Removes `class="next-head"` from the children of the `<Head>` component. Instead, a single sentinel meta element named `next-head-count` is appended. The content is the number of contiguous elements immediately preceding the sentinel that _would have had_ the `class="next-head"` attribute.

During an update, instead of searching for `class="next-head"`, the sentinel is located and the N previous elements are considered candidates for `oldTags`. New elements are inserted before the sentinel, and finally the sentinel is updated to reflect the new count.
This commit is contained in:
Gerald Monaco 2019-07-25 09:39:09 -07:00 committed by kodiakhq[bot]
parent 3ee5ec4412
commit e68307df3a
5 changed files with 64 additions and 77 deletions

View file

@ -8,15 +8,14 @@ type WithInAmpMode = {
inAmpMode?: boolean
}
export function defaultHead(className = 'next-head', inAmpMode = false) {
const head = [<meta key="charSet" charSet="utf-8" className={className} />]
export function defaultHead(inAmpMode = false) {
const head = [<meta key="charSet" charSet="utf-8" />]
if (!inAmpMode) {
head.push(
<meta
key="viewport"
name="viewport"
content="width=device-width,minimum-scale=1,initial-scale=1"
className={className}
/>
)
}
@ -121,19 +120,12 @@ function reduceComponents(
)
.reduce(onlyReactElement, [])
.reverse()
.concat(defaultHead('', props.inAmpMode))
.concat(defaultHead(props.inAmpMode))
.filter(unique())
.reverse()
.map((c: React.ReactElement<any>, i: number) => {
let className: string | undefined =
(c.props && c.props.className ? c.props.className + ' ' : '') +
'next-head'
if (c.type === 'title' && !c.props.className) {
className = undefined
}
const key = c.key || i
return React.cloneElement(c, { key, className })
return React.cloneElement(c, { key })
})
}

View file

@ -116,7 +116,7 @@ function render(
try {
html = renderElementToString(element)
} finally {
head = Head.rewind() || defaultHead(undefined, isInAmpMode(ampMode))
head = Head.rewind() || defaultHead(isInAmpMode(ampMode))
}
return { html, head }

View file

@ -46,14 +46,24 @@ export default class HeadManager {
updateElements (type, components) {
const headEl = document.getElementsByTagName('head')[0]
const oldTags = Array.prototype.slice.call(
headEl.querySelectorAll(type + '.next-head')
)
const headCountEl = headEl.querySelector('meta[name=next-head-count]')
const headCount = Number(headCountEl.content)
const oldTags = []
for (
let i = 0, j = headCountEl.previousElementSibling;
i < headCount;
i++, j = j.previousElementSibling
) {
if (j.tagName.toLowerCase() === type) {
oldTags.push(j)
}
}
const newTags = components.map(reactElementToDOM).filter(newTag => {
for (let i = 0, len = oldTags.length; i < len; i++) {
const oldTag = oldTags[i]
for (let k = 0, len = oldTags.length; k < len; k++) {
const oldTag = oldTags[k]
if (oldTag.isEqualNode(newTag)) {
oldTags.splice(i, 1)
oldTags.splice(k, 1)
return false
}
}
@ -61,7 +71,12 @@ export default class HeadManager {
})
oldTags.forEach(t => t.parentNode.removeChild(t))
newTags.forEach(t => headEl.appendChild(t))
newTags.forEach(t => headEl.insertBefore(t, headCountEl))
headCountEl.content = (
headCount -
oldTags.length +
newTags.length
).toString()
}
}

View file

@ -331,6 +331,10 @@ export class Head extends Component<
<head {...this.props}>
{children}
{head}
<meta
name="next-head-count"
content={React.Children.count(head || []).toString()}
/>
{inAmpMode && (
<>
<meta

View file

@ -13,9 +13,7 @@ export default function (render, fetch) {
describe('Rendering via HTTP', () => {
test('renders a stateless component', async () => {
const html = await render('/stateless')
expect(
html.includes('<meta charSet="utf-8" class="next-head"/>')
).toBeTruthy()
expect(html.includes('<meta charSet="utf-8"/>')).toBeTruthy()
expect(html.includes('My component!')).toBeTruthy()
})
@ -39,39 +37,33 @@ export default function (render, fetch) {
// default-head contains an empty <Head />.
test('header renders default charset', async () => {
const html = await render('/default-head')
expect(
html.includes('<meta charSet="utf-8" class="next-head"/>')
).toBeTruthy()
expect(html.includes('<meta charSet="utf-8"/>')).toBeTruthy()
expect(html.includes('next-head, but only once.')).toBeTruthy()
})
test('header renders default viewport', async () => {
const html = await render('/default-head')
expect(html).toContain(
'<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1" class="next-head"/>'
'<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"/>'
)
})
test('header helper renders header information', async () => {
const html = await render('/head')
expect(
html.includes('<meta charSet="iso-8859-5" class="next-head"/>')
).toBeTruthy()
expect(
html.includes('<meta content="my meta" class="next-head"/>')
).toBeTruthy()
expect(html.includes('<meta charSet="iso-8859-5"/>')).toBeTruthy()
expect(html.includes('<meta content="my meta"/>')).toBeTruthy()
expect(html).toContain(
'<meta name="viewport" content="width=device-width,initial-scale=1" class="next-head"/>'
'<meta name="viewport" content="width=device-width,initial-scale=1"/>'
)
expect(html.includes('I can have meta tags')).toBeTruthy()
})
test('header helper dedupes tags', async () => {
const html = await render('/head')
expect(html).toContain('<meta charSet="iso-8859-5" class="next-head"/>')
expect(html).not.toContain('<meta charSet="utf-8" class="next-head"/>')
expect(html).toContain('<meta charSet="iso-8859-5"/>')
expect(html).not.toContain('<meta charSet="utf-8"/>')
expect(html).toContain(
'<meta name="viewport" content="width=device-width,initial-scale=1" class="next-head"/>'
'<meta name="viewport" content="width=device-width,initial-scale=1"/>'
)
expect(html.match(/<meta name="viewport" /g).length).toBe(
1,
@ -80,85 +72,69 @@ export default function (render, fetch) {
expect(html).not.toContain(
'<meta name="viewport" content="width=device-width"/>'
)
expect(html).toContain('<meta content="my meta" class="next-head"/>')
expect(html).toContain('<meta content="my meta"/>')
expect(html).toContain(
'<link rel="stylesheet" href="/dup-style.css" class="next-head"/><link rel="stylesheet" href="/dup-style.css" class="next-head"/>'
)
expect(html).toContain(
'<link rel="stylesheet" href="dedupe-style.css" class="next-head"/>'
'<link rel="stylesheet" href="/dup-style.css"/><link rel="stylesheet" href="/dup-style.css"/>'
)
expect(html).toContain('<link rel="stylesheet" href="dedupe-style.css"/>')
expect(html).not.toContain(
'<link rel="stylesheet" href="dedupe-style.css" class="next-head"/><link rel="stylesheet" href="dedupe-style.css" class="next-head"/>'
'<link rel="stylesheet" href="dedupe-style.css"/><link rel="stylesheet" href="dedupe-style.css"/>'
)
})
test('header helper avoids dedupe of specific tags', async () => {
const html = await render('/head')
expect(html).toContain('<meta property="article:tag" content="tag1"/>')
expect(html).toContain('<meta property="article:tag" content="tag2"/>')
expect(html).not.toContain('<meta property="dedupe:tag" content="tag3"/>')
expect(html).toContain('<meta property="dedupe:tag" content="tag4"/>')
expect(html).toContain(
'<meta property="article:tag" content="tag1" class="next-head"/>'
'<meta property="og:image" content="ogImageTag1"/>'
)
expect(html).toContain(
'<meta property="article:tag" content="tag2" class="next-head"/>'
)
expect(html).not.toContain(
'<meta property="dedupe:tag" content="tag3" class="next-head"/>'
'<meta property="og:image" content="ogImageTag2"/>'
)
expect(html).toContain(
'<meta property="dedupe:tag" content="tag4" class="next-head"/>'
'<meta property="og:image:alt" content="ogImageAltTag1"/>'
)
expect(html).toContain(
'<meta property="og:image" content="ogImageTag1" class="next-head"/>'
'<meta property="og:image:alt" content="ogImageAltTag2"/>'
)
expect(html).toContain(
'<meta property="og:image" content="ogImageTag2" class="next-head"/>'
'<meta property="og:image:width" content="ogImageWidthTag1"/>'
)
expect(html).toContain(
'<meta property="og:image:alt" content="ogImageAltTag1" class="next-head"/>'
'<meta property="og:image:width" content="ogImageWidthTag2"/>'
)
expect(html).toContain(
'<meta property="og:image:alt" content="ogImageAltTag2" class="next-head"/>'
'<meta property="og:image:height" content="ogImageHeightTag1"/>'
)
expect(html).toContain(
'<meta property="og:image:width" content="ogImageWidthTag1" class="next-head"/>'
'<meta property="og:image:height" content="ogImageHeightTag2"/>'
)
expect(html).toContain(
'<meta property="og:image:width" content="ogImageWidthTag2" class="next-head"/>'
'<meta property="og:image:type" content="ogImageTypeTag1"/>'
)
expect(html).toContain(
'<meta property="og:image:height" content="ogImageHeightTag1" class="next-head"/>'
'<meta property="og:image:type" content="ogImageTypeTag2"/>'
)
expect(html).toContain(
'<meta property="og:image:height" content="ogImageHeightTag2" class="next-head"/>'
'<meta property="og:image:secure_url" content="ogImageSecureUrlTag1"/>'
)
expect(html).toContain(
'<meta property="og:image:type" content="ogImageTypeTag1" class="next-head"/>'
'<meta property="og:image:secure_url" content="ogImageSecureUrlTag2"/>'
)
expect(html).toContain(
'<meta property="og:image:type" content="ogImageTypeTag2" class="next-head"/>'
)
expect(html).toContain(
'<meta property="og:image:secure_url" content="ogImageSecureUrlTag1" class="next-head"/>'
)
expect(html).toContain(
'<meta property="og:image:secure_url" content="ogImageSecureUrlTag2" class="next-head"/>'
)
expect(html).toContain(
'<meta property="og:image:url" content="ogImageUrlTag1" class="next-head"/>'
)
expect(html).toContain(
'<meta property="fb:pages" content="fbpages1" class="next-head"/>'
)
expect(html).toContain(
'<meta property="fb:pages" content="fbpages2" class="next-head"/>'
'<meta property="og:image:url" content="ogImageUrlTag1"/>'
)
expect(html).toContain('<meta property="fb:pages" content="fbpages1"/>')
expect(html).toContain('<meta property="fb:pages" content="fbpages2"/>')
})
test('header helper renders Fragment children', async () => {
const html = await render('/head')
expect(html).toContain('<title>Fragment title</title>')
expect(html).toContain(
'<meta content="meta fragment" class="next-head"/>'
)
expect(html).toContain('<meta content="meta fragment"/>')
})
it('should render the page with custom extension', async () => {