diff --git a/packages/next/build/webpack/loaders/next-image-loader.js b/packages/next/build/webpack/loaders/next-image-loader.ts similarity index 77% rename from packages/next/build/webpack/loaders/next-image-loader.js rename to packages/next/build/webpack/loaders/next-image-loader.ts index 7a24bd9c7b4a..cc33c6c532a2 100644 --- a/packages/next/build/webpack/loaders/next-image-loader.js +++ b/packages/next/build/webpack/loaders/next-image-loader.ts @@ -1,14 +1,23 @@ +import isAnimated from 'next/dist/compiled/is-animated' import loaderUtils from 'next/dist/compiled/loader-utils3' -import { resizeImage, getImageSize } from '../../../server/image-optimizer' +import { optimizeImage, getImageSize } from '../../../server/image-optimizer' const BLUR_IMG_SIZE = 8 const BLUR_QUALITY = 70 const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif'] // should match next/client/image.tsx -function nextImageLoader(content) { +interface Options { + isServer: boolean + isDev: boolean + assetPrefix: string + basePath: string +} + +function nextImageLoader(this: any, content: Buffer) { const imageLoaderSpan = this.currentTraceSpan.traceChild('next-image-loader') return imageLoaderSpan.traceAsyncFn(async () => { - const { isServer, isDev, assetPrefix, basePath } = this.getOptions() + const options: Options = this.getOptions() + const { isServer, isDev, assetPrefix, basePath } = options const context = this.rootContext const opts = { context, content } const interpolatedName = loaderUtils.interpolateName( @@ -33,9 +42,9 @@ function nextImageLoader(content) { throw err } - let blurDataURL - let blurWidth - let blurHeight + let blurDataURL: string + let blurWidth: number + let blurHeight: number if (VALID_BLUR_EXT.includes(extension)) { // Shrink the image's largest dimension @@ -60,14 +69,23 @@ function nextImageLoader(content) { const prefix = 'http://localhost' const url = new URL(`${basePath || ''}/_next/image`, prefix) url.searchParams.set('url', outputPath) - url.searchParams.set('w', blurWidth) - url.searchParams.set('q', BLUR_QUALITY) + url.searchParams.set('w', String(blurWidth)) + url.searchParams.set('q', String(BLUR_QUALITY)) blurDataURL = url.href.slice(prefix.length) } else { const resizeImageSpan = imageLoaderSpan.traceChild('image-resize') - const resizedImage = await resizeImageSpan.traceAsyncFn(() => - resizeImage(content, blurWidth, blurHeight, extension, BLUR_QUALITY) - ) + const resizedImage = await resizeImageSpan.traceAsyncFn(() => { + if (isAnimated(content)) { + return content + } + return optimizeImage({ + buffer: content, + width: blurWidth, + height: blurHeight, + contentType: `image/${extension}`, + quality: BLUR_QUALITY, + }) + }) const blurDataURLSpan = imageLoaderSpan.traceChild( 'image-base64-tostring' ) diff --git a/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts b/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts index bd96c248ea0c..fa86bc92f443 100644 --- a/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts +++ b/packages/next/client/components/react-dev-overlay/internal/helpers/use-error-handler.ts @@ -22,9 +22,12 @@ function isHydrationError(error: Error): boolean { ) } -try { - Error.stackTraceLimit = 50 -} catch {} +if (typeof window !== 'undefined') { + try { + // Increase the number of stack frames on the client + Error.stackTraceLimit = 50 + } catch {} +} const errorQueue: Array = [] const rejectionQueue: Array = [] diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 336686fbe51f..4f126062f435 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -150,6 +150,10 @@ export default class DevServer extends Server { } constructor(options: Options) { + try { + // Increase the number of stack frames on the server + Error.stackTraceLimit = 50 + } catch {} super({ ...options, dev: true }) this.persistPatchedGlobals() this.renderOpts.dev = true diff --git a/packages/next/server/image-optimizer.ts b/packages/next/server/image-optimizer.ts index d0518eb7ab98..6dc952e90c4b 100644 --- a/packages/next/server/image-optimizer.ts +++ b/packages/next/server/image-optimizer.ts @@ -390,6 +390,119 @@ export function getMaxAge(str: string | null): number { return 0 } +export async function optimizeImage({ + buffer, + contentType, + quality, + width, + height, + nextConfigOutput, +}: { + buffer: Buffer + contentType: string + quality: number + width: number + height?: number + nextConfigOutput?: 'standalone' +}): Promise { + let optimizedBuffer = buffer + if (sharp) { + // Begin sharp transformation logic + const transformer = sharp(buffer) + + transformer.rotate() + + if (height) { + transformer.resize(width, height) + } else { + const { width: metaWidth } = await transformer.metadata() + + if (metaWidth && metaWidth > width) { + transformer.resize(width) + } + } + + if (contentType === AVIF) { + if (transformer.avif) { + const avifQuality = quality - 15 + transformer.avif({ + quality: Math.max(avifQuality, 0), + chromaSubsampling: '4:2:0', // same as webp + }) + } else { + console.warn( + chalk.yellow.bold('Warning: ') + + `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' + ) + transformer.webp({ quality }) + } + } else if (contentType === WEBP) { + transformer.webp({ quality }) + } else if (contentType === PNG) { + transformer.png({ quality }) + } else if (contentType === JPEG) { + transformer.jpeg({ quality }) + } + + optimizedBuffer = await transformer.toBuffer() + // End sharp transformation logic + } else { + if (showSharpMissingWarning && nextConfigOutput) { + // TODO: should we ensure squoosh also works even though we don't + // recommend it be used in production and this is a production feature + console.error( + `Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production` + ) + throw new ImageError(500, 'internal server error') + } + // Show sharp warning in production once + if (showSharpMissingWarning) { + console.warn( + chalk.yellow.bold('Warning: ') + + `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add sharp', and Next.js will use it automatically for Image Optimization.\n` + + 'Read more: https://nextjs.org/docs/messages/sharp-missing-in-production' + ) + showSharpMissingWarning = false + } + + // Begin Squoosh transformation logic + const orientation = await getOrientation(buffer) + + const operations: Operation[] = [] + + if (orientation === Orientation.RIGHT_TOP) { + operations.push({ type: 'rotate', numRotations: 1 }) + } else if (orientation === Orientation.BOTTOM_RIGHT) { + operations.push({ type: 'rotate', numRotations: 2 }) + } else if (orientation === Orientation.LEFT_BOTTOM) { + operations.push({ type: 'rotate', numRotations: 3 }) + } else { + // TODO: support more orientations + // eslint-disable-next-line @typescript-eslint/no-unused-vars + // const _: never = orientation + } + + if (height) { + operations.push({ type: 'resize', width, height }) + } else { + operations.push({ type: 'resize', width }) + } + + if (contentType === AVIF) { + optimizedBuffer = await processBuffer(buffer, operations, 'avif', quality) + } else if (contentType === WEBP) { + optimizedBuffer = await processBuffer(buffer, operations, 'webp', quality) + } else if (contentType === PNG) { + optimizedBuffer = await processBuffer(buffer, operations, 'png', quality) + } else if (contentType === JPEG) { + optimizedBuffer = await processBuffer(buffer, operations, 'jpeg', quality) + } + } + + return optimizedBuffer +} + export async function imageOptimizer( _req: IncomingMessage, _res: ServerResponse, @@ -504,114 +617,13 @@ export async function imageOptimizer( contentType = JPEG } try { - let optimizedBuffer: Buffer | undefined - if (sharp) { - // Begin sharp transformation logic - const transformer = sharp(upstreamBuffer) - - transformer.rotate() - - const { width: metaWidth } = await transformer.metadata() - - if (metaWidth && metaWidth > width) { - transformer.resize(width) - } - - if (contentType === AVIF) { - if (transformer.avif) { - const avifQuality = quality - 15 - transformer.avif({ - quality: Math.max(avifQuality, 0), - chromaSubsampling: '4:2:0', // same as webp - }) - } else { - console.warn( - chalk.yellow.bold('Warning: ') + - `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + - 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' - ) - transformer.webp({ quality }) - } - } else if (contentType === WEBP) { - transformer.webp({ quality }) - } else if (contentType === PNG) { - transformer.png({ quality }) - } else if (contentType === JPEG) { - transformer.jpeg({ quality }) - } - - optimizedBuffer = await transformer.toBuffer() - // End sharp transformation logic - } else { - if (showSharpMissingWarning && nextConfig.output === 'standalone') { - // TODO: should we ensure squoosh also works even though we don't - // recommend it be used in production and this is a production feature - console.error( - `Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production` - ) - throw new ImageError(500, 'internal server error') - } - // Show sharp warning in production once - if (showSharpMissingWarning) { - console.warn( - chalk.yellow.bold('Warning: ') + - `For production Image Optimization with Next.js, the optional 'sharp' package is strongly recommended. Run 'yarn add sharp', and Next.js will use it automatically for Image Optimization.\n` + - 'Read more: https://nextjs.org/docs/messages/sharp-missing-in-production' - ) - showSharpMissingWarning = false - } - - // Begin Squoosh transformation logic - const orientation = await getOrientation(upstreamBuffer) - - const operations: Operation[] = [] - - if (orientation === Orientation.RIGHT_TOP) { - operations.push({ type: 'rotate', numRotations: 1 }) - } else if (orientation === Orientation.BOTTOM_RIGHT) { - operations.push({ type: 'rotate', numRotations: 2 }) - } else if (orientation === Orientation.LEFT_BOTTOM) { - operations.push({ type: 'rotate', numRotations: 3 }) - } else { - // TODO: support more orientations - // eslint-disable-next-line @typescript-eslint/no-unused-vars - // const _: never = orientation - } - - operations.push({ type: 'resize', width }) - - if (contentType === AVIF) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'avif', - quality - ) - } else if (contentType === WEBP) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'webp', - quality - ) - } else if (contentType === PNG) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'png', - quality - ) - } else if (contentType === JPEG) { - optimizedBuffer = await processBuffer( - upstreamBuffer, - operations, - 'jpeg', - quality - ) - } - - // End Squoosh transformation logic - } + let optimizedBuffer = await optimizeImage({ + buffer: upstreamBuffer, + contentType, + quality, + width, + nextConfigOutput: nextConfig.output, + }) if (optimizedBuffer) { if (isDev && width <= BLUR_IMG_SIZE && quality === BLUR_QUALITY) { // During `next dev`, we don't want to generate blur placeholders with webpack @@ -743,52 +755,6 @@ export function sendResponse( } } -export async function resizeImage( - content: Buffer, - width: number, - height: number, - // Should match VALID_BLUR_EXT - extension: 'avif' | 'webp' | 'png' | 'jpeg', - quality: number -): Promise { - if (isAnimated(content)) { - return content - } else if (sharp) { - const transformer = sharp(content) - - if (extension === 'avif') { - if (transformer.avif) { - transformer.avif({ quality }) - } else { - console.warn( - chalk.yellow.bold('Warning: ') + - `Your installed version of the 'sharp' package does not support AVIF images. Run 'yarn add sharp@latest' to upgrade to the latest version.\n` + - 'Read more: https://nextjs.org/docs/messages/sharp-version-avif' - ) - transformer.webp({ quality }) - } - } else if (extension === 'webp') { - transformer.webp({ quality }) - } else if (extension === 'png') { - transformer.png({ quality }) - } else if (extension === 'jpeg') { - transformer.jpeg({ quality }) - } - transformer.resize(width, height) - const buf = await transformer.toBuffer() - return buf - } else { - const resizeOperationOpts: Operation = { type: 'resize', width, height } - const buf = await processBuffer( - content, - [resizeOperationOpts], - extension, - quality - ) - return buf - } -} - export async function getImageSize( buffer: Buffer, // Should match VALID_BLUR_EXT diff --git a/test/development/acceptance-app/ReactRefreshLogBox.test.ts b/test/development/acceptance-app/ReactRefreshLogBox.test.ts index 2ed392f21040..9fa41067ff30 100644 --- a/test/development/acceptance-app/ReactRefreshLogBox.test.ts +++ b/test/development/acceptance-app/ReactRefreshLogBox.test.ts @@ -1154,6 +1154,74 @@ describe('ReactRefreshLogBox app', () => { await cleanup() }) + test('Call stack count is correct for server error', async () => { + const { session, browser, cleanup } = await sandbox( + next, + new Map([ + [ + 'app/page.js', + ` + export default function Page() { + throw new Error('Server error') + } +`, + ], + ]) + ) + + expect(await session.hasRedbox(true)).toBe(true) + + // Open full Call Stack + await browser + .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]') + .click() + const callStackCount = ( + await browser.elementsByCss('[data-nextjs-call-stack-frame]') + ).length + + // Expect more than the default amount of frames + // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements + expect(callStackCount).toBeGreaterThan(9) + + await cleanup() + }) + + test('Call stack count is correct for client error', async () => { + const { session, browser, cleanup } = await sandbox( + next, + new Map([ + [ + 'app/page.js', + ` + 'use client' + export default function Page() { + if (typeof window !== 'undefined') { + throw new Error('Client error') + } + return null + } +`, + ], + ]) + ) + + expect(await session.hasRedbox(true)).toBe(true) + + // Open full Call Stack + await browser + .elementByCss('[data-nextjs-data-runtime-error-collapsed-action]') + .click() + const callStackCount = ( + await browser.elementsByCss('[data-nextjs-call-stack-frame]') + ).length + + // Expect more than the default amount of frames + // The default stackTraceLimit results in max 9 [data-nextjs-call-stack-frame] elements + expect(callStackCount).toBeGreaterThan(9) + + await cleanup() + }) + test('Server component errors should open up in fullscreen', async () => { const { session, browser, cleanup } = await sandbox( next, diff --git a/test/development/acceptance-app/__snapshots__/server-components.test.ts.snap b/test/development/acceptance-app/__snapshots__/server-components.test.ts.snap deleted file mode 100644 index 5c76475e0fc1..000000000000 --- a/test/development/acceptance-app/__snapshots__/server-components.test.ts.snap +++ /dev/null @@ -1,42 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Error Overlay for server components createContext called in Server Component should show error when React.createContext is called 1`] = ` -" 1 of 1 unhandled error -Server Error - -TypeError: createContext only works in Client Components. Add the \\"use client\\" directive at the top of the file to use it. Read more: https://beta.nextjs.org/docs/rendering/server-and-client-components#context - -This error happened while generating the page. Any console logs will be displayed in the terminal window. - -app/page.js (3:24) @ React - - 1 | - 2 | import React from 'react' -> 3 | const Context = React.createContext() - | ^ - 4 | export default function Page() { - 5 | return ( - 6 | <>" -`; - -exports[`Error Overlay for server components createContext called in Server Component should show error when React.createContext is called in external package 1`] = ` -" 1 of 1 unhandled error -Server Error - -TypeError: createContext only works in Client Components. Add the \\"use client\\" directive at the top of the file to use it. Read more: https://beta.nextjs.org/docs/rendering/server-and-client-components#context - -This error happened while generating the page. Any console logs will be displayed in the terminal window. - -null" -`; - -exports[`Error Overlay for server components createContext called in Server Component should show error when createContext is called in external package 1`] = ` -" 1 of 1 unhandled error -Server Error - -TypeError: createContext only works in Client Components. Add the \\"use client\\" directive at the top of the file to use it. Read more: https://beta.nextjs.org/docs/rendering/server-and-client-components#context - -This error happened while generating the page. Any console logs will be displayed in the terminal window. - -null" -`; diff --git a/test/development/acceptance-app/server-components.test.ts b/test/development/acceptance-app/server-components.test.ts index 47246440498c..cdace90cd7b8 100644 --- a/test/development/acceptance-app/server-components.test.ts +++ b/test/development/acceptance-app/server-components.test.ts @@ -3,6 +3,7 @@ import { sandbox } from './helpers' import { createNext, FileRef } from 'e2e-utils' import { NextInstance } from 'test/lib/next-modes/base' import path from 'path' +import { check } from 'next-test-utils' describe('Error Overlay for server components', () => { if (process.env.NEXT_TEST_REACT_VERSION === '^17') { @@ -51,7 +52,12 @@ describe('Error Overlay for server components', () => { await browser.refresh() expect(await session.hasRedbox(true)).toBe(true) - expect(await session.getRedboxSource(true)).toMatchSnapshot() + await check(async () => { + expect(await session.getRedboxSource(true)).toContain( + `TypeError: createContext only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/context-in-server-component` + ) + return 'success' + }, 'success') expect(next.cliOutput).toContain( 'createContext only works in Client Components' ) @@ -100,7 +106,14 @@ describe('Error Overlay for server components', () => { await browser.refresh() expect(await session.hasRedbox(true)).toBe(true) - expect(await session.getRedboxSource(true)).toMatchSnapshot() + + await check(async () => { + expect(await session.getRedboxSource(true)).toContain( + `TypeError: createContext only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/context-in-server-component` + ) + return 'success' + }, 'success') + expect(next.cliOutput).toContain( 'createContext only works in Client Components' ) @@ -149,11 +162,17 @@ describe('Error Overlay for server components', () => { await browser.refresh() expect(await session.hasRedbox(true)).toBe(true) - expect(await session.getRedboxSource(true)).toMatchSnapshot() + + await check(async () => { + expect(await session.getRedboxSource(true)).toContain( + `TypeError: createContext only works in Client Components. Add the "use client" directive at the top of the file to use it. Read more: https://nextjs.org/docs/messages/context-in-server-component` + ) + return 'success' + }, 'success') + expect(next.cliOutput).toContain( 'createContext only works in Client Components' ) - await cleanup() }) })