diff --git a/packages/next/src/client/head-manager.ts b/packages/next/src/client/head-manager.ts index 8d477e46e9fb4..052b251d36d58 100644 --- a/packages/next/src/client/head-manager.ts +++ b/packages/next/src/client/head-manager.ts @@ -51,62 +51,56 @@ 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[]) => { + updateElements = (type, components) => { const headEl = document.querySelector('head') if (!headEl) return - const headMetaTags = headEl.querySelectorAll('meta[name="next-head"]') || [] - const oldTags: Element[] = [] + const oldTags = new Set(headEl.querySelectorAll(`${type}[data-next-head]`)) if (type === 'meta') { const metaCharset = headEl.querySelector('meta[charset]') - if (metaCharset) { - oldTags.push(metaCharset) + 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 + ) { + headEl.prepend(newTag) } - headEl.appendChild(t) - }) + headEl.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]' diff --git a/packages/next/src/pages/_document.tsx b/packages/next/src/pages/_document.tsx index 792c45f0c2d29..99f89cd208cff 100644 --- a/packages/next/src/pages/_document.tsx +++ b/packages/next/src/pages/_document.tsx @@ -682,30 +682,29 @@ export class Head extends React.Component { let cssPreloads: Array = [] let otherHeadElements: Array = [] 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) } } }) diff --git a/test/development/pages-dir/client-navigation/index.test.ts b/test/development/pages-dir/client-navigation/index.test.ts index d684bffa3a615..a6ba555d2924f 100644 --- a/test/development/pages-dir/client-navigation/index.test.ts +++ b/test/development/pages-dir/client-navigation/index.test.ts @@ -1683,6 +1683,9 @@ 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) @@ -1690,6 +1693,9 @@ 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) } finally { if (browser) { await browser.close() diff --git a/test/development/pages-dir/client-navigation/rendering.test.ts b/test/development/pages-dir/client-navigation/rendering.test.ts index 2fd1dcb096bca..869327510d4fa 100644 --- a/test/development/pages-dir/client-navigation/rendering.test.ts +++ b/test/development/pages-dir/client-navigation/rendering.test.ts @@ -43,8 +43,8 @@ describe('Client Navigation rendering', () => { describe('Rendering via HTTP', () => { test('renders a stateless component', async () => { const html = await render('/stateless') - expect(html.includes('')).toBeTruthy() - expect(html.includes('My component!')).toBeTruthy() + expect(html).toContain('') + expect(html).toContain('My component!') }) it('should should not contain scripts that are not js', async () => { @@ -349,55 +349,93 @@ describe.each([[false], [true]])( // default-head contains an empty . test('header renders default charset', async () => { const html = await render('/default-head') - expect(html.includes('')).toBeTruthy() - expect(html.includes('next-head, but only once.')).toBeTruthy() + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) }) test('header renders default viewport', async () => { const html = await render('/default-head') expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) }) test('header helper renders header information', async () => { const html = await render('/head') - expect(html.includes('')).toBeTruthy() - expect(html.includes('')).toBeTruthy() expect(html).toContain( - '' + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' ) - expect(html.includes('I can have meta tags')).toBeTruthy() + expect(html).toContain('I can have meta tags') }) test('header helper dedupes tags', async () => { const html = await render('/head') - expect(html).toContain('') - expect(html).not.toContain('') expect(html).toContain( - '' + strictNextHead + ? '' + : '' + ) + expect(html).not.toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' ) // Should contain only one viewport expect(html.match(/' + '' + : '' ) - expect(html).toContain('') expect(html).toContain( strictNextHead - ? '' + ? '' : '' ) - const dedupeLink = '' + const dedupeLink = strictNextHead + ? '' + : '' expect(html).toContain(dedupeLink) expect( html.substring(html.indexOf(dedupeLink) + dedupeLink.length) - ).not.toContain('') + ).not.toContain('' + strictNextHead + ? '' + : '' ) expect(html).not.toContain( - '' + '') - expect(html).toContain('') + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) }) test('header helper avoids dedupe of specific tags', async () => { const html = await render('/head') - expect(html).toContain('') - expect(html).toContain('') - expect(html).not.toContain('') - expect(html).toContain('') + console.log(html) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).not.toContain('' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) - expect(html).toContain('') - expect(html).toContain('') }) test('header helper avoids dedupe of meta tags with the same name if they use unique keys', async () => { const html = await render('/head') expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) expect(html).toContain( - '' + strictNextHead + ? '' + : '' ) }) test('header helper renders Fragment children', async () => { const html = await render('/head') - expect(html).toContain('Fragment title') - expect(html).toContain('') + expect(html).toContain( + strictNextHead + ? 'Fragment title' + : 'Fragment title' + ) + expect(html).toContain( + strictNextHead + ? '' + : '' + ) }) test('header helper renders boolean attributes correctly children', async () => { const html = await render('/head') - expect(html).toContain('