From f328943db6e7d789759d2abffc69798354d60af3 Mon Sep 17 00:00:00 2001 From: Benjamin Favre Date: Wed, 18 Mar 2026 11:07:50 +0000 Subject: [PATCH 1/2] perf: replace structuredClone with shallow copy in metadata resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `structuredClone` in `mergeMetadata` and `mergeViewport` is the #1 CPU hotspot at 914ms (3.6% of request time). With 10 nested layouts, each request triggers 20 structuredClone calls (10 metadata + 10 viewport), deep-cloning the entire resolved metadata/viewport objects every time. After cloning, the merge functions iterate over metadata keys and *replace* every property with a new value — no nested property is ever mutated in-place. This means a shallow object spread (`{ ...obj }`) is semantically equivalent to `structuredClone` for this use case. Replace both `structuredClone` calls with `{ ...obj }` spreads, which are effectively free (~0ms) compared to the deep-clone overhead. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/next/src/lib/metadata/resolve-metadata.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index bac8bb9e146b..d29359c72607 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -232,7 +232,9 @@ async function mergeMetadata( leafSegmentStaticIcons: StaticIcons } ): Promise { - const newResolvedMetadata = structuredClone(resolvedMetadata) + // Use a shallow copy instead of structuredClone. This is safe because + // every property below is fully replaced (never mutated in-place). + const newResolvedMetadata: ResolvedMetadata = { ...resolvedMetadata } const metadataBase = normalizeMetadataBase( metadata?.metadataBase !== undefined @@ -456,7 +458,9 @@ function mergeViewport({ resolvedViewport: ResolvedViewport viewport: Viewport | null }): ResolvedViewport { - const newResolvedViewport = structuredClone(resolvedViewport) + // Use a shallow copy instead of structuredClone. This is safe because + // every property below is fully replaced (never mutated in-place). + const newResolvedViewport: ResolvedViewport = { ...resolvedViewport } if (viewport) { for (const key_ in viewport) { From dc37068c55542833c62ffb1926f1f29b1b628cf6 Mon Sep 17 00:00:00 2001 From: Benjamin Favre Date: Wed, 18 Mar 2026 14:22:51 +0000 Subject: [PATCH 2/2] fix: deep-copy mutable nested objects in metadata resolution The shallow spread `{ ...resolvedMetadata }` kept frozen inner references in dev mode (where deepFreeze is applied) and caused shared-state leaks in production. postProcessMetadata mutates nested objects: - icons.icon.unshift(favicon) - inheritFromMetadata sets openGraph.title / .description - Object.assign on twitter Spread the three mutable nested objects (openGraph, twitter, icons) so mutations operate on fresh copies. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../next/src/lib/metadata/resolve-metadata.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index d29359c72607..093984de38b0 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -232,9 +232,26 @@ async function mergeMetadata( leafSegmentStaticIcons: StaticIcons } ): Promise { - // Use a shallow copy instead of structuredClone. This is safe because - // every property below is fully replaced (never mutated in-place). - const newResolvedMetadata: ResolvedMetadata = { ...resolvedMetadata } + // Shallow-copy the top-level object, plus a targeted copy of nested objects + // that postProcessMetadata / inheritFromMetadata may mutate in-place + // (e.g. icons.icon.unshift, openGraph.title = ..., twitter Object.assign). + // A plain `{ ...resolvedMetadata }` would keep frozen inner references in + // dev mode (deepFreeze) and cause shared-state leaks in production. + const newResolvedMetadata: ResolvedMetadata = { + ...resolvedMetadata, + openGraph: resolvedMetadata.openGraph + ? { ...resolvedMetadata.openGraph } + : null, + twitter: resolvedMetadata.twitter + ? { ...resolvedMetadata.twitter } + : null, + icons: resolvedMetadata.icons + ? { + icon: [...resolvedMetadata.icons.icon], + apple: [...resolvedMetadata.icons.apple], + } + : null, + } const metadataBase = normalizeMetadataBase( metadata?.metadataBase !== undefined