Skip to content

Commit 88deb12

Browse files
committed
[metadata] re-insert icons to head for streamed metadata (#76915)
### What Re-insert the metadata icons under body tag into head tag to ensure they can be proper displayed. ### Why * For chromium based browsers (Chrome, Edge, etc.) and Safari, icons need to stay under `<head>` to be picked up by the browser. Firefox doesn't have this requirement. * Firefox will always render svg > icon > png, so it doesn't matter to it. Made a test in https://icons-order-test.vercel.app/, you can play with the test cases here. These links can see the comparsion of the approach in the this PR. During deployment it's very hard to verify Safari as it's pretty broken. But deploymet works well. After we re-insert the icon from body into head, they can be properly displayed across all the browsers. Note: We insert the script during streaming rather than using a jsx element right after metadata to avoid the hydration errors Closes #76810 Closes NDX-951
1 parent f455282 commit 88deb12

File tree

10 files changed

+138
-12
lines changed

10 files changed

+138
-12
lines changed

packages/next/src/lib/metadata/generate/icons.tsx

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ResolvedMetadata } from '../types/metadata-interface'
22
import type { Icon, IconDescriptor } from '../types/metadata-types'
33

4-
import React from 'react'
54
import { MetaFilter } from './meta'
65

76
function IconDescriptorLink({ icon }: { icon: IconDescriptor }) {

packages/next/src/server/app-render/app-render.tsx

+8-7
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
317317
}
318318

