Repository: vercel/next.js
Pull request: [metadata] replace for initial body icon case

Follow up on #76915, when there's icon showing up in the initial chunk during streaming, and they're located after the `<head>` tag, we need to insert the icon hoist script to ensure they're hoisted in the `<head>` in browser.

Closes NEXT-4619

Changed files:
filename: packages/next/src/server/stream-utils/node-web-streams-helper.ts
patch: @@ -263,21 +263,39 @@ function createMetadataTransformStream(
       // Check if icon mark is inside <head> tag in the first chunk.
       if (chunkIndex === 0) {
         closedHeadIndex = indexOfUint8Array(chunk, ENCODED_TAGS.CLOSED.HEAD)
-        // The mark icon is located in the 1st chunk before the head tag.
-        // We do not need to insert the script tag in this case because it's in the head.
-        // Just remove the icon mark from the chunk.
-        if (iconMarkIndex < closedHeadIndex && iconMarkIndex !== -1) {
-          const replaced = new Uint8Array(chunk.length - iconMarkLength)
-
-          // Remove the icon mark from the chunk.
-          replaced.set(chunk.subarray(0, iconMarkIndex))
-          replaced.set(
-            chunk.subarray(iconMarkIndex + iconMarkLength),
-            iconMarkIndex
-          )
-          chunk = replaced
+        if (iconMarkIndex !== -1) {
+          // The mark icon is located in the 1st chunk before the head tag.
+          // We do not need to insert the script tag in this case because it's in the head.
+          // Just remove the icon mark from the chunk.
+          if (iconMarkIndex < closedHeadIndex) {
+            const replaced = new Uint8Array(chunk.length - iconMarkLength)
+
+            // Remove the icon mark from the chunk.
+            replaced.set(chunk.subarray(0, iconMarkIndex))
+            replaced.set(
+              chunk.subarray(iconMarkIndex + iconMarkLength),
+              iconMarkIndex
+            )
+            chunk = replaced
+          } else {
+            // The icon mark is after the head tag, replace and insert the script tag at that position.
+            const insertion = await insert()
+            const encodedInsertion = encoder.encode(insertion)
+            const insertionLength = encodedInsertion.length
+            const replaced = new Uint8Array(
+              chunk.length - iconMarkLength + insertionLength
+            )
+            replaced.set(chunk.subarray(0, iconMarkIndex))
+            replaced.set(encodedInsertion, iconMarkIndex)
+            replaced.set(
+              chunk.subarray(iconMarkIndex + iconMarkLength),
+              iconMarkIndex + insertionLength
+            )
+            chunk = replaced
+          }
           isMarkRemoved = true
         }
+        // If there's no icon mark located, it will be handled later when if present in the following chunks.
       } else {
         // When it's appeared in the following chunks, we'll need to
         // remove the mark and then insert the script tag at that position.

filename: test/e2e/app-dir/metadata-icons/app/custom-icon/delay-icons/page.tsx
patch: @@ -0,0 +1,28 @@
+import Link from 'next/link'
+import { connection } from 'next/server'
+
+export default async function Page() {
+  return (
+    <div>
+      <h1>Delay Icons</h1>
+      <Link id="custom-icon-sub-link" href="/custom-icon/sub">
+        Go to another page with custom icon
+      </Link>
+      <br />
+      <Link id="custom-icon-sub-link" href="/custom-icon">
+        Go to root page with custom icon
+      </Link>
+    </div>
+  )
+}
+
+export async function generateMetadata() {
+  await connection()
+  return {
+    // This long text description will lead to the metadata being inserted after the head tag.
+    description: 'long text description'.repeat(1000),
+    icons: {
+      icon: `/heart.png`,
+    },
+  }
+}

filename: test/e2e/app-dir/metadata-icons/metadata-icons.test.ts
patch: @@ -1,6 +1,8 @@
 import { nextTestSetup } from 'e2e-utils'
 import { retry } from 'next-test-utils'
 
+const iconInsertionScript = `document.querySelectorAll('body link[rel="icon"], body link[rel="apple-touch-icon"]').forEach(el => document.head.appendChild(el))`
+
 describe('app-dir - metadata-icons', () => {
   const { next } = nextTestSetup({
     files: __dirname,
@@ -45,15 +47,13 @@ describe('app-dir - metadata-icons', () => {
   })
 
   it('should not contain icon insertion script when metadata is rendered in head', async () => {
-    const iconInsertionScript = `document.querySelectorAll('body link[rel="icon"], body link[rel="apple-touch-icon"]').forEach(el => document.head.appendChild(el))`
-
     const suspendedHtml = await next.render('/custom-icon')
     expect(suspendedHtml).toContain(iconInsertionScript)
   })
 
   it('should not contain icon replacement mark in html or after hydration', async () => {
     const html = await next.render('/custom-icon')
-    expect(html).not.toContain('<meta name="«nxt-icon»" content=""/>')
+    expect(html).not.toContain('<meta name="«nxt-icon»">')
     expect(html).not.toContain('«nxt-icon»')
 
     const browser = await next.browser('/custom-icon')
@@ -112,4 +112,32 @@ describe('app-dir - metadata-icons', () => {
       ])
     })
   })
+
+  it('should re-insert the icons into head when icons are inserted in body during initial chunk', async () => {
+    const $ = await next.render$('/custom-icon/delay-icons')
+    expect($('meta[name="«nxt-icon»"]').length).toBe(0)
+
+    // body will contain the icons and the script to insert them into head
+    const body = $('body')
+    const icons = body.find('link[rel="icon"]')
+    expect(icons.length).toBe(2)
+    expect(Array.from(icons.map((_, el) => $(el).attr('href')))).toContain(
+      '/heart.png'
+    )
+
+    const bodyHtml = body.html()
+    expect(bodyHtml).toContain(iconInsertionScript)
+
+    // icons should be inserted into head after hydration
+    const browser = await next.browser('/custom-icon/delay-icons')
+    await retry(async () => {
+      const iconsInHead = await browser.elementsByCss('head link[rel="icon"]')
+      const iconUrls = await Promise.all(
+        iconsInHead.map(
+          async (el) => (await el.getAttribute('href')).split('?')[0]
+        )
+      )
+      expect(iconUrls).toEqual(['/favicon.ico', '/heart.png'])
+    })
+  })
 })