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:
parent
3ee5ec4412
commit
e68307df3a
5 changed files with 64 additions and 77 deletions
|
@ -8,15 +8,14 @@ type WithInAmpMode = {
|
||||||
inAmpMode?: boolean
|
inAmpMode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultHead(className = 'next-head', inAmpMode = false) {
|
export function defaultHead(inAmpMode = false) {
|
||||||
const head = [<meta key="charSet" charSet="utf-8" className={className} />]
|
const head = [<meta key="charSet" charSet="utf-8" />]
|
||||||
if (!inAmpMode) {
|
if (!inAmpMode) {
|
||||||
head.push(
|
head.push(
|
||||||
<meta
|
<meta
|
||||||
key="viewport"
|
key="viewport"
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width,minimum-scale=1,initial-scale=1"
|
content="width=device-width,minimum-scale=1,initial-scale=1"
|
||||||
className={className}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -121,19 +120,12 @@ function reduceComponents(
|
||||||
)
|
)
|
||||||
.reduce(onlyReactElement, [])
|
.reduce(onlyReactElement, [])
|
||||||
.reverse()
|
.reverse()
|
||||||
.concat(defaultHead('', props.inAmpMode))
|
.concat(defaultHead(props.inAmpMode))
|
||||||
.filter(unique())
|
.filter(unique())
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((c: React.ReactElement<any>, i: number) => {
|
.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
|
const key = c.key || i
|
||||||
return React.cloneElement(c, { key, className })
|
return React.cloneElement(c, { key })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -116,7 +116,7 @@ function render(
|
||||||
try {
|
try {
|
||||||
html = renderElementToString(element)
|
html = renderElementToString(element)
|
||||||
} finally {
|
} finally {
|
||||||
head = Head.rewind() || defaultHead(undefined, isInAmpMode(ampMode))
|
head = Head.rewind() || defaultHead(isInAmpMode(ampMode))
|
||||||
}
|
}
|
||||||
|
|
||||||
return { html, head }
|
return { html, head }
|
||||||
|
|
|
@ -46,14 +46,24 @@ export default class HeadManager {
|
||||||
|
|
||||||
updateElements (type, components) {
|
updateElements (type, components) {
|
||||||
const headEl = document.getElementsByTagName('head')[0]
|
const headEl = document.getElementsByTagName('head')[0]
|
||||||
const oldTags = Array.prototype.slice.call(
|
const headCountEl = headEl.querySelector('meta[name=next-head-count]')
|
||||||
headEl.querySelectorAll(type + '.next-head')
|
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 => {
|
const newTags = components.map(reactElementToDOM).filter(newTag => {
|
||||||
for (let i = 0, len = oldTags.length; i < len; i++) {
|
for (let k = 0, len = oldTags.length; k < len; k++) {
|
||||||
const oldTag = oldTags[i]
|
const oldTag = oldTags[k]
|
||||||
if (oldTag.isEqualNode(newTag)) {
|
if (oldTag.isEqualNode(newTag)) {
|
||||||
oldTags.splice(i, 1)
|
oldTags.splice(k, 1)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,7 +71,12 @@ export default class HeadManager {
|
||||||
})
|
})
|
||||||
|
|
||||||
oldTags.forEach(t => t.parentNode.removeChild(t))
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -331,6 +331,10 @@ export class Head extends Component<
|
||||||
<head {...this.props}>
|
<head {...this.props}>
|
||||||
{children}
|
{children}
|
||||||
{head}
|
{head}
|
||||||
|
<meta
|
||||||
|
name="next-head-count"
|
||||||
|
content={React.Children.count(head || []).toString()}
|
||||||
|
/>
|
||||||
{inAmpMode && (
|
{inAmpMode && (
|
||||||
<>
|
<>
|
||||||
<meta
|
<meta
|
||||||
|
|
|
@ -13,9 +13,7 @@ export default function (render, fetch) {
|
||||||
describe('Rendering via HTTP', () => {
|
describe('Rendering via HTTP', () => {
|
||||||
test('renders a stateless component', async () => {
|
test('renders a stateless component', async () => {
|
||||||
const html = await render('/stateless')
|
const html = await render('/stateless')
|
||||||
expect(
|
expect(html.includes('<meta charSet="utf-8"/>')).toBeTruthy()
|
||||||
html.includes('<meta charSet="utf-8" class="next-head"/>')
|
|
||||||
).toBeTruthy()
|
|
||||||
expect(html.includes('My component!')).toBeTruthy()
|
expect(html.includes('My component!')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -39,39 +37,33 @@ export default function (render, fetch) {
|
||||||
// default-head contains an empty <Head />.
|
// default-head contains an empty <Head />.
|
||||||
test('header renders default charset', async () => {
|
test('header renders default charset', async () => {
|
||||||
const html = await render('/default-head')
|
const html = await render('/default-head')
|
||||||
expect(
|
expect(html.includes('<meta charSet="utf-8"/>')).toBeTruthy()
|
||||||
html.includes('<meta charSet="utf-8" class="next-head"/>')
|
|
||||||
).toBeTruthy()
|
|
||||||
expect(html.includes('next-head, but only once.')).toBeTruthy()
|
expect(html.includes('next-head, but only once.')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('header renders default viewport', async () => {
|
test('header renders default viewport', async () => {
|
||||||
const html = await render('/default-head')
|
const html = await render('/default-head')
|
||||||
expect(html).toContain(
|
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 () => {
|
test('header helper renders header information', async () => {
|
||||||
const html = await render('/head')
|
const html = await render('/head')
|
||||||
expect(
|
expect(html.includes('<meta charSet="iso-8859-5"/>')).toBeTruthy()
|
||||||
html.includes('<meta charSet="iso-8859-5" class="next-head"/>')
|
expect(html.includes('<meta content="my meta"/>')).toBeTruthy()
|
||||||
).toBeTruthy()
|
|
||||||
expect(
|
|
||||||
html.includes('<meta content="my meta" class="next-head"/>')
|
|
||||||
).toBeTruthy()
|
|
||||||
expect(html).toContain(
|
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()
|
expect(html.includes('I can have meta tags')).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('header helper dedupes tags', async () => {
|
test('header helper dedupes tags', async () => {
|
||||||
const html = await render('/head')
|
const html = await render('/head')
|
||||||
expect(html).toContain('<meta charSet="iso-8859-5" class="next-head"/>')
|
expect(html).toContain('<meta charSet="iso-8859-5"/>')
|
||||||
expect(html).not.toContain('<meta charSet="utf-8" class="next-head"/>')
|
expect(html).not.toContain('<meta charSet="utf-8"/>')
|
||||||
expect(html).toContain(
|
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(
|
expect(html.match(/<meta name="viewport" /g).length).toBe(
|
||||||
1,
|
1,
|
||||||
|
@ -80,85 +72,69 @@ export default function (render, fetch) {
|
||||||
expect(html).not.toContain(
|
expect(html).not.toContain(
|
||||||
'<meta name="viewport" content="width=device-width"/>'
|
'<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(
|
expect(html).toContain(
|
||||||
'<link rel="stylesheet" href="/dup-style.css" class="next-head"/><link rel="stylesheet" href="/dup-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" class="next-head"/>'
|
|
||||||
)
|
)
|
||||||
|
expect(html).toContain('<link rel="stylesheet" href="dedupe-style.css"/>')
|
||||||
expect(html).not.toContain(
|
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 () => {
|
test('header helper avoids dedupe of specific tags', async () => {
|
||||||
const html = await render('/head')
|
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(
|
expect(html).toContain(
|
||||||
'<meta property="article:tag" content="tag1" class="next-head"/>'
|
'<meta property="og:image" content="ogImageTag1"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="article:tag" content="tag2" class="next-head"/>'
|
'<meta property="og:image" content="ogImageTag2"/>'
|
||||||
)
|
|
||||||
expect(html).not.toContain(
|
|
||||||
'<meta property="dedupe:tag" content="tag3" class="next-head"/>'
|
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="dedupe:tag" content="tag4" class="next-head"/>'
|
'<meta property="og:image:alt" content="ogImageAltTag1"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image" content="ogImageTag1" class="next-head"/>'
|
'<meta property="og:image:alt" content="ogImageAltTag2"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image" content="ogImageTag2" class="next-head"/>'
|
'<meta property="og:image:width" content="ogImageWidthTag1"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:alt" content="ogImageAltTag1" class="next-head"/>'
|
'<meta property="og:image:width" content="ogImageWidthTag2"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:alt" content="ogImageAltTag2" class="next-head"/>'
|
'<meta property="og:image:height" content="ogImageHeightTag1"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:width" content="ogImageWidthTag1" class="next-head"/>'
|
'<meta property="og:image:height" content="ogImageHeightTag2"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:width" content="ogImageWidthTag2" class="next-head"/>'
|
'<meta property="og:image:type" content="ogImageTypeTag1"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:height" content="ogImageHeightTag1" class="next-head"/>'
|
'<meta property="og:image:type" content="ogImageTypeTag2"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:height" content="ogImageHeightTag2" class="next-head"/>'
|
'<meta property="og:image:secure_url" content="ogImageSecureUrlTag1"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:type" content="ogImageTypeTag1" class="next-head"/>'
|
'<meta property="og:image:secure_url" content="ogImageSecureUrlTag2"/>'
|
||||||
)
|
)
|
||||||
expect(html).toContain(
|
expect(html).toContain(
|
||||||
'<meta property="og:image:type" content="ogImageTypeTag2" class="next-head"/>'
|
'<meta property="og:image:url" content="ogImageUrlTag1"/>'
|
||||||
)
|
|
||||||
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"/>'
|
|
||||||
)
|
)
|
||||||
|
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 () => {
|
test('header helper renders Fragment children', async () => {
|
||||||
const html = await render('/head')
|
const html = await render('/head')
|
||||||
expect(html).toContain('<title>Fragment title</title>')
|
expect(html).toContain('<title>Fragment title</title>')
|
||||||
expect(html).toContain(
|
expect(html).toContain('<meta content="meta fragment"/>')
|
||||||
'<meta content="meta fragment" class="next-head"/>'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the page with custom extension', async () => {
|
it('should render the page with custom extension', async () => {
|
||||||
|
|
Loading…
Reference in a new issue