Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[metadata] re-insert icons to head for streamed metadata #76915

Merged
merged 5 commits into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/next/src/lib/metadata/generate/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { ResolvedMetadata } from '../types/metadata-interface'
import type { Icon, IconDescriptor } from '../types/metadata-types'

import React from 'react'
import { MetaFilter } from './meta'

function IconDescriptorLink({ icon }: { icon: IconDescriptor }) {
Expand Down
15 changes: 8 additions & 7 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
}

function createDivergedMetadataComponents(
Metadata: React.ComponentType<{}>,
Metadata: React.ComponentType,
serveStreamingMetadata: boolean
): {
StaticMetadata: React.ComponentType<{}>
Expand All @@ -326,8 +326,9 @@ function createDivergedMetadataComponents(
function EmptyMetadata() {
return null
}
const StreamingMetadata: React.ComponentType<{}> | null =
serveStreamingMetadata ? Metadata : null
const StreamingMetadata: React.ComponentType | null = serveStreamingMetadata
? Metadata
: null

const StaticMetadata: React.ComponentType<{}> = serveStreamingMetadata
? EmptyMetadata
Expand Down Expand Up @@ -1711,7 +1712,7 @@ async function renderToStream(
const { ServerInsertedHTMLProvider, renderServerInsertedHTML } =
createServerInsertedHTML()
const { ServerInsertedMetadataProvider, getServerInsertedMetadata } =
createServerInsertedMetadata()
createServerInsertedMetadata(ctx.nonce)

const tracingMetadata = getTracedMetadata(
getTracer().getTracePropagationData(),
Expand Down Expand Up @@ -2299,9 +2300,9 @@ async function spawnDynamicValidationInDev(
}
}

const { ServerInsertedHTMLProvider } = createServerInsertedHTML()
const { ServerInsertedMetadataProvider } = createServerInsertedMetadata()
const nonce = '1'
const { ServerInsertedHTMLProvider } = createServerInsertedHTML()
const { ServerInsertedMetadataProvider } = createServerInsertedMetadata(nonce)

if (initialServerStream) {
const [warmupStream, renderStream] = initialServerStream.tee()
Expand Down Expand Up @@ -2584,7 +2585,7 @@ async function prerenderToStream(
const { ServerInsertedHTMLProvider, renderServerInsertedHTML } =
createServerInsertedHTML()
const { ServerInsertedMetadataProvider, getServerInsertedMetadata } =
createServerInsertedMetadata()
createServerInsertedMetadata(ctx.nonce)

const tracingMetadata = getTracedMetadata(
getTracer().getTracePropagationData(),
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/server/app-render/create-component-tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function createComponentTree(props: {
missingSlots?: Set<string>
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
StreamingMetadata: React.ComponentType<{}> | null
StreamingMetadata: React.ComponentType | null
StreamingMetadataOutlet: React.ComponentType
}): Promise<CacheNodeSeedData> {
return getTracer().trace(
Expand Down Expand Up @@ -92,7 +92,7 @@ async function createComponentTreeInternal({
missingSlots?: Set<string>
preloadCallbacks: PreloadCallbacks
authInterrupts: boolean
StreamingMetadata: React.ComponentType<{}> | null
StreamingMetadata: React.ComponentType | null
StreamingMetadataOutlet: React.ComponentType | null
}): Promise<CacheNodeSeedData> {
const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import {
} from '../../../shared/lib/server-inserted-metadata.shared-runtime'
import { renderToString } from '../render-to-string'

export function createServerInsertedMetadata() {
/**
* For chromium based browsers (Chrome, Edge, etc.) and Safari,
* icons need to stay under <head> to be picked up by the browser.
*
*/
const REINSERT_ICON_SCRIPT = `\
document.querySelectorAll('body link[rel="icon"], body link[rel="apple-touch-icon"]').forEach(el => document.head.appendChild(el))`

export function createServerInsertedMetadata(nonce: string | undefined) {
let metadataResolver: MetadataResolver | null = null
let metadataToFlush: React.ReactNode = null
const setMetadataResolver = (resolver: MetadataResolver): void => {
Expand Down Expand Up @@ -34,7 +42,12 @@ export function createServerInsertedMetadata() {
metadataToFlush = metadataResolver()
const html = await renderToString({
renderToReadableStream,
element: <>{metadataToFlush}</>,
element: (
<>
{metadataToFlush}
<script nonce={nonce}>{REINSERT_ICON_SCRIPT}</script>
</>
),
})

return html
Expand Down
25 changes: 25 additions & 0 deletions test/e2e/app-dir/metadata-icons/app/custom-icon/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Metadata } from 'next'
import Link from 'next/link'
import { connection } from 'next/server'

export default function Page() {
return (
<>
<Link id="custom-icon-sub-link" href="/custom-icon/sub">
Go to another page with custom icon
</Link>
</>
)
}

export async function generateMetadata(): Promise<Metadata> {
await connection()
await new Promise((resolve) => setTimeout(resolve, 300))

return {
icons: {
// add version query to avoid caching on client side with multiple navs
icon: `/heart.png?v=${Math.round(Math.random() * 1000)}`,
},
}
}
25 changes: 25 additions & 0 deletions test/e2e/app-dir/metadata-icons/app/custom-icon/sub/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type Metadata } from 'next'
import Link from 'next/link'
import { connection } from 'next/server'

export async function generateMetadata(): Promise<Metadata> {
await connection()
await new Promise((resolve) => setTimeout(resolve, 300))

return {
icons: {
// add version query to avoid caching on client side with multiple navs
icon: `/star.png?v=${Math.round(Math.random() * 1000)}`,
},
}
}

export default function Page() {
return (
<>
<Link id="custom-icon-link" href="/custom-icon">
Back to previous page with custom icon
</Link>
</>
)
}
63 changes: 63 additions & 0 deletions test/e2e/app-dir/metadata-icons/metadata-icons.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'

describe('app-dir - metadata-icons', () => {
const { next } = nextTestSetup({
Expand Down Expand Up @@ -28,4 +29,66 @@ describe('app-dir - metadata-icons', () => {
'/shortcut-icon-nested.png'
)
})

it('should re-insert the body icons into the head', async () => {
const browser = await next.browser('/custom-icon')

await retry(async () => {
const iconsInBody = await browser.elementsByCss('body link[rel="icon"]')
const iconsInHead = await browser.elementsByCss('head link[rel="icon"]')

// moved to head
expect(iconsInBody.length).toBe(0)
// re-inserted favicon.ico + /heart.png
expect(iconsInHead.length).toBe(2)
})
})

it('should re-insert the apple icons into the head after navigation', async () => {
const browser = await next.browser('/custom-icon')
await browser.elementByCss('#custom-icon-sub-link').click()

await retry(async () => {
const url = await browser.url()
expect(url).toMatch(/\/custom-icon\/sub$/)
})

await retry(async () => {
const iconsInHead = await browser.elementsByCss('head link[rel="icon"]')
let iconUrls = await Promise.all(
iconsInHead.map(
async (el) => (await el.getAttribute('href')).split('?')[0]
)
)
// Pick last 2 icons
// In non-headless mode, the icons are deduped;
// In headless mode, the icons are not deduped
expect(iconUrls.length === 4 ? iconUrls.slice(2) : iconUrls).toEqual([
'/favicon.ico',
'/star.png',
])
})

// navigate back
await browser.elementByCss('#custom-icon-link').click()
await retry(async () => {
const url = await browser.url()
expect(url).toMatch(/\/custom-icon$/)
})

await retry(async () => {
const icons = await browser.elementsByCss('head link[rel="icon"]')
const iconUrls = await Promise.all(
icons.map(async (el) => (await el.getAttribute('href')).split('?')[0])
)

// Pick last 2 icons
// In non-headless mode, the icons are deduped;
// In headless mode, the icons are not deduped
expect(iconUrls.length === 4 ? iconUrls.slice(2) : iconUrls).toEqual([
'/favicon.ico',
'/heart.png',
])
})
})
})
Binary file added test/e2e/app-dir/metadata-icons/public/heart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/e2e/app-dir/metadata-icons/public/star.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading