From 2b2c7462cd67862b66d10eaf822a60c75457ec0b Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Wed, 15 Feb 2023 14:49:34 +0100 Subject: [PATCH] Freeze resolved metadata object in dev mode (#45923) In dev mode, instead of `resolve(resolvedMetadata)` for the parent metadata argument we pass down `resolve(freeze(deepClone(resolvedMetadata)))` as parent metdadata, this approach will avoid users mutating resolved metadata manually but still allowing next manage to merge it during resolving Closes NEXT-559 - [x] linked task - [x] e2e tests --- .../next/src/lib/metadata/resolve-metadata.ts | 12 +++++-- .../metadata/app/params/[slug]/page.tsx | 2 +- test/e2e/app-dir/metadata/metadata.test.ts | 33 ++++++++++++++++++- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/next/src/lib/metadata/resolve-metadata.ts b/packages/next/src/lib/metadata/resolve-metadata.ts index 13ece1750a652..25425c5f03e3a 100644 --- a/packages/next/src/lib/metadata/resolve-metadata.ts +++ b/packages/next/src/lib/metadata/resolve-metadata.ts @@ -247,7 +247,13 @@ export async function collectMetadata( export async function accumulateMetadata( metadataItems: MetadataItems ): Promise { + const deepClone: (v: T) => T = + process.env.NODE_ENV === 'development' + ? require('next/dist/compiled/@edge-runtime/primitives/structured-clone') + .structuredClone + : (v) => v const resolvedMetadata = createDefaultMetadata() + let parentPromise = Promise.resolve(resolvedMetadata) for (const item of metadataItems) { @@ -263,13 +269,15 @@ export async function accumulateMetadata( parentPromise = parentPromise.then((resolved) => { return layerMetadataPromise.then((metadata) => { + resolved = deepClone(resolved) merge(resolved, metadata, staticFilesMetadata, { title: resolved.title?.template || null, openGraph: resolved.openGraph?.title?.template || null, twitter: resolved.twitter?.title?.template || null, }) - - return resolved + return process.env.NODE_ENV === 'development' + ? Object.freeze(resolved) + : resolved }) }) } diff --git a/test/e2e/app-dir/metadata/app/params/[slug]/page.tsx b/test/e2e/app-dir/metadata/app/params/[slug]/page.tsx index 2f2577a350ffe..aea4fa3fb4c56 100644 --- a/test/e2e/app-dir/metadata/app/params/[slug]/page.tsx +++ b/test/e2e/app-dir/metadata/app/params/[slug]/page.tsx @@ -10,7 +10,7 @@ export default function page(props) { export async function generateMetadata(props, parent) { const parentMetadata = await parent - + /* mutating */ return { ...parentMetadata, title: format(props), diff --git a/test/e2e/app-dir/metadata/metadata.test.ts b/test/e2e/app-dir/metadata/metadata.test.ts index 5044defb1f261..4b0376f53d0f4 100644 --- a/test/e2e/app-dir/metadata/metadata.test.ts +++ b/test/e2e/app-dir/metadata/metadata.test.ts @@ -1,5 +1,5 @@ import { createNextDescribe } from 'e2e-utils' -import { check } from 'next-test-utils' +import { check, hasRedbox, getRedboxDescription } from 'next-test-utils' import { BrowserInterface } from 'test/lib/browsers/base' createNextDescribe( @@ -317,6 +317,37 @@ createNextDescribe( ) }) + if (isNextDev) { + it('should freeze parent resolved metadata to avoid mutating in generateMetadata', async () => { + const pagePath = 'app/mutate/page.tsx' + const content = `export default function page(props) { + return

mutate

+ } + + export async function generateMetadata(props, parent) { + const parentMetadata = await parent + parentMetadata.x = 1 + return { + ...parentMetadata, + } + }` + await next.patchFile(pagePath, content) + + const browser = await next.browser('/mutate') + await check( + async () => ((await hasRedbox(browser, true)) ? 'success' : 'fail'), + /success/ + ) + const error = await getRedboxDescription(browser) + + await next.deleteFile(pagePath) + + expect(error).toContain( + 'Cannot add property x, object is not extensible' + ) + }) + } + it('should support synchronous generateMetadata export', async () => { const browser = await next.browser('/basic/sync-generate-metadata') expect(await getTitle(browser)).toBe('synchronous generateMetadata')