319319
function createDivergedMetadataComponents(
320-
Metadata: React.ComponentType<{}>,
320+
Metadata: React.ComponentType,
321321
serveStreamingMetadata: boolean
322322
): {
323323
StaticMetadata: React.ComponentType<{}>
@@ -326,8 +326,9 @@ function createDivergedMetadataComponents(
326326
function EmptyMetadata() {
327327
return null
328328
}
329-
const StreamingMetadata: React.ComponentType<{}> | null =
330-
serveStreamingMetadata ? Metadata : null
329+
const StreamingMetadata: React.ComponentType | null = serveStreamingMetadata
330+
? Metadata
331+
: null
331332

332333
const StaticMetadata: React.ComponentType<{}> = serveStreamingMetadata
333334
? EmptyMetadata
@@ -1711,7 +1712,7 @@ async function renderToStream(
17111712
const { ServerInsertedHTMLProvider, renderServerInsertedHTML } =
17121713
createServerInsertedHTML()
17131714
const { ServerInsertedMetadataProvider, getServerInsertedMetadata } =
1714-
createServerInsertedMetadata()
1715+
createServerInsertedMetadata(ctx.nonce)
17151716

17161717
const tracingMetadata = getTracedMetadata(
17171718
getTracer().getTracePropagationData(),
@@ -2299,9 +2300,9 @@ async function spawnDynamicValidationInDev(
22992300
}
23002301
}
23012302

2302-
const { ServerInsertedHTMLProvider } = createServerInsertedHTML()
2303-
const { ServerInsertedMetadataProvider } = createServerInsertedMetadata()
23042303
const nonce = '1'
2304+
const { ServerInsertedHTMLProvider } = createServerInsertedHTML()
2305+
const { ServerInsertedMetadataProvider } = createServerInsertedMetadata(nonce)
23052306

23062307
if (initialServerStream) {
23072308
const [warmupStream, renderStream] = initialServerStream.tee()
@@ -2584,7 +2585,7 @@ async function prerenderToStream(
25842585
const { ServerInsertedHTMLProvider, renderServerInsertedHTML } =
25852586
createServerInsertedHTML()
25862587
const { ServerInsertedMetadataProvider, getServerInsertedMetadata } =
2587-
createServerInsertedMetadata()
2588+
createServerInsertedMetadata(ctx.nonce)
25882589

25892590
const tracingMetadata = getTracedMetadata(
25902591
getTracer().getTracePropagationData(),

packages/next/src/server/app-render/create-component-tree.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export function createComponentTree(props: {
4040
missingSlots?: Set<string>
4141
preloadCallbacks: PreloadCallbacks
4242
authInterrupts: boolean
43-
StreamingMetadata: React.ComponentType<{}> | null
43+
StreamingMetadata: React.ComponentType | null
4444
StreamingMetadataOutlet: React.ComponentType
4545
}): Promise<CacheNodeSeedData> {
4646
return getTracer().trace(
@@ -92,7 +92,7 @@ async function createComponentTreeInternal({
9292
missingSlots?: Set<string>
9393
preloadCallbacks: PreloadCallbacks
9494
authInterrupts: boolean
95-
StreamingMetadata: React.ComponentType<{}> | null
95+
StreamingMetadata: React.ComponentType | null
9696
StreamingMetadataOutlet: React.ComponentType | null
9797
}): Promise<CacheNodeSeedData> {
9898
const {

packages/next/src/server/app-render/metadata-insertion/create-server-inserted-metadata.tsx

+15-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@ import {
66
} from '../../../shared/lib/server-inserted-metadata.shared-runtime'
77
import { renderToString } from '../render-to-string'
88

9-
export function createServerInsertedMetadata() {
9+
/**
10+
* For chromium based browsers (Chrome, Edge, etc.) and Safari,
11+
* icons need to stay under <head> to be picked up by the browser.
12+
*
13+
*/
14+
const REINSERT_ICON_SCRIPT = `\
15+
document.querySelectorAll('body link[rel="icon"], body link[rel="apple-touch-icon"]').forEach(el => document.head.appendChild(el))`
16+
17+
export function createServerInsertedMetadata(nonce: string | undefined) {
1018
let metadataResolver: MetadataResolver | null = null
1119
let metadataToFlush: React.ReactNode = null
1220
const setMetadataResolver = (resolver: MetadataResolver): void => {
@@ -34,7 +42,12 @@ export function createServerInsertedMetadata() {
3442
metadataToFlush = metadataResolver()
3543
const html = await renderToString({
3644
renderToReadableStream,
37-
element: <>{metadataToFlush}</>,
45+
element: (
46+
<>
47+
{metadataToFlush}
48+
<script nonce={nonce}>{REINSERT_ICON_SCRIPT}</script>
49+
</>
50+
),
3851
})
3952

4053
return html
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Metadata } from 'next'
2+
import Link from 'next/link'
3+
import { connection } from 'next/server'
4+
5+
export default function Page() {
6+
return (
7+
<>
8+
<Link id="custom-icon-sub-link" href="/custom-icon/sub">
9+
Go to another page with custom icon
10+
</Link>
11+
</>
12+
)
13+
}
14+
15+
export async function generateMetadata(): Promise<Metadata> {
16+
await connection()
17+
await new Promise((resolve) => setTimeout(resolve, 300))
18+
19+
return {
20+
icons: {
21+
// add version query to avoid caching on client side with multiple navs
22+
icon: `/heart.png?v=${Math.round(Math.random() * 1000)}`,
23+
},
24+
}
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { type Metadata } from 'next'
2+
import Link from 'next/link'
3+
import { connection } from 'next/server'
4+
5+
export async function generateMetadata(): Promise<Metadata> {
6+
await connection()
7+
await new Promise((resolve) => setTimeout(resolve, 300))
8+
9+
return {
10+
icons: {
11+
// add version query to avoid caching on client side with multiple navs
12+
icon: `/star.png?v=${Math.round(Math.random() * 1000)}`,
13+
},
14+
}
15+
}
16+
17+
export default function Page() {
18+
return (
19+
<>
20+
<Link id="custom-icon-link" href="/custom-icon">
21+
Back to previous page with custom icon
22+
</Link>
23+
</>
24+
)
25+
}

test/e2e/app-dir/metadata-icons/metadata-icons.test.ts

+63
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { nextTestSetup } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
23

34
describe('app-dir - metadata-icons', () => {
45
const { next } = nextTestSetup({
@@ -28,4 +29,66 @@ describe('app-dir - metadata-icons', () => {
2829
'/shortcut-icon-nested.png'
2930
)
3031
})
32+
33+
it('should re-insert the body icons into the head', async () => {
34+
const browser = await next.browser('/custom-icon')
35+
36+
await retry(async () => {
37+
const iconsInBody = await browser.elementsByCss('body link[rel="icon"]')
38+
const iconsInHead = await browser.elementsByCss('head link[rel="icon"]')
39+
40+
// moved to head
41+
expect(iconsInBody.length).toBe(0)
42+
// re-inserted favicon.ico + /heart.png
43+
expect(iconsInHead.length).toBe(2)
44+
})
45+
})
46+
47+
it('should re-insert the apple icons into the head after navigation', async () => {
48+
const browser = await next.browser('/custom-icon')
49+
await browser.elementByCss('#custom-icon-sub-link').click()
50+
51+
await retry(async () => {
52+
const url = await browser.url()
53+
expect(url).toMatch(/\/custom-icon\/sub$/)
54+
})
55+
56+
await retry(async () => {
57+
const iconsInHead = await browser.elementsByCss('head link[rel="icon"]')
58+
let iconUrls = await Promise.all(
59+
iconsInHead.map(
60+
async (el) => (await el.getAttribute('href')).split('?')[0]
61+
)
62+
)
63+
// Pick last 2 icons
64+
// In non-headless mode, the icons are deduped;
65+
// In headless mode, the icons are not deduped
66+
expect(iconUrls.length === 4 ? iconUrls.slice(2) : iconUrls).toEqual([
67+
'/favicon.ico',
68+
'/star.png',
69+
])
70+
})
71+
72+
// navigate back
73+
await browser.elementByCss('#custom-icon-link').click()
74+
await retry(async () => {
75+
const url = await browser.url()
76+
expect(url).toMatch(/\/custom-icon$/)
77+
})
78+
79+
await retry(async () => {
80+
const icons = await browser.elementsByCss('head link[rel="icon"]')
81+
const iconUrls = await Promise.all(
82+
icons.map(async (el) => (await el.getAttribute('href')).split('?')[0])
83+
)
84+
85+
// Pick last 2 icons
86+
// In non-headless mode, the icons are deduped;
87+
// In headless mode, the icons are not deduped
88+
expect(iconUrls.length === 4 ? iconUrls.slice(2) : iconUrls).toEqual([
89+
'/favicon.ico',
90+
'/heart.png',
91+
])
92+
})
93+
})
3194
})
873 Bytes
Loading
827 Bytes
Loading

0 commit comments

Comments
 (0)