From 79bebe7bd38bb60546d7deb46d5aef0683b589df Mon Sep 17 00:00:00 2001 From: Janka Uryga Date: Mon, 20 May 2024 10:49:53 +0200 Subject: [PATCH 1/3] experimental: unstable_after (#65038) Implements `unstable_after`, which lets the user schedule work to be executed after the response is finished. ### Implementation notes - `unstable_after()` is a dynamic function (bypassable only with `export dynamic = "force-static"`) - Usable in: server components (including `generateMetadata`), actions, route handlers, middleware - It is meant to run its callbacks even if a response didn't complete successfully (thrown error) or called `notFound()`/`redirect()` - Currently gated behind a `experimental.after` feature flag, because it touches many runtime bits (including a React monkeypatch...) - The state for `unstable_after()` in a given request lives in `requestAsyncStorage` (added via `RequestAsyncStorageWrapper`) - the implementation is based around two functions that we inject via `renderOpts`: - `waitUntil(promise)` - keep a function invocation alive until a promise settles. it is provided as a platform primitive in serverless contexts, and a noop in `next start` - for serverless (nodejs), Next.js will attempt to get `waitUntil` from `globalThis[Symbol.for('@next/request-context')].get().waitUntil`. This should be considered unstable for now. See `packages/next/src/server/after/wait-until-builtin.ts` for details. - `onClose(callback)` **[NEW]** - run something when a response is done. basically `res.on('close', callback)`, but also implemented for Web APIs - unfortunately, for Web, this requires some potentially expensive tricks - see `packages/next/src/server/web/web-on-close.ts` --- .../crates/next-core/src/next_config.rs | 1 + .../transforms/next_cjs_optimizer.rs | 1 + .../src/transforms/react_server_components.rs | 27 +- packages/next/server.d.ts | 1 + packages/next/server.js | 2 + packages/next/src/build/utils.ts | 3 + .../loaders/next-edge-ssr-loader/render.ts | 9 +- .../webpack/plugins/define-env-plugin.ts | 1 + .../request-async-storage.external.ts | 2 + packages/next/src/export/index.ts | 1 + packages/next/src/export/routes/app-route.ts | 7 +- packages/next/src/export/types.ts | 6 +- packages/next/src/export/worker.ts | 5 +- .../src/server/after/after-context.test.ts | 421 ++++++++++++++++++ .../next/src/server/after/after-context.ts | 174 ++++++++ packages/next/src/server/after/after.ts | 40 ++ packages/next/src/server/after/index.ts | 1 + .../src/server/after/react-cache-scope.ts | 160 +++++++ .../src/server/after/wait-until-builtin.ts | 27 ++ .../next/src/server/app-render/app-render.tsx | 4 + .../next/src/server/app-render/entry-base.ts | 12 + packages/next/src/server/app-render/types.ts | 5 +- .../request-async-storage-wrapper.ts | 48 +- ...static-generation-async-storage-wrapper.ts | 11 +- packages/next/src/server/base-http/index.ts | 2 + packages/next/src/server/base-http/node.ts | 4 + .../next/src/server/base-http/web.test.ts | 47 ++ packages/next/src/server/base-http/web.ts | 41 +- packages/next/src/server/base-server.ts | 46 +- packages/next/src/server/config-schema.ts | 1 + packages/next/src/server/config-shared.ts | 6 + .../future/route-modules/app-route/module.ts | 9 +- packages/next/src/server/next-server.ts | 1 + packages/next/src/server/web/adapter.ts | 76 +++- .../server/web/edge-route-module-wrapper.ts | 46 +- packages/next/src/server/web/exports/index.ts | 1 + packages/next/src/server/web/types.ts | 3 +- packages/next/src/server/web/web-on-close.ts | 56 +++ .../next/src/shared/lib/invariant-error.ts | 9 + ...component-compiler-errors-in-pages.test.ts | 58 +++ .../next-after-app/app/[id]/dynamic/page.js | 27 ++ .../app-dir/next-after-app/app/[id]/layout.js | 9 + .../app/[id]/setting-cookies/page.js | 29 ++ .../app/[id]/with-action/page.js | 38 ++ .../app/[id]/with-metadata/page.js | 18 + .../app-dir/next-after-app/app/delay/page.js | 30 ++ .../app/interrupted/calls-not-found/page.js | 12 + .../app/interrupted/calls-redirect/page.js | 12 + .../app/interrupted/redirect-target/page.js | 11 + .../app/interrupted/throws-error/page.js | 11 + .../app/invalid-in-client/page.js | 13 + test/e2e/app-dir/next-after-app/app/layout.js | 13 + .../app/middleware/redirect/page.js | 3 + .../app-dir/next-after-app/app/route/route.js | 16 + .../app-dir/next-after-app/app/static/page.js | 12 + test/e2e/app-dir/next-after-app/index.test.ts | 338 ++++++++++++++ test/e2e/app-dir/next-after-app/middleware.js | 24 + .../e2e/app-dir/next-after-app/next.config.js | 7 + test/e2e/app-dir/next-after-app/utils/log.js | 16 + .../app-dir/next-after-pages/index.test.ts | 80 ++++ .../app-dir/next-after-pages/middleware.js | 24 + .../app-dir/next-after-pages/next.config.js | 7 + .../pages/middleware/redirect/index.js | 3 + .../pages/pages-dir/[id]/invalid-in-gsp.js | 23 + .../pages/pages-dir/invalid-in-gssp.js | 15 + .../pages/pages-dir/invalid-in-page.js | 15 + .../e2e/app-dir/next-after-pages/utils/log.js | 16 + 67 files changed, 2153 insertions(+), 44 deletions(-) create mode 100644 packages/next/src/server/after/after-context.test.ts create mode 100644 packages/next/src/server/after/after-context.ts create mode 100644 packages/next/src/server/after/after.ts create mode 100644 packages/next/src/server/after/index.ts create mode 100644 packages/next/src/server/after/react-cache-scope.ts create mode 100644 packages/next/src/server/after/wait-until-builtin.ts create mode 100644 packages/next/src/server/base-http/web.test.ts create mode 100644 packages/next/src/server/web/web-on-close.ts create mode 100644 packages/next/src/shared/lib/invariant-error.ts create mode 100644 test/e2e/app-dir/next-after-app/app/[id]/dynamic/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/[id]/layout.js create mode 100644 test/e2e/app-dir/next-after-app/app/[id]/setting-cookies/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/[id]/with-action/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/[id]/with-metadata/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/delay/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/interrupted/calls-not-found/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/interrupted/calls-redirect/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/interrupted/redirect-target/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/interrupted/throws-error/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/invalid-in-client/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/layout.js create mode 100644 test/e2e/app-dir/next-after-app/app/middleware/redirect/page.js create mode 100644 test/e2e/app-dir/next-after-app/app/route/route.js create mode 100644 test/e2e/app-dir/next-after-app/app/static/page.js create mode 100644 test/e2e/app-dir/next-after-app/index.test.ts create mode 100644 test/e2e/app-dir/next-after-app/middleware.js create mode 100644 test/e2e/app-dir/next-after-app/next.config.js create mode 100644 test/e2e/app-dir/next-after-app/utils/log.js create mode 100644 test/e2e/app-dir/next-after-pages/index.test.ts create mode 100644 test/e2e/app-dir/next-after-pages/middleware.js create mode 100644 test/e2e/app-dir/next-after-pages/next.config.js create mode 100644 test/e2e/app-dir/next-after-pages/pages/middleware/redirect/index.js create mode 100644 test/e2e/app-dir/next-after-pages/pages/pages-dir/[id]/invalid-in-gsp.js create mode 100644 test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-gssp.js create mode 100644 test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-page.js create mode 100644 test/e2e/app-dir/next-after-pages/utils/log.js diff --git a/packages/next-swc/crates/next-core/src/next_config.rs b/packages/next-swc/crates/next-core/src/next_config.rs index d5740a72ce331..de9b4d6c67915 100644 --- a/packages/next-swc/crates/next-core/src/next_config.rs +++ b/packages/next-swc/crates/next-core/src/next_config.rs @@ -526,6 +526,7 @@ pub struct ExperimentalConfig { // --- adjust_font_fallbacks: Option, adjust_font_fallbacks_with_size_adjust: Option, + after: Option, amp: Option, app_document_preloading: Option, case_sensitive_routes: Option, diff --git a/packages/next-swc/crates/next-core/src/next_shared/transforms/next_cjs_optimizer.rs b/packages/next-swc/crates/next-core/src/next_shared/transforms/next_cjs_optimizer.rs index 17112cb7f0808..7354058c2b93d 100644 --- a/packages/next-swc/crates/next-core/src/next_shared/transforms/next_cjs_optimizer.rs +++ b/packages/next-swc/crates/next-core/src/next_shared/transforms/next_cjs_optimizer.rs @@ -45,6 +45,7 @@ pub fn get_next_cjs_optimizer_rule(enable_mdx_rs: bool) -> ModuleRule { "userAgent".into(), "next/dist/server/web/spec-extension/user-agent".into(), ), + ("unstable_after".into(), "next/dist/server/after".into()), ]), }, )]), diff --git a/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs b/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs index 10f8ed5454468..3feddac24b8a8 100644 --- a/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs +++ b/packages/next-swc/crates/next-custom-transforms/src/transforms/react_server_components.rs @@ -472,8 +472,9 @@ struct ReactServerComponentValidator { filepath: String, app_dir: Option, invalid_server_imports: Vec, - invalid_client_imports: Vec, invalid_server_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>, + invalid_client_imports: Vec, + invalid_client_lib_apis_mapping: HashMap<&'static str, Vec<&'static str>>, pub directive_import_collection: Option<(bool, bool, Vec, Vec)>, } @@ -540,7 +541,10 @@ impl ReactServerComponentValidator { JsWord::from("react-dom/server"), JsWord::from("next/router"), ], + invalid_client_imports: vec![JsWord::from("server-only"), JsWord::from("next/headers")], + + invalid_client_lib_apis_mapping: [("next/server", vec!["unstable_after"])].into(), } } @@ -627,14 +631,31 @@ impl ReactServerComponentValidator { return; } for import in imports { - let source = import.source.0.clone(); - if self.invalid_client_imports.contains(&source) { + let source = &import.source.0; + + if self.invalid_client_imports.contains(source) { report_error( &self.app_dir, &self.filepath, RSCErrorKind::NextRscErrClientImport((source.to_string(), import.source.1)), ); } + + let invalid_apis = self.invalid_client_lib_apis_mapping.get(source.as_str()); + if let Some(invalid_apis) = invalid_apis { + for specifier in &import.specifiers { + if invalid_apis.contains(&specifier.0.as_str()) { + report_error( + &self.app_dir, + &self.filepath, + RSCErrorKind::NextRscErrClientImport(( + specifier.0.to_string(), + specifier.1, + )), + ); + } + } + } } } diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index d5eb0a58dd801..872bbc3899ff1 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -13,3 +13,4 @@ export { userAgent } from 'next/dist/server/web/spec-extension/user-agent' export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url' export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response' export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types' +export { unstable_after } from 'next/dist/server/after' diff --git a/packages/next/server.js b/packages/next/server.js index c6bc19107c8b9..589a789dfe66b 100644 --- a/packages/next/server.js +++ b/packages/next/server.js @@ -11,6 +11,7 @@ const serverExports = { .userAgent, URLPattern: require('next/dist/server/web/spec-extension/url-pattern') .URLPattern, + unstable_after: require('next/dist/server/after').unstable_after, } // https://nodejs.org/api/esm.html#commonjs-namespaces @@ -24,3 +25,4 @@ exports.ImageResponse = serverExports.ImageResponse exports.userAgentFromString = serverExports.userAgentFromString exports.userAgent = serverExports.userAgent exports.URLPattern = serverExports.URLPattern +exports.unstable_after = serverExports.unstable_after diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 5b6429b7f24ed..b4f583f08d461 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1391,6 +1391,9 @@ export async function buildAppStaticPaths({ incrementalCache, supportsDynamicHTML: true, isRevalidate: false, + experimental: { + after: false, + }, }, }, async () => { diff --git a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts index 1bda32f320d47..83168210d7432 100644 --- a/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts +++ b/packages/next/src/build/webpack/loaders/next-edge-ssr-loader/render.ts @@ -158,12 +158,19 @@ export function getRender({ event?: NextFetchEvent ) { const extendedReq = new WebNextRequest(request) - const extendedRes = new WebNextResponse() + const extendedRes = new WebNextResponse( + undefined, + // tracking onClose adds overhead, so only do it if `experimental.after` is on. + !!process.env.__NEXT_AFTER + ) handler(extendedReq, extendedRes) const result = await extendedRes.toResponse() if (event?.waitUntil) { + // TODO(after): + // remove `internal_runWithWaitUntil` and the `internal-edge-wait-until` module + // when consumers switch to `unstable_after`. const waitUntilPromise = internal_getCurrentFunctionWaitUntil() if (waitUntilPromise) { event.waitUntil(waitUntilPromise) diff --git a/packages/next/src/build/webpack/plugins/define-env-plugin.ts b/packages/next/src/build/webpack/plugins/define-env-plugin.ts index 8717847d89b13..79cb41dea7102 100644 --- a/packages/next/src/build/webpack/plugins/define-env-plugin.ts +++ b/packages/next/src/build/webpack/plugins/define-env-plugin.ts @@ -170,6 +170,7 @@ export function getDefineEnv({ : '', 'process.env.NEXT_MINIMAL': '', 'process.env.__NEXT_PPR': checkIsAppPPREnabled(config.experimental.ppr), + 'process.env.__NEXT_AFTER': config.experimental.after ?? false, 'process.env.NEXT_DEPLOYMENT_ID': config.deploymentId || false, 'process.env.__NEXT_FETCH_CACHE_KEY_PREFIX': fetchCacheKeyPrefix ?? '', 'process.env.__NEXT_MIDDLEWARE_MATCHERS': middlewareMatchers ?? [], diff --git a/packages/next/src/client/components/request-async-storage.external.ts b/packages/next/src/client/components/request-async-storage.external.ts index e56ff93f0da46..e2c696bed2319 100644 --- a/packages/next/src/client/components/request-async-storage.external.ts +++ b/packages/next/src/client/components/request-async-storage.external.ts @@ -7,6 +7,7 @@ import type { ReadonlyRequestCookies } from '../../server/web/spec-extension/ada // Share the instance module in the next-shared layer import { requestAsyncStorage } from './request-async-storage-instance' with { 'turbopack-transition': 'next-shared' } import type { DeepReadonly } from '../../shared/lib/deep-readonly' +import type { AfterContext } from '../../server/after/after-context' export interface RequestStore { readonly headers: ReadonlyHeaders @@ -17,6 +18,7 @@ export interface RequestStore { Record > readonly assetPrefix: string + readonly afterContext: AfterContext | undefined } export type RequestAsyncStorage = AsyncLocalStorage diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index b52db8cd6d3dd..0c6e366ebf71f 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -422,6 +422,7 @@ export async function exportAppImpl( isAppPPREnabled: checkIsAppPPREnabled(nextConfig.experimental.ppr), clientTraceMetadata: nextConfig.experimental.clientTraceMetadata, swrDelta: nextConfig.experimental.swrDelta, + after: nextConfig.experimental.after ?? false, }, } diff --git a/packages/next/src/export/routes/app-route.ts b/packages/next/src/export/routes/app-route.ts index 189b307c28fd0..838745cb25510 100644 --- a/packages/next/src/export/routes/app-route.ts +++ b/packages/next/src/export/routes/app-route.ts @@ -23,6 +23,7 @@ import type { import { isDynamicUsageError } from '../helpers/is-dynamic-usage-error' import { SERVER_DIRECTORY } from '../../shared/lib/constants' import { hasNextSupport } from '../../telemetry/ci-info' +import type { ExperimentalConfig } from '../../server/config-shared' export const enum ExportedAppRouteFiles { BODY = 'BODY', @@ -37,7 +38,8 @@ export async function exportAppRoute( incrementalCache: IncrementalCache | undefined, distDir: string, htmlFilepath: string, - fileWriter: FileWriter + fileWriter: FileWriter, + experimental: Required> ): Promise { // Ensure that the URL is absolute. req.url = `http://localhost:3000${req.url}` @@ -64,10 +66,13 @@ export async function exportAppRoute( notFoundRoutes: [], }, renderOpts: { + experimental: experimental, originalPathname: page, nextExport: true, supportsDynamicHTML: false, incrementalCache, + waitUntil: undefined, + onClose: undefined, }, } diff --git a/packages/next/src/export/types.ts b/packages/next/src/export/types.ts index ce5350e93c589..59d9ed4485bcb 100644 --- a/packages/next/src/export/types.ts +++ b/packages/next/src/export/types.ts @@ -8,7 +8,10 @@ import type { FontConfig } from '../server/font-utils' import type { ExportPathMap, NextConfigComplete } from '../server/config-shared' import type { Span } from '../trace' import type { Revalidate } from '../server/lib/revalidate' -import type { NextEnabledDirectories } from '../server/base-server' +import type { + NextEnabledDirectories, + RequestLifecycleOpts, +} from '../server/base-server' import type { SerializableTurborepoAccessTraceResult, TurborepoAccessTraceResult, @@ -97,6 +100,7 @@ export type WorkerRenderOptsPartial = PagesRenderOptsPartial & AppRenderOptsPartial export type WorkerRenderOpts = WorkerRenderOptsPartial & + RequestLifecycleOpts & LoadComponentsReturnType export type ExportWorker = ( diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index 7f973ffae3764..2677a015259ff 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -248,7 +248,8 @@ async function exportPageImpl( incrementalCache, distDir, htmlFilepath, - fileWriter + fileWriter, + input.renderOpts.experimental ) } @@ -274,6 +275,8 @@ async function exportPageImpl( ...input.renderOpts.experimental, isRoutePPREnabled, }, + waitUntil: undefined, + onClose: undefined, } if (hasNextSupport) { diff --git a/packages/next/src/server/after/after-context.test.ts b/packages/next/src/server/after/after-context.test.ts new file mode 100644 index 0000000000000..fc4f751ea81ba --- /dev/null +++ b/packages/next/src/server/after/after-context.test.ts @@ -0,0 +1,421 @@ +import { DetachedPromise } from '../../lib/detached-promise' +import { AsyncLocalStorage } from 'async_hooks' + +import type { RequestStore } from '../../client/components/request-async-storage.external' +import type { AfterContext } from './after-context' + +describe('createAfterContext', () => { + // 'async-local-storage.ts' needs `AsyncLocalStorage` on `globalThis` at import time, + // so we have to do some contortions here to set it up before running anything else + type RASMod = + typeof import('../../client/components/request-async-storage.external') + type AfterMod = typeof import('./after') + type AfterContextMod = typeof import('./after-context') + + let requestAsyncStorage: RASMod['requestAsyncStorage'] + let createAfterContext: AfterContextMod['createAfterContext'] + let after: AfterMod['unstable_after'] + + beforeAll(async () => { + // @ts-expect-error + globalThis.AsyncLocalStorage = AsyncLocalStorage + + const RASMod = await import( + '../../client/components/request-async-storage.external' + ) + requestAsyncStorage = RASMod.requestAsyncStorage + + const AfterContextMod = await import('./after-context') + createAfterContext = AfterContextMod.createAfterContext + + const AfterMod = await import('./after') + after = AfterMod.unstable_after + }) + + const createRun = + (afterContext: AfterContext, requestStore: RequestStore) => + (cb: () => T): T => { + return afterContext.run(requestStore, () => + requestAsyncStorage.run(requestStore, cb) + ) + } + + it('runs after() callbacks from a run() callback that resolves', async () => { + const waitUntilPromises: Promise[] = [] + const waitUntil = jest.fn((promise) => waitUntilPromises.push(promise)) + + let onCloseCallback: (() => void) | undefined = undefined + const onClose = jest.fn((cb) => { + onCloseCallback = cb + }) + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + const run = createRun(afterContext, requestStore) + + // ================================== + + const promise0 = new DetachedPromise() + + const promise1 = new DetachedPromise() + const afterCallback1 = jest.fn(() => promise1.promise) + + const promise2 = new DetachedPromise() + const afterCallback2 = jest.fn(() => promise2.promise) + + await run(async () => { + after(promise0.promise) + expect(onClose).not.toHaveBeenCalled() // we don't need onClose for bare promises + expect(waitUntil).toHaveBeenCalledTimes(1) + + await Promise.resolve(null) + + after(afterCallback1) + expect(waitUntil).toHaveBeenCalledTimes(2) // just runCallbacksOnClose + + await Promise.resolve(null) + + after(afterCallback2) + expect(waitUntil).toHaveBeenCalledTimes(2) // should only `waitUntil(this.runCallbacksOnClose())` once for all callbacks + }) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(afterCallback1).not.toHaveBeenCalled() + expect(afterCallback2).not.toHaveBeenCalled() + + // the response is done. + onCloseCallback!() + await Promise.resolve(null) + + expect(afterCallback1).toHaveBeenCalledTimes(1) + expect(afterCallback2).toHaveBeenCalledTimes(1) + expect(waitUntil).toHaveBeenCalledTimes(2) + + promise0.resolve('0') + promise1.resolve('1') + promise2.resolve('2') + + const results = await Promise.all(waitUntilPromises) + expect(results).toEqual([ + '0', // promises are passed to waitUntil as is + undefined, // callbacks all get collected into a big void promise + ]) + }) + + it('runs after() callbacks from a run() callback that throws', async () => { + const waitUntilPromises: Promise[] = [] + const waitUntil = jest.fn((promise) => waitUntilPromises.push(promise)) + + let onCloseCallback: (() => void) | undefined = undefined + const onClose = jest.fn((cb) => { + onCloseCallback = cb + }) + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + + const run = createRun(afterContext, requestStore) + + // ================================== + + const promise1 = new DetachedPromise() + const afterCallback1 = jest.fn(() => promise1.promise) + + await run(async () => { + after(afterCallback1) + throw new Error('boom!') + }).catch(() => {}) + + // runCallbacksOnClose + expect(waitUntil).toHaveBeenCalledTimes(1) + expect(onClose).toHaveBeenCalledTimes(1) + + expect(afterCallback1).not.toHaveBeenCalled() + + // the response is done. + onCloseCallback!() + await Promise.resolve(null) + + expect(afterCallback1).toHaveBeenCalledTimes(1) + expect(waitUntil).toHaveBeenCalledTimes(1) + + promise1.resolve('1') + + const results = await Promise.all(waitUntilPromises) + expect(results).toEqual([undefined]) + }) + + it('runs after() callbacks from a run() callback that streams', async () => { + const waitUntilPromises: Promise[] = [] + const waitUntil = jest.fn((promise) => waitUntilPromises.push(promise)) + + let onCloseCallback: (() => void) | undefined = undefined + const onClose = jest.fn((cb) => { + onCloseCallback = cb + }) + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + + const run = createRun(afterContext, requestStore) + + // ================================== + + const promise1 = new DetachedPromise() + const afterCallback1 = jest.fn(() => promise1.promise) + + const promise2 = new DetachedPromise() + const afterCallback2 = jest.fn(() => promise2.promise) + + const streamStarted = new DetachedPromise() + + const stream = run(() => { + return new ReadableStream({ + async start(controller) { + await streamStarted.promise // block the stream to start it manually later + + const delay = () => + new Promise((resolve) => setTimeout(resolve, 50)) + + after(afterCallback1) + controller.enqueue('one') + await delay() + expect(waitUntil).toHaveBeenCalledTimes(1) // runCallbacksOnClose + + after(afterCallback2) + controller.enqueue('two') + await delay() + expect(waitUntil).toHaveBeenCalledTimes(1) // runCallbacksOnClose + + await delay() + controller.close() + }, + }) + }) + + expect(onClose).not.toHaveBeenCalled() // no after()s executed yet + expect(afterCallback1).not.toHaveBeenCalled() + expect(afterCallback2).not.toHaveBeenCalled() + + // start the stream and consume it, which'll execute the after()s. + { + streamStarted.resolve() + const reader = stream.getReader() + while (true) { + const chunk = await reader.read() + if (chunk.done) { + break + } + } + } + + // runCallbacksOnClose + expect(onClose).toHaveBeenCalledTimes(1) + expect(waitUntil).toHaveBeenCalledTimes(1) + + expect(afterCallback1).not.toHaveBeenCalled() + expect(afterCallback2).not.toHaveBeenCalled() + + // the response is done. + onCloseCallback!() + await Promise.resolve(null) + + expect(afterCallback1).toHaveBeenCalledTimes(1) + expect(afterCallback2).toHaveBeenCalledTimes(1) + expect(waitUntil).toHaveBeenCalledTimes(1) + + promise1.resolve('1') + promise2.resolve('2') + + const results = await Promise.all(waitUntilPromises) + expect(results).toEqual([undefined]) + }) + + it('does not hang forever if onClose failed', async () => { + const waitUntilPromises: Promise[] = [] + const waitUntil = jest.fn((promise) => waitUntilPromises.push(promise)) + + const onClose = jest.fn(() => { + throw new Error('onClose is broken for some reason') + }) + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + + const run = createRun(afterContext, requestStore) + + // ================================== + + const afterCallback1 = jest.fn() + + await run(async () => { + after(afterCallback1) + }) + + expect(waitUntil).toHaveBeenCalledTimes(1) // runCallbacksOnClose + expect(onClose).toHaveBeenCalledTimes(1) + expect(afterCallback1).not.toHaveBeenCalled() + + // if we didn't properly reject the runCallbacksOnClose promise, this should hang forever, and get killed by jest. + const results = await Promise.allSettled(waitUntilPromises) + expect(results).toEqual([ + { status: 'rejected', value: undefined, reason: expect.anything() }, + ]) + }) + + it('runs all after() callbacks even if some of them threw', async () => { + const waitUntilPromises: Promise[] = [] + const waitUntil = jest.fn((promise) => waitUntilPromises.push(promise)) + + let onCloseCallback: (() => void) | undefined = undefined + const onClose = jest.fn((cb) => { + onCloseCallback = cb + }) + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + + // ================================== + + const promise1 = new DetachedPromise() + const afterCallback1 = jest.fn(() => promise1.promise) + + const afterCallback2 = jest.fn(() => { + throw new Error('2') + }) + + const promise3 = new DetachedPromise() + const afterCallback3 = jest.fn(() => promise3.promise) + + requestAsyncStorage.run(requestStore, () => + afterContext.run(requestStore, () => { + after(afterCallback1) + after(afterCallback2) + after(afterCallback3) + }) + ) + + expect(afterCallback1).not.toHaveBeenCalled() + expect(afterCallback2).not.toHaveBeenCalled() + expect(afterCallback3).not.toHaveBeenCalled() + expect(waitUntil).toHaveBeenCalledTimes(1) + + // the response is done. + onCloseCallback!() + await Promise.resolve(null) + + expect(afterCallback1).toHaveBeenCalledTimes(1) + expect(afterCallback2).toHaveBeenCalledTimes(1) + expect(afterCallback3).toHaveBeenCalledTimes(1) + expect(waitUntil).toHaveBeenCalledTimes(1) + + promise1.reject(new Error('1')) + promise3.resolve('3') + + const results = await Promise.all(waitUntilPromises) + expect(results).toEqual([undefined]) + }) + + it('throws from after() if waitUntil is not provided', async () => { + const waitUntil = undefined + const onClose = jest.fn() + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + + const run = createRun(afterContext, requestStore) + + // ================================== + + const afterCallback1 = jest.fn() + + expect(() => + run(() => { + after(afterCallback1) + }) + ).toThrow(/`waitUntil` is not available in the current environment/) + + expect(onClose).not.toHaveBeenCalled() + expect(afterCallback1).not.toHaveBeenCalled() + }) + + it('throws from after() if onClose is not provided', async () => { + const waitUntilPromises: Promise[] = [] + const waitUntil = jest.fn((promise) => waitUntilPromises.push(promise)) + + const onClose = undefined + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope: undefined, + }) + + const requestStore = createMockRequestStore(afterContext) + + const run = createRun(afterContext, requestStore) + + // ================================== + + const afterCallback1 = jest.fn() + + expect(() => + run(() => { + after(afterCallback1) + }) + ).toThrow(/Missing `onClose` implementation/) + + expect(waitUntil).not.toHaveBeenCalled() + expect(afterCallback1).not.toHaveBeenCalled() + }) +}) + +const createMockRequestStore = (afterContext: AfterContext): RequestStore => { + const partialStore: Partial = { + afterContext: afterContext, + assetPrefix: '', + reactLoadableManifest: {}, + draftMode: undefined, + } + + return new Proxy(partialStore, { + get(target, key) { + if (key in target) { + return target[key as keyof typeof target] + } + throw new Error( + `RequestStore property not mocked: '${typeof key === 'symbol' ? key.toString() : key}'` + ) + }, + }) as RequestStore +} diff --git a/packages/next/src/server/after/after-context.ts b/packages/next/src/server/after/after-context.ts new file mode 100644 index 0000000000000..d91309b845327 --- /dev/null +++ b/packages/next/src/server/after/after-context.ts @@ -0,0 +1,174 @@ +import { + requestAsyncStorage, + type RequestStore, +} from '../../client/components/request-async-storage.external' +import type { CacheScope } from './react-cache-scope' +import { ResponseCookies } from '../web/spec-extension/cookies' +import type { RequestLifecycleOpts } from '../base-server' +import type { AfterCallback, AfterTask } from './after' +import { InvariantError } from '../../shared/lib/invariant-error' + +export interface AfterContext { + run(requestStore: RequestStore, callback: () => T): T + after(task: AfterTask): void +} + +export type AfterContextOpts = { + waitUntil: RequestLifecycleOpts['waitUntil'] | undefined + onClose: RequestLifecycleOpts['onClose'] | undefined + cacheScope: CacheScope | undefined +} + +export function createAfterContext(opts: AfterContextOpts): AfterContext { + return new AfterContextImpl(opts) +} + +export class AfterContextImpl implements AfterContext { + private waitUntil: RequestLifecycleOpts['waitUntil'] | undefined + private onClose: RequestLifecycleOpts['onClose'] | undefined + private cacheScope: CacheScope | undefined + + private requestStore: RequestStore | undefined + + private afterCallbacks: AfterCallback[] = [] + + constructor({ waitUntil, onClose, cacheScope }: AfterContextOpts) { + this.waitUntil = waitUntil + this.onClose = onClose + this.cacheScope = cacheScope + } + + public run(requestStore: RequestStore, callback: () => T): T { + this.requestStore = requestStore + if (this.cacheScope) { + return this.cacheScope.run(() => callback()) + } else { + return callback() + } + } + + public after(task: AfterTask): void { + if (isPromise(task)) { + task.catch(() => {}) // avoid unhandled rejection crashes + if (!this.waitUntil) { + errorWaitUntilNotAvailable() + } + this.waitUntil(task) + } else if (typeof task === 'function') { + // TODO(after): implement tracing + this.addCallback(task) + } else { + throw new Error( + '`unstable_after()`: Argument must be a promise or a function' + ) + } + } + + private addCallback(callback: AfterCallback) { + // if something is wrong, throw synchronously, bubbling up to the `unstable_after` callsite. + if (!this.waitUntil) { + errorWaitUntilNotAvailable() + } + if (!this.requestStore) { + throw new InvariantError( + 'unstable_after: Expected `AfterContext.requestStore` to be initialized' + ) + } + if (!this.onClose) { + throw new InvariantError( + 'unstable_after: Missing `onClose` implementation' + ) + } + if (this.afterCallbacks.length === 0) { + this.waitUntil(this.runCallbacksOnClose()) + } + this.afterCallbacks.push(callback) + } + + private async runCallbacksOnClose() { + await new Promise((resolve) => this.onClose!(resolve)) + return this.runCallbacks(this.requestStore!) + } + + private async runCallbacks(requestStore: RequestStore): Promise { + if (this.afterCallbacks.length === 0) return + + const runCallbacksImpl = async () => { + // TODO(after): we should consider limiting the parallelism here via something like `p-queue`. + // (having a queue will also be needed for after-within-after, so this'd solve two problems at once). + await Promise.all( + this.afterCallbacks.map(async (afterCallback) => { + try { + await afterCallback() + } catch (err) { + // TODO(after): this is fine for now, but will need better intergration with our error reporting. + console.error( + 'An error occurred in a function passed to `unstable_after()`:', + err + ) + } + }) + ) + } + + const readonlyRequestStore: RequestStore = + wrapRequestStoreForAfterCallbacks(requestStore) + + return requestAsyncStorage.run(readonlyRequestStore, () => { + if (this.cacheScope) { + return this.cacheScope.run(runCallbacksImpl) + } else { + return runCallbacksImpl() + } + }) + } +} + +function errorWaitUntilNotAvailable(): never { + throw new Error( + '`unstable_after()` will not work correctly, because `waitUntil` is not available in the current environment.' + ) +} + +/** Disable mutations of `requestStore` within `after()` and disallow nested after calls. */ +function wrapRequestStoreForAfterCallbacks( + requestStore: RequestStore +): RequestStore { + return { + get headers() { + return requestStore.headers + }, + get cookies() { + return requestStore.cookies + }, + get draftMode() { + return requestStore.draftMode + }, + // TODO(after): calling a `cookies.set()` in an after() that's in an action doesn't currently error. + mutableCookies: new ResponseCookies(new Headers()), + assetPrefix: requestStore.assetPrefix, + reactLoadableManifest: requestStore.reactLoadableManifest, + + afterContext: { + after: () => { + throw new Error( + 'Calling `unstable_after()` from within `unstable_after()` is not supported yet.' + ) + }, + run: () => { + throw new InvariantError( + 'unstable_after: Cannot call `AfterContext.run()` from within an `unstable_after()` callback' + ) + }, + }, + } +} + +function isPromise(p: unknown): p is Promise { + return ( + p !== null && + typeof p === 'object' && + 'then' in p && + typeof p.then === 'function' + ) +} diff --git a/packages/next/src/server/after/after.ts b/packages/next/src/server/after/after.ts new file mode 100644 index 0000000000000..d9863a9048ad2 --- /dev/null +++ b/packages/next/src/server/after/after.ts @@ -0,0 +1,40 @@ +import { getExpectedRequestStore } from '../../client/components/request-async-storage.external' +import { staticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage.external' +import { StaticGenBailoutError } from '../../client/components/static-generation-bailout' +import { getPathname } from '../../lib/url' + +import { markCurrentScopeAsDynamic } from '../app-render/dynamic-rendering' + +export type AfterTask = Promise | AfterCallback +export type AfterCallback = () => T | Promise + +/** + * This function allows you to schedule callbacks to be executed after the current request finishes. + */ +export function unstable_after(task: AfterTask) { + const callingExpression = 'unstable_after' + + const requestStore = getExpectedRequestStore(callingExpression) + + const { afterContext } = requestStore + if (!afterContext) { + throw new Error( + '`unstable_after()` must be explicitly enabled by setting `experimental.after: true` in your next.config.js.' + ) + } + + const staticGenerationStore = staticGenerationAsyncStorage.getStore() + + if (staticGenerationStore) { + if (staticGenerationStore.forceStatic) { + const pathname = getPathname(staticGenerationStore.urlPathname) + throw new StaticGenBailoutError( + `Route ${pathname} with \`dynamic = "force-static"\` couldn't be rendered statically because it used \`${callingExpression}\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering` + ) + } else { + markCurrentScopeAsDynamic(staticGenerationStore, callingExpression) + } + } + + return afterContext.after(task) +} diff --git a/packages/next/src/server/after/index.ts b/packages/next/src/server/after/index.ts new file mode 100644 index 0000000000000..1dfec4b048468 --- /dev/null +++ b/packages/next/src/server/after/index.ts @@ -0,0 +1 @@ +export * from './after' diff --git a/packages/next/src/server/after/react-cache-scope.ts b/packages/next/src/server/after/react-cache-scope.ts new file mode 100644 index 0000000000000..b832734fd0c9b --- /dev/null +++ b/packages/next/src/server/after/react-cache-scope.ts @@ -0,0 +1,160 @@ +import { AsyncLocalStorage } from 'async_hooks' +import { InvariantError } from '../../shared/lib/invariant-error' + +export function createCacheScope() { + const storage = createCacheMap() + return { + run: (callback: () => T): T => { + return CacheScopeStorage.run(storage, () => callback()) + }, + } +} + +export type CacheScope = ReturnType + +// #region custom cache dispatcher with support for scoping + +// Note that the plan is to upstream this into React itself, +// but after() is an experimental feature, and this should be good enough for now. + +type CacheDispatcher = { + [HAS_CACHE_SCOPE]?: boolean + getCacheForType: (create: () => T) => T + // DEV-only (or !disableStringRefs) + getOwner?: () => null | Record +} + +const HAS_CACHE_SCOPE: unique symbol = Symbol.for('next.cacheScope') + +type CacheMap = Map + +function createCacheMap(): CacheMap { + return new Map() +} + +function isWithinCacheScope() { + return !!CacheScopeStorage.getStore() +} + +const CacheScopeStorage: AsyncLocalStorage = + new AsyncLocalStorage() + +/** forked from packages/react-server/src/flight/ReactFlightServerCache.js */ +function resolveCache(): CacheMap { + const store = CacheScopeStorage.getStore() + if (store) { + return store + } + return createCacheMap() +} + +/** forked from packages/react-server/src/flight/ReactFlightServerCache.js */ +const ScopedCacheDispatcher: CacheDispatcher = { + getCacheForType(resourceType: () => T): T { + if (!isWithinCacheScope()) { + throw new InvariantError( + 'Expected patched cache dispatcher to run within CacheScopeStorage' + ) + } + const cache = resolveCache() + let entry: T | undefined = cache.get(resourceType) as any + if (entry === undefined) { + entry = resourceType() + // TODO: Warn if undefined? + cache.set(resourceType, entry) + } + return entry + }, +} + +// #endregion + +// #region injecting the patched dispatcher into React + +export function patchCacheScopeSupportIntoReact(React: typeof import('react')) { + const internals = getReactServerInternals(React) + if ('A' in internals) { + if (internals.A) { + patchReactCacheDispatcher(internals.A) + } else { + patchReactCacheDispatcherWhenSet(internals, 'A') + } + } else { + throw new InvariantError( + 'Could not find cache dispatcher in React internals' + ) + } +} + +function patchReactCacheDispatcher(dispatcher: CacheDispatcher) { + if (dispatcher[HAS_CACHE_SCOPE]) { + return + } + const { getCacheForType: originalGetCacheForType } = dispatcher + + dispatcher.getCacheForType = function ( + this: CacheDispatcher, + create: () => T + ) { + if (isWithinCacheScope()) { + return ScopedCacheDispatcher.getCacheForType(create) + } + return originalGetCacheForType.call(this, create) as T + } + dispatcher[HAS_CACHE_SCOPE] = true +} + +function patchReactCacheDispatcherWhenSet< + Container extends Record & { [HAS_CACHE_SCOPE]?: boolean }, +>(container: Container, key: keyof Container) { + if (container[HAS_CACHE_SCOPE]) { + return + } + + let current: CacheDispatcher | null = null + Object.defineProperty(container, key, { + get: () => current, + set: (maybeDispatcher) => { + try { + if (maybeDispatcher) { + patchReactCacheDispatcher(maybeDispatcher) + } + } catch (err) { + throw new InvariantError('Could not patch the React cache dispatcher', { + cause: err, + }) + } + current = maybeDispatcher + }, + }) + container[HAS_CACHE_SCOPE] = true +} + +type ReactWithServerInternals = typeof import('react') & + ReactServerInternalProperties + +type ReactServerInternalProperties = { + __SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: ReactServerSharedInternals +} + +type ReactServerSharedInternals = { + [HAS_CACHE_SCOPE]?: boolean + A: CacheDispatcher | null +} + +const INTERNALS_KEY = + '__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE' + +function getReactServerInternals( + React: typeof import('react') +): ReactServerSharedInternals { + const _React = React as ReactWithServerInternals + + if (INTERNALS_KEY in _React) { + return _React[INTERNALS_KEY] + } + + throw new InvariantError('Could not access React server internals') +} + +// #endregion diff --git a/packages/next/src/server/after/wait-until-builtin.ts b/packages/next/src/server/after/wait-until-builtin.ts new file mode 100644 index 0000000000000..af4f1e4db2082 --- /dev/null +++ b/packages/next/src/server/after/wait-until-builtin.ts @@ -0,0 +1,27 @@ +export type WaitUntil = (promise: Promise) => void + +export function getBuiltinWaitUntil(): WaitUntil | undefined { + const _globalThis = globalThis as GlobalThisWithRequestContext + const ctx = + _globalThis[NEXT_REQUEST_CONTEXT_SYMBOL] ?? + _globalThis[VERCEL_REQUEST_CONTEXT_SYMBOL] + return ctx?.get()?.waitUntil +} + +/** This should be considered unstable until `unstable_after` is stablized. */ +const NEXT_REQUEST_CONTEXT_SYMBOL = Symbol.for('@next/request-context') + +// TODO(after): this is a temporary workaround. +// Remove this when vercel builder is updated to provide '@next/request-context'. +const VERCEL_REQUEST_CONTEXT_SYMBOL = Symbol.for('@vercel/request-context') + +type GlobalThisWithRequestContext = typeof globalThis & { + [NEXT_REQUEST_CONTEXT_SYMBOL]?: RequestContext + /** @deprecated */ + [VERCEL_REQUEST_CONTEXT_SYMBOL]?: RequestContext +} + +/** This should be considered unstable until `unstable_after` is stablized. */ +type RequestContext = { + get(): { waitUntil: WaitUntil } | undefined +} diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 23facb23c4902..f0672fe11a8dc 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -764,6 +764,10 @@ async function renderToHTMLOrFlightImpl( ComponentMod.patchFetch() + if (renderOpts.experimental.after) { + ComponentMod.patchCacheScopeSupportIntoReact() + } + /** * Rules of Static & Dynamic HTML: * diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index 52decc09a7219..a45eb52fe54c1 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -31,6 +31,16 @@ import { import { Postpone } from '../../server/app-render/rsc/postpone' import { taintObjectReference } from '../../server/app-render/rsc/taint' +import * as React from 'react' +import { + patchCacheScopeSupportIntoReact as _patchCacheScopeSupportIntoReact, + createCacheScope, +} from '../after/react-cache-scope' + +function patchCacheScopeSupportIntoReact() { + _patchCacheScopeSupportIntoReact(React) +} + // patchFetch makes use of APIs such as `React.unstable_postpone` which are only available // in the experimental channel of React, so export it from here so that it comes from the bundled runtime function patchFetch() { @@ -55,4 +65,6 @@ export { ClientPageRoot, NotFoundBoundary, patchFetch, + createCacheScope, + patchCacheScopeSupportIntoReact, } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 7709994149763..da7a5760362a5 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -10,6 +10,7 @@ import type { LoadingModuleData } from '../../shared/lib/app-router-context.shar import type { DeepReadonly } from '../../shared/lib/deep-readonly' import s from 'next/dist/compiled/superstruct' +import type { RequestLifecycleOpts } from '../base-server' export type DynamicParamTypes = | 'catchall' @@ -171,6 +172,7 @@ export interface RenderOptsPartial { isRoutePPREnabled?: boolean swrDelta: SwrDelta | undefined clientTraceMetadata: string[] | undefined + after: boolean } postponed?: string /** @@ -182,4 +184,5 @@ export interface RenderOptsPartial { } export type RenderOpts = LoadComponentsReturnType & - RenderOptsPartial + RenderOptsPartial & + RequestLifecycleOpts diff --git a/packages/next/src/server/async-storage/request-async-storage-wrapper.ts b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts index 634e4c13be2ee..f597987da07d5 100644 --- a/packages/next/src/server/async-storage/request-async-storage-wrapper.ts +++ b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts @@ -20,6 +20,8 @@ import { import { ResponseCookies, RequestCookies } from '../web/spec-extension/cookies' import { DraftModeProvider } from './draft-mode-provider' import { splitCookiesString } from '../web/utils' +import { createAfterContext, type AfterContext } from '../after/after-context' +import type { RequestLifecycleOpts } from '../base-server' function getHeaders(headers: Headers | IncomingHttpHeaders): ReadonlyHeaders { const cleaned = HeadersAdapter.from(headers) @@ -38,10 +40,21 @@ function getMutableCookies( return MutableRequestCookiesAdapter.wrap(cookies, onUpdateCookies) } +export type WrapperRenderOpts = Omit & + RequestLifecycleOpts & + Partial< + Pick< + RenderOpts, + 'ComponentMod' // can be undefined in a route handler + > + > & { + experimental: Pick + } + export type RequestContext = { req: IncomingMessage | BaseNextRequest | NextRequest res?: ServerResponse | BaseNextResponse - renderOpts?: RenderOpts + renderOpts?: WrapperRenderOpts } export const RequestAsyncStorageWrapper: AsyncStorageWrapper< @@ -69,6 +82,8 @@ export const RequestAsyncStorageWrapper: AsyncStorageWrapper< previewProps = (renderOpts as any).previewProps } + const [wrapWithAfter, afterContext] = createAfterWrapper(renderOpts) + function defaultOnUpdateCookies(cookies: string[]) { if (res) { res.setHeader('Set-Cookie', cookies) @@ -148,10 +163,37 @@ export const RequestAsyncStorageWrapper: AsyncStorageWrapper< return cache.draftMode }, + reactLoadableManifest: renderOpts?.reactLoadableManifest || {}, assetPrefix: renderOpts?.assetPrefix || '', + afterContext, } - - return storage.run(store, callback, store) + return wrapWithAfter(store, () => storage.run(store, callback, store)) }, } + +function createAfterWrapper( + renderOpts: WrapperRenderOpts | undefined +): [ + wrap: (requestStore: RequestStore, callback: () => Result) => Result, + afterContext: AfterContext | undefined, +] { + const isAfterEnabled = renderOpts?.experimental?.after ?? false + if (!renderOpts || !isAfterEnabled) { + return [(_, callback) => callback(), undefined] + } + + const { waitUntil, onClose } = renderOpts + const cacheScope = renderOpts.ComponentMod?.createCacheScope() + + const afterContext = createAfterContext({ + waitUntil, + onClose, + cacheScope, + }) + + const wrap = (requestStore: RequestStore, callback: () => Result) => + afterContext.run(requestStore, callback) + + return [wrap, afterContext] +} diff --git a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts index bf9fcb863ab98..d49ce0a56aaf2 100644 --- a/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts +++ b/packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts @@ -6,6 +6,7 @@ import type { RenderOptsPartial } from '../app-render/types' import { createPrerenderState } from '../../server/app-render/dynamic-rendering' import type { FetchMetric } from '../base-http' +import type { RequestLifecycleOpts } from '../base-server' export type StaticGenerationContext = { urlPathname: string @@ -15,8 +16,11 @@ export type StaticGenerationContext = { isOnDemandRevalidate?: boolean fetchCache?: StaticGenerationStore['fetchCache'] isServerAction?: boolean - waitUntil?: Promise - experimental?: Pick + pendingWaitUntil?: Promise + experimental: Pick< + RenderOptsPartial['experimental'], + 'isRoutePPREnabled' | 'after' + > /** * Fetch metrics attached in patch-fetch.ts @@ -42,7 +46,8 @@ export type StaticGenerationContext = { | 'nextExport' | 'isDraftMode' | 'isDebugPPRSkeleton' - > + > & + Partial } export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper< diff --git a/packages/next/src/server/base-http/index.ts b/packages/next/src/server/base-http/index.ts index 3b886ca580f7a..ad7c0ad0c2fb4 100644 --- a/packages/next/src/server/base-http/index.ts +++ b/packages/next/src/server/base-http/index.ts @@ -83,6 +83,8 @@ export abstract class BaseNextResponse { abstract send(): void + abstract onClose(callback: () => void): void + // Utils implemented using the abstract methods above public redirect(destination: string, statusCode: number) { diff --git a/packages/next/src/server/base-http/node.ts b/packages/next/src/server/base-http/node.ts index fe41e84182a99..4e83dd29e49c2 100644 --- a/packages/next/src/server/base-http/node.ts +++ b/packages/next/src/server/base-http/node.ts @@ -162,4 +162,8 @@ export class NodeNextResponse extends BaseNextResponse { send() { this._res.end(this.textBody) } + + public onClose(callback: () => void) { + this.originalResponse.on('close', callback) + } } diff --git a/packages/next/src/server/base-http/web.test.ts b/packages/next/src/server/base-http/web.test.ts new file mode 100644 index 0000000000000..d5f00ea8f454e --- /dev/null +++ b/packages/next/src/server/base-http/web.test.ts @@ -0,0 +1,47 @@ +import { WebNextResponse } from './web' + +describe('WebNextResponse onClose', () => { + it("doesn't track onClose unless enabled", () => { + const webNextResponse = new WebNextResponse(undefined, false).body('abcdef') + expect(() => webNextResponse.onClose(() => {})).toThrow() + }) + it('stream body', async () => { + const cb = jest.fn() + const ts = new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk) + }, + }) + + const webNextResponse = new WebNextResponse(ts, true) + webNextResponse.onClose(cb) + webNextResponse.send() + expect(cb).toHaveBeenCalledTimes(0) + const response = await webNextResponse.toResponse() + expect(cb).toHaveBeenCalledTimes(0) + const t = response.text() + + const encoder = new TextEncoder() + const writer = ts.writable.getWriter() + await writer.write(encoder.encode('abc')) + await writer.write(encoder.encode('def')) + await writer.close() + + const text = await t + expect(cb).toHaveBeenCalledTimes(1) + expect(text).toBe('abcdef') + }) + + it('string body', async () => { + const cb = jest.fn() + const webNextResponse = new WebNextResponse(undefined, true).body('abcdef') + webNextResponse.onClose(cb) + webNextResponse.send() + expect(cb).toHaveBeenCalledTimes(0) + const response = await webNextResponse.toResponse() + expect(cb).toHaveBeenCalledTimes(0) + const text = await response.text() + expect(cb).toHaveBeenCalledTimes(1) + expect(text).toBe('abcdef') + }) +}) diff --git a/packages/next/src/server/base-http/web.ts b/packages/next/src/server/base-http/web.ts index 58df722ffa20a..1d0c17cabbfaf 100644 --- a/packages/next/src/server/base-http/web.ts +++ b/packages/next/src/server/base-http/web.ts @@ -5,6 +5,8 @@ import { toNodeOutgoingHttpHeaders } from '../web/utils' import { BaseNextRequest, BaseNextResponse } from './index' import { DetachedPromise } from '../../lib/detached-promise' import type { NextRequestHint } from '../web/adapter' +import { CloseController, trackBodyConsumed } from '../web/web-on-close' +import { InvariantError } from '../../shared/lib/invariant-error' export class WebNextRequest extends BaseNextRequest { public request: Request @@ -37,10 +39,15 @@ export class WebNextResponse extends BaseNextResponse { private headers = new Headers() private textBody: string | undefined = undefined + private closeController = new CloseController() + public statusCode: number | undefined public statusMessage: string | undefined - constructor(public transformStream = new TransformStream()) { + constructor( + public transformStream = new TransformStream(), + private trackOnClose = false + ) { super(transformStream.writable) } @@ -87,6 +94,7 @@ export class WebNextResponse extends BaseNextResponse { } private readonly sendPromise = new DetachedPromise() + private _sent = false public send() { this.sendPromise.resolve() @@ -101,10 +109,39 @@ export class WebNextResponse extends BaseNextResponse { // If we haven't called `send` yet, wait for it to be called. if (!this.sent) await this.sendPromise.promise - return new Response(this.textBody ?? this.transformStream.readable, { + const body = this.textBody ?? this.transformStream.readable + + let bodyInit: BodyInit = body + + const canAddListenersLater = typeof bodyInit !== 'string' + const shouldTrackBody = + this.trackOnClose && + (canAddListenersLater ? true : this.closeController.listeners > 0) + + if (shouldTrackBody) { + bodyInit = trackBodyConsumed(body, () => { + this.closeController.dispatchClose() + }) + } + + return new Response(bodyInit, { headers: this.headers, status: this.statusCode, statusText: this.statusMessage, }) } + + public onClose(callback: () => void) { + if (!this.trackOnClose) { + throw new InvariantError( + 'Cannot call onClose on a WebNextResponse initialized with `trackOnClose = false`' + ) + } + if (this.sent) { + throw new InvariantError( + 'Cannot call onClose on a response that is already sent' + ) + } + return this.closeController.onClose(callback) + } } diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 48a5e45e64022..f5cc8c5eb3f27 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -143,6 +143,7 @@ import type { DeepReadonly } from '../shared/lib/deep-readonly' import { isNodeNextRequest, isNodeNextResponse } from './base-http/helpers' import { patchSetHeaderWithCookieSupport } from './lib/patch-set-header' import { checkIsAppPPREnabled } from './lib/experimental/ppr' +import { getBuiltinWaitUntil } from './after/wait-until-builtin' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -229,7 +230,14 @@ export interface Options { export type RenderOpts = PagesRenderOptsPartial & AppRenderOptsPartial -export type LoadedRenderOpts = RenderOpts & LoadComponentsReturnType +export type LoadedRenderOpts = RenderOpts & + LoadComponentsReturnType & + RequestLifecycleOpts + +export type RequestLifecycleOpts = { + waitUntil: ((promise: Promise) => void) | undefined + onClose: ((callback: () => void) => void) | undefined +} type BaseRenderOpts = RenderOpts & { poweredByHeader: boolean @@ -557,6 +565,7 @@ export default abstract class Server< isAppPPREnabled, swrDelta: this.nextConfig.experimental.swrDelta, clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata, + after: this.nextConfig.experimental.after ?? false, }, } @@ -1655,6 +1664,26 @@ export default abstract class Server< ) } + private getWaitUntil() { + const useBuiltinWaitUntil = + process.env.NEXT_RUNTIME === 'edge' || this.minimalMode + + let waitUntil = useBuiltinWaitUntil ? getBuiltinWaitUntil() : undefined + + if (!waitUntil) { + // if we're not running in a serverless environment, + // we don't actually need waitUntil -- the server will stay alive anyway. + // the only thing we want to do is prevent unhandled rejections. + waitUntil = function noopWaitUntil(promise) { + promise.catch((err: unknown) => { + console.error(err) + }) + } + } + + return waitUntil + } + private async renderImpl( req: ServerRequest, res: ServerResponse, @@ -2284,7 +2313,6 @@ export default abstract class Server< // make sure to only add query values from original URL query: origQuery, }) - const renderOpts: LoadedRenderOpts = { ...components, ...opts, @@ -2326,6 +2354,8 @@ export default abstract class Server< isDraftMode: isPreviewMode, isServerAction, postponed, + waitUntil: this.getWaitUntil(), + onClose: res.onClose.bind(res), } if (isDebugPPRSkeleton) { @@ -2359,10 +2389,15 @@ export default abstract class Server< params: opts.params, prerenderManifest, renderOpts: { + experimental: { + after: renderOpts.experimental.after, + }, originalPathname: components.ComponentMod.originalPathname, supportsDynamicHTML, incrementalCache, isRevalidate: isSSG, + waitUntil: this.getWaitUntil(), + onClose: res.onClose.bind(res), }, } @@ -2413,7 +2448,12 @@ export default abstract class Server< } // Send the response now that we have copied it into the cache. - await sendResponse(req, res, response, context.renderOpts.waitUntil) + await sendResponse( + req, + res, + response, + context.renderOpts.pendingWaitUntil + ) return null } catch (err) { // If this is during static generation, throw the error again. diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index 4d68c6476243e..5fbcf7832ef52 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -248,6 +248,7 @@ export const configSchema: zod.ZodType = z.lazy(() => excludeDefaultMomentLocales: z.boolean().optional(), experimental: z .strictObject({ + after: z.boolean().optional(), appDocumentPreloading: z.boolean().optional(), preloadEntriesOnStart: z.boolean().optional(), adjustFontFallbacks: z.boolean().optional(), diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index c112cff43179f..703e59d50b648 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -469,6 +469,11 @@ export interface ExperimentalConfig { * compiler will be enabled. */ reactCompiler?: boolean | ReactCompilerOptions + + /** + * Enables `unstable_after` + */ + after?: boolean } export type ExportPathMap = { @@ -963,6 +968,7 @@ export const defaultConfig: NextConfig = { }, allowDevelopmentBuild: undefined, reactCompiler: undefined, + after: false, }, bundlePagesRouterDependencies: false, } diff --git a/packages/next/src/server/future/route-modules/app-route/module.ts b/packages/next/src/server/future/route-modules/app-route/module.ts index 10671285fa829..c1ad6f1a4e2ee 100644 --- a/packages/next/src/server/future/route-modules/app-route/module.ts +++ b/packages/next/src/server/future/route-modules/app-route/module.ts @@ -250,9 +250,12 @@ export class AppRouteRouteModule extends RouteModule< req: rawRequest, } - // TODO: types for renderOpts should include previewProps - ;(requestContext as any).renderOpts = { + requestContext.renderOpts = { + // @ts-expect-error TODO: types for renderOpts should include previewProps previewProps: context.prerenderManifest.preview, + waitUntil: context.renderOpts.waitUntil, + onClose: context.renderOpts.onClose, + experimental: context.renderOpts.experimental, } // Get the context for the static generation. @@ -395,7 +398,7 @@ export class AppRouteRouteModule extends RouteModule< context.renderOpts.fetchMetrics = staticGenerationStore.fetchMetrics - context.renderOpts.waitUntil = Promise.all([ + context.renderOpts.pendingWaitUntil = Promise.all([ staticGenerationStore.incrementalCache?.revalidateTag( staticGenerationStore.revalidatedTags || [] ), diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 953b85d8ae863..e5721706faa02 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -1592,6 +1592,7 @@ export default class NextNodeServer extends BaseServer< basePath: this.nextConfig.basePath, i18n: this.nextConfig.i18n, trailingSlash: this.nextConfig.trailingSlash, + experimental: this.nextConfig.experimental, }, url: url, page, diff --git a/packages/next/src/server/web/adapter.ts b/packages/next/src/server/web/adapter.ts index c74f4cddfe37d..18030b38e6bf5 100644 --- a/packages/next/src/server/web/adapter.ts +++ b/packages/next/src/server/web/adapter.ts @@ -13,11 +13,15 @@ import { stripInternalSearchParams } from '../internal-utils' import { normalizeRscURL } from '../../shared/lib/router/utils/app-paths' import { FLIGHT_PARAMETERS } from '../../client/components/app-router-headers' import { ensureInstrumentationRegistered } from './globals' -import { RequestAsyncStorageWrapper } from '../async-storage/request-async-storage-wrapper' +import { + RequestAsyncStorageWrapper, + type WrapperRenderOpts, +} from '../async-storage/request-async-storage-wrapper' import { requestAsyncStorage } from '../../client/components/request-async-storage.external' import { getTracer } from '../lib/trace/tracer' import type { TextMapGetter } from 'next/dist/compiled/@opentelemetry/api' import { MiddlewareSpan } from '../lib/trace/constants' +import { CloseController } from './web-on-close' export class NextRequestHint extends NextRequest { sourcePage: string @@ -208,7 +212,23 @@ export async function adapter( // we only care to make async storage available for middleware const isMiddleware = params.page === '/middleware' || params.page === '/src/middleware' + if (isMiddleware) { + // if we're in an edge function, we only get a subset of `nextConfig` (no `experimental`), + // so we have to inject it via DefinePlugin. + // in `next start` this will be passed normally (see `NextNodeServer.runMiddleware`). + const isAfterEnabled = + params.request.nextConfig?.experimental?.after ?? + !!process.env.__NEXT_AFTER + + let waitUntil: WrapperRenderOpts['waitUntil'] = undefined + let closeController: CloseController | undefined = undefined + + if (isAfterEnabled) { + waitUntil = event.waitUntil.bind(event) + closeController = new CloseController() + } + return getTracer().trace( MiddlewareSpan.execute, { @@ -218,25 +238,45 @@ export async function adapter( 'http.method': request.method, }, }, - () => - RequestAsyncStorageWrapper.wrap( - requestAsyncStorage, - { - req: request, - renderOpts: { - onUpdateCookies: (cookies) => { - cookiesFromResponse = cookies - }, - // @ts-expect-error: TODO: investigate why previewProps isn't on RenderOpts - previewProps: prerenderManifest?.preview || { - previewModeId: 'development-id', - previewModeEncryptionKey: '', - previewModeSigningKey: '', + async () => { + try { + return await RequestAsyncStorageWrapper.wrap( + requestAsyncStorage, + { + req: request, + renderOpts: { + onUpdateCookies: (cookies) => { + cookiesFromResponse = cookies + }, + // @ts-expect-error TODO: investigate why previewProps isn't on RenderOpts + previewProps: prerenderManifest?.preview || { + previewModeId: 'development-id', + previewModeEncryptionKey: '', + previewModeSigningKey: '', + }, + waitUntil, + onClose: closeController + ? closeController.onClose.bind(closeController) + : undefined, + experimental: { + after: isAfterEnabled, + }, }, }, - }, - () => params.handler(request, event) - ) + () => params.handler(request, event) + ) + } finally { + // middleware cannot stream, so we can consider the response closed + // as soon as the handler returns. + if (closeController) { + // we can delay running it until a bit later -- + // if it's needed, we'll have a `waitUntil` lock anyway. + setTimeout(() => { + closeController!.dispatchClose() + }, 0) + } + } + } ) } return params.handler(request, event) diff --git a/packages/next/src/server/web/edge-route-module-wrapper.ts b/packages/next/src/server/web/edge-route-module-wrapper.ts index acd2c57b32816..7ba39c1931fa7 100644 --- a/packages/next/src/server/web/edge-route-module-wrapper.ts +++ b/packages/next/src/server/web/edge-route-module-wrapper.ts @@ -14,6 +14,8 @@ import type { NextFetchEvent } from './spec-extension/fetch-event' import { internal_getCurrentFunctionWaitUntil } from './internal-edge-wait-until' import { getUtils } from '../server-utils' import { searchParamsToUrlQuery } from '../../shared/lib/router/utils/querystring' +import type { RequestLifecycleOpts } from '../base-server' +import { CloseController, trackStreamConsumed } from './web-on-close' type WrapOptions = Partial> @@ -87,6 +89,16 @@ export class EdgeRouteModuleWrapper { ? JSON.parse(self.__PRERENDER_MANIFEST) : undefined + const isAfterEnabled = !!process.env.__NEXT_AFTER + + let waitUntil: RequestLifecycleOpts['waitUntil'] = undefined + let closeController: CloseController | undefined + + if (isAfterEnabled) { + waitUntil = evt.waitUntil.bind(evt) + closeController = new CloseController() + } + // Create the context for the handler. This contains the params from the // match (if any). const context: AppRouteRouteHandlerContext = { @@ -104,18 +116,46 @@ export class EdgeRouteModuleWrapper { }, renderOpts: { supportsDynamicHTML: true, + waitUntil, + onClose: closeController + ? closeController.onClose.bind(closeController) + : undefined, + experimental: { + after: isAfterEnabled, + }, }, } // Get the response from the handler. - const res = await this.routeModule.handle(request, context) + let res = await this.routeModule.handle(request, context) const waitUntilPromises = [internal_getCurrentFunctionWaitUntil()] - if (context.renderOpts.waitUntil) { - waitUntilPromises.push(context.renderOpts.waitUntil) + if (context.renderOpts.pendingWaitUntil) { + waitUntilPromises.push(context.renderOpts.pendingWaitUntil) } evt.waitUntil(Promise.all(waitUntilPromises)) + if (closeController) { + const _closeController = closeController // TS annoyance - "possibly undefined" in callbacks + + if (!res.body) { + // we can delay running it until a bit later -- + // if it's needed, we'll have a `waitUntil` lock anyway. + setTimeout(() => _closeController.dispatchClose(), 0) + } else { + // NOTE: if this is a streaming response, onClose may be called later, + // so we can't rely on `closeController.listeners` -- it might be 0 at this point. + const trackedBody = trackStreamConsumed(res.body, () => + _closeController.dispatchClose() + ) + res = new Response(trackedBody, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }) + } + } + return res } } diff --git a/packages/next/src/server/web/exports/index.ts b/packages/next/src/server/web/exports/index.ts index 1bc0d6a821b9b..942bc5a9caf9a 100644 --- a/packages/next/src/server/web/exports/index.ts +++ b/packages/next/src/server/web/exports/index.ts @@ -5,3 +5,4 @@ export { NextRequest } from '../spec-extension/request' export { NextResponse } from '../spec-extension/response' export { userAgent, userAgentFromString } from '../spec-extension/user-agent' export { URLPattern } from '../spec-extension/url-pattern' +export { unstable_after } from '../../after' diff --git a/packages/next/src/server/web/types.ts b/packages/next/src/server/web/types.ts index b7e14f99f6a00..c27d3135f066b 100644 --- a/packages/next/src/server/web/types.ts +++ b/packages/next/src/server/web/types.ts @@ -1,4 +1,4 @@ -import type { I18NConfig } from '../config-shared' +import type { ExperimentalConfig, I18NConfig } from '../config-shared' import type { NextRequest } from './spec-extension/request' import type { NextFetchEvent } from './spec-extension/fetch-event' import type { NextResponse } from './spec-extension/response' @@ -23,6 +23,7 @@ export interface RequestData { basePath?: string i18n?: I18NConfig | null trailingSlash?: boolean + experimental?: Pick } page?: { name?: string diff --git a/packages/next/src/server/web/web-on-close.ts b/packages/next/src/server/web/web-on-close.ts new file mode 100644 index 0000000000000..775e5bf2efccb --- /dev/null +++ b/packages/next/src/server/web/web-on-close.ts @@ -0,0 +1,56 @@ +/** Monitor when the consumer finishes reading the response body. +that's as close as we can get to `res.on('close')` using web APIs. +*/ +export function trackBodyConsumed( + body: string | ReadableStream, + onEnd: () => void +): BodyInit { + if (typeof body === 'string') { + const generator = async function* generate() { + const encoder = new TextEncoder() + yield encoder.encode(body) + onEnd() + } + // @ts-expect-error BodyInit typings doesn't seem to include AsyncIterables even though it's supported in practice + return generator() + } else { + return trackStreamConsumed(body, onEnd) + } +} + +export function trackStreamConsumed( + stream: ReadableStream, + onEnd: () => void +): ReadableStream { + const closePassThrough = new TransformStream({ + flush: () => { + return onEnd() + }, + }) + return stream.pipeThrough(closePassThrough) +} + +export class CloseController { + private target = new EventTarget() + listeners = 0 + isClosed = false + + onClose(callback: () => void) { + if (this.isClosed) { + throw new Error('Cannot subscribe to a closed CloseController') + } + + this.target.addEventListener('close', callback) + this.listeners++ + } + + dispatchClose() { + if (this.isClosed) { + throw new Error('Cannot close a CloseController multiple times') + } + if (this.listeners > 0) { + this.target.dispatchEvent(new Event('close')) + } + this.isClosed = true + } +} diff --git a/packages/next/src/shared/lib/invariant-error.ts b/packages/next/src/shared/lib/invariant-error.ts new file mode 100644 index 0000000000000..db9f7147beb4f --- /dev/null +++ b/packages/next/src/shared/lib/invariant-error.ts @@ -0,0 +1,9 @@ +export class InvariantError extends Error { + constructor(message: string, options?: ErrorOptions) { + super( + `Invariant: ${message.endsWith('.') ? message : message + '.'} This is a bug in Next.js.`, + options + ) + this.name = 'InvariantError' + } +} diff --git a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts index bfeb1898a2b76..9c739216aeed5 100644 --- a/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts +++ b/test/development/acceptance/server-component-compiler-errors-in-pages.test.ts @@ -150,4 +150,62 @@ describe('Error Overlay for server components compiler errors in pages', () => { } await cleanup() }) + + test("importing unstable_after from 'next/server' in pages", async () => { + const { session, cleanup } = await sandbox(next, initialFiles) + + await next.patchFile( + 'components/Comp.js', + outdent` + import { unstable_after } from 'next/server' + + export default function Page() { + return 'hello world' + } + ` + ) + + expect(await session.hasRedbox()).toBe(true) + await check( + () => session.getRedboxSource(), + /That only works in a Server Component/ + ) + + if (process.env.TURBOPACK) { + expect(next.normalizeTestDirContent(await session.getRedboxSource())) + .toMatchInlineSnapshot(` + "./components/Comp.js:1:10 + Ecmascript file had an error + > 1 | import { unstable_after } from 'next/server' + | ^^^^^^^^^^^^^^ + 2 | + 3 | export default function Page() { + 4 | return 'hello world' + + You're importing a component that needs "unstable_after". That only works in a Server Component which is not supported in the pages/ directory. Read more: https://nextjs.org/docs/getting-started/react-essentials#server-components" + `) + } else { + expect(next.normalizeTestDirContent(await session.getRedboxSource())) + .toMatchInlineSnapshot(` + "./components/Comp.js + Error: + x You're importing a component that needs "unstable_after". That only works in a Server Component which is not supported in the pages/ directory. Read more: https://nextjs.org/docs/getting- + | started/react-essentials#server-components + | + | + ,-[TEST_DIR/components/Comp.js:1:1] + 1 | import { unstable_after } from 'next/server' + : ^^^^^^^^^^^^^^ + 2 | + 3 | export default function Page() { + 4 | return 'hello world' + \`---- + + Import trace for requested module: + ./components/Comp.js + ./pages/index.js" + `) + } + await cleanup() + }) }) diff --git a/test/e2e/app-dir/next-after-app/app/[id]/dynamic/page.js b/test/e2e/app-dir/next-after-app/app/[id]/dynamic/page.js new file mode 100644 index 0000000000000..3794116ed4e8a --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/[id]/dynamic/page.js @@ -0,0 +1,27 @@ +import { unstable_after as after } from 'next/server' +import { cache } from 'react' +import { cliLog } from '../../../utils/log' +import { headers } from 'next/headers' + +const thing = cache(() => Symbol('cache me please')) + +export default function Index({ params }) { + const hostFromRender = headers().get('host') + const valueFromRender = thing() + + after(() => { + const hostFromAfter = headers().get('host') + const valueFromAfter = thing() + + cliLog({ + source: '[page] /[id]/dynamic', + value: params.id, + assertions: { + 'cache() works in after()': valueFromRender === valueFromAfter, + 'headers() works in after()': hostFromRender === hostFromAfter, + }, + }) + }) + + return
Page with after()
+} diff --git a/test/e2e/app-dir/next-after-app/app/[id]/layout.js b/test/e2e/app-dir/next-after-app/app/[id]/layout.js new file mode 100644 index 0000000000000..9c589f027344d --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/[id]/layout.js @@ -0,0 +1,9 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +export default function Layout({ children }) { + after(async () => { + cliLog({ source: '[layout] /[id]' }) + }) + return <>{children} +} diff --git a/test/e2e/app-dir/next-after-app/app/[id]/setting-cookies/page.js b/test/e2e/app-dir/next-after-app/app/[id]/setting-cookies/page.js new file mode 100644 index 0000000000000..3ba685f617ba3 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/[id]/setting-cookies/page.js @@ -0,0 +1,29 @@ +import { unstable_after as after } from 'next/server' +import { cookies } from 'next/headers' + +export default function Index() { + after(() => { + cookies().set('testCookie', 'after-render', { path: '/' }) + }) + + const action = async () => { + 'use server' + cookies().set('testCookie', 'action', { path: '/' }) + + after(() => { + cookies().set('testCookie', 'after-action', { path: '/' }) + }) + } + + return ( +
+

Page with after() that tries to set cookies

+ +
+ +
+
+ ) +} diff --git a/test/e2e/app-dir/next-after-app/app/[id]/with-action/page.js b/test/e2e/app-dir/next-after-app/app/[id]/with-action/page.js new file mode 100644 index 0000000000000..87108571b308f --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/[id]/with-action/page.js @@ -0,0 +1,38 @@ +import { unstable_after as after } from 'next/server' +import { cache } from 'react' +import { cliLog } from '../../../utils/log' +import { headers } from 'next/headers' + +const thing = cache(() => Symbol('cache me please')) + +export default function Index({ params }) { + const action = async () => { + 'use server' + + const hostFromAction = headers().get('host') + const valueFromAction = thing() + + after(() => { + const valueFromAfter = thing() + const hostFromAfter = headers().get('host') + + cliLog({ + source: '[action] /[id]/with-action', + value: params.id, + assertions: { + 'cache() works in after()': valueFromAction === valueFromAfter, + 'headers() works in after()': hostFromAction === hostFromAfter, + }, + }) + }) + } + + return ( +
+

Page with after() in an action

+
+ +
+
+ ) +} diff --git a/test/e2e/app-dir/next-after-app/app/[id]/with-metadata/page.js b/test/e2e/app-dir/next-after-app/app/[id]/with-metadata/page.js new file mode 100644 index 0000000000000..8209df109face --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/[id]/with-metadata/page.js @@ -0,0 +1,18 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../../utils/log' + +export function generateMetadata({ params }) { + after(() => { + cliLog({ + source: '[metadata] /[id]/with-metadata', + value: params.id, + }) + }) + return { + title: `With metadata: ${params.id}`, + } +} + +export default function Page() { + return
With metadata
+} diff --git a/test/e2e/app-dir/next-after-app/app/delay/page.js b/test/e2e/app-dir/next-after-app/app/delay/page.js new file mode 100644 index 0000000000000..bf832369c558e --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/delay/page.js @@ -0,0 +1,30 @@ +import { Suspense } from 'react' +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +export const dynamic = 'force-dynamic' + +export default async function Page() { + after(() => { + cliLog({ source: '[page] /delay (Page)' }) + }) + return ( + + Delay + + ) +} + +async function Inner({ children }) { + after(() => { + cliLog({ source: '[page] /delay (Inner)' }) + }) + + // the test intercepts this to assert on whether the after() callbacks ran + // before and after we finish handling the request. + await fetch('https://example.test/delayed-request', { + signal: AbortSignal.timeout(10_000), + }) + + return <>{children} +} diff --git a/test/e2e/app-dir/next-after-app/app/interrupted/calls-not-found/page.js b/test/e2e/app-dir/next-after-app/app/interrupted/calls-not-found/page.js new file mode 100644 index 0000000000000..19fb589c8e24e --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/interrupted/calls-not-found/page.js @@ -0,0 +1,12 @@ +import { notFound } from 'next/navigation' +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../../utils/log' + +export default function Page() { + after(() => { + cliLog({ + source: '[page] /interrupted/calls-not-found', + }) + }) + notFound() +} diff --git a/test/e2e/app-dir/next-after-app/app/interrupted/calls-redirect/page.js b/test/e2e/app-dir/next-after-app/app/interrupted/calls-redirect/page.js new file mode 100644 index 0000000000000..29eb6d9d15f7e --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/interrupted/calls-redirect/page.js @@ -0,0 +1,12 @@ +import { redirect } from 'next/navigation' +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../../utils/log' + +export default function Page() { + after(() => { + cliLog({ + source: '[page] /interrupted/calls-redirect', + }) + }) + redirect('/interrupted/redirect-target') +} diff --git a/test/e2e/app-dir/next-after-app/app/interrupted/redirect-target/page.js b/test/e2e/app-dir/next-after-app/app/interrupted/redirect-target/page.js new file mode 100644 index 0000000000000..09aea952148c6 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/interrupted/redirect-target/page.js @@ -0,0 +1,11 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../../utils/log' + +export default function Page() { + after(() => { + cliLog({ + source: '[page] /interrupted/redirect-target', + }) + }) + return
Redirect
+} diff --git a/test/e2e/app-dir/next-after-app/app/interrupted/throws-error/page.js b/test/e2e/app-dir/next-after-app/app/interrupted/throws-error/page.js new file mode 100644 index 0000000000000..2eee04cf2463b --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/interrupted/throws-error/page.js @@ -0,0 +1,11 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../../utils/log' + +export default function Page() { + after(() => { + cliLog({ + source: '[page] /interrupted/throws-error', + }) + }) + throw new Error('User error') +} diff --git a/test/e2e/app-dir/next-after-app/app/invalid-in-client/page.js b/test/e2e/app-dir/next-after-app/app/invalid-in-client/page.js new file mode 100644 index 0000000000000..6b185f4514b5f --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/invalid-in-client/page.js @@ -0,0 +1,13 @@ +// 'use client' + +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +export const dynamic = 'force-dynamic' + +export default function Page() { + after(() => { + cliLog({ source: '[page] /invalid-in-client' }) + }) + return
Invalid: Client page with after()
+} diff --git a/test/e2e/app-dir/next-after-app/app/layout.js b/test/e2e/app-dir/next-after-app/app/layout.js new file mode 100644 index 0000000000000..bece157b43025 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/layout.js @@ -0,0 +1,13 @@ +// (patched in tests) +// export const runtime = 'REPLACE_ME' + +export default function AppLayout({ children }) { + return ( + + + after + + {children} + + ) +} diff --git a/test/e2e/app-dir/next-after-app/app/middleware/redirect/page.js b/test/e2e/app-dir/next-after-app/app/middleware/redirect/page.js new file mode 100644 index 0000000000000..6321fa7d95987 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/middleware/redirect/page.js @@ -0,0 +1,3 @@ +export default function Page() { + return
Redirect
+} diff --git a/test/e2e/app-dir/next-after-app/app/route/route.js b/test/e2e/app-dir/next-after-app/app/route/route.js new file mode 100644 index 0000000000000..e8c6290fc9011 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/route/route.js @@ -0,0 +1,16 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +// (patched in tests) +// export const runtime = 'REPLACE_ME' + +export const dynamic = 'force-dynamic' + +export async function GET() { + const data = { message: 'Hello, world!' } + after(() => { + cliLog({ source: '[route handler] /route' }) + }) + + return Response.json({ data }) +} diff --git a/test/e2e/app-dir/next-after-app/app/static/page.js b/test/e2e/app-dir/next-after-app/app/static/page.js new file mode 100644 index 0000000000000..80fafb550f11f --- /dev/null +++ b/test/e2e/app-dir/next-after-app/app/static/page.js @@ -0,0 +1,12 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +// (patched in tests) +// export const dynamic = 'REPLACE_ME' + +export default function Index() { + after(async () => { + cliLog({ source: '[page] /static' }) + }) + return
Page with after()
+} diff --git a/test/e2e/app-dir/next-after-app/index.test.ts b/test/e2e/app-dir/next-after-app/index.test.ts new file mode 100644 index 0000000000000..35d1f1b6f8fa2 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/index.test.ts @@ -0,0 +1,338 @@ +/* eslint-env jest */ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' +import { createProxyServer } from 'next/experimental/testmode/proxy' +import { sandbox } from '../../../lib/development-sandbox' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import * as Log from './utils/log' +import { BrowserInterface } from '../../../lib/next-webdriver' + +const runtimes = ['nodejs', 'edge'] + +describe.each(runtimes)('unstable_after() in %s runtime', (runtimeValue) => { + const logFileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logs-')) + const logFile = path.join(logFileDir, 'logs.jsonl') + + const { next, isNextDev, isNextDeploy } = nextTestSetup({ + files: __dirname, + env: { + PERSISTENT_LOG_FILE: logFile, + }, + }) + + { + const originalContents: Record = {} + + beforeAll(async () => { + const placeholder = `// export const runtime = 'REPLACE_ME'` + + const filesToPatch = ['app/layout.js', 'app/route/route.js'] + + for (const file of filesToPatch) { + await next.patchFile(file, (contents) => { + if (!contents.includes(placeholder)) { + throw new Error(`Placeholder "${placeholder}" not found in ${file}`) + } + originalContents[file] = contents + + return contents.replace( + placeholder, + `export const runtime = '${runtimeValue}'` + ) + }) + } + }) + + afterAll(async () => { + for (const [file, contents] of Object.entries(originalContents)) { + await next.patchFile(file, contents) + } + }) + } + + let currentCliOutputIndex = 0 + beforeEach(() => { + currentCliOutputIndex = next.cliOutput.length + }) + + const getLogs = () => { + return Log.readCliLogs(next.cliOutput.slice(currentCliOutputIndex)) + } + + it('runs in dynamic pages', async () => { + await next.render('/123/dynamic') + await retry(() => { + expect(getLogs()).toContainEqual({ source: '[layout] /[id]' }) + expect(getLogs()).toContainEqual({ + source: '[page] /[id]/dynamic', + value: '123', + assertions: { + 'cache() works in after()': true, + 'headers() works in after()': true, + }, + }) + }) + }) + + it('runs in dynamic route handlers', async () => { + const res = await next.fetch('/route') + expect(res.status).toBe(200) + await retry(() => { + expect(getLogs()).toContainEqual({ source: '[route handler] /route' }) + }) + }) + + it('runs in server actions', async () => { + const browser = await next.browser('/123/with-action') + expect(getLogs()).toContainEqual({ + source: '[layout] /[id]', + }) + await browser.elementByCss('button[type="submit"]').click() + + await retry(() => { + expect(getLogs()).toContainEqual({ + source: '[action] /[id]/with-action', + value: '123', + assertions: { + 'cache() works in after()': true, + 'headers() works in after()': true, + }, + }) + }) + // TODO: server seems to close before the response fully returns? + }) + + describe('interrupted RSC renders', () => { + it('runs callbacks if redirect() was called', async () => { + await next.browser('/interrupted/calls-redirect') + expect(getLogs()).toContainEqual({ + source: '[page] /interrupted/calls-redirect', + }) + expect(getLogs()).toContainEqual({ + source: '[page] /interrupted/redirect-target', + }) + }) + + it('runs callbacks if notFound() was called', async () => { + await next.browser('/interrupted/calls-not-found') + expect(getLogs()).toContainEqual({ + source: '[page] /interrupted/calls-not-found', + }) + }) + + it('runs callbacks if a user error was thrown in the RSC render', async () => { + await next.browser('/interrupted/throws-error') + expect(getLogs()).toContainEqual({ + source: '[page] /interrupted/throws-error', + }) + }) + }) + + it('runs in middleware', async () => { + const requestId = `${Date.now()}` + const res = await next.fetch( + `/middleware/redirect-source?requestId=${requestId}`, + { + redirect: 'follow', + headers: { + cookie: 'testCookie=testValue', + }, + } + ) + + expect(res.status).toBe(200) + await retry(() => { + expect(getLogs()).toContainEqual({ + source: '[middleware] /middleware/redirect-source', + requestId, + cookies: { testCookie: 'testValue' }, + }) + }) + }) + + if (!isNextDeploy) { + it('only runs callbacks after the response is fully sent', async () => { + const pageStartedFetching = promiseWithResolvers() + const shouldSendResponse = promiseWithResolvers() + const abort = (error: Error) => { + pageStartedFetching.reject(error) + shouldSendResponse.reject(error) + } + + const proxyServer = await createProxyServer({ + async onFetch(_, request) { + if (request.url === 'https://example.test/delayed-request') { + pageStartedFetching.resolve() + await shouldSendResponse.promise + return new Response('') + } + }, + }) + + try { + const pendingReq = next.fetch('/delay', { + headers: { 'Next-Test-Proxy-Port': String(proxyServer.port) }, + }) + + pendingReq.then( + async (res) => { + if (res.status !== 200) { + const msg = `Got non-200 response (${res.status}), aborting` + console.error(msg + '\n', await res.text()) + abort(new Error(msg)) + } + }, + (err) => { + abort(err) + } + ) + + await Promise.race([ + pageStartedFetching.promise, + timeoutPromise( + 10_000, + 'Timeout while waiting for the page to call fetch' + ), + ]) + + // we blocked the request from completing, so there should be no logs yet, + // because after() shouldn't run callbacks until the request is finished. + expect(getLogs()).not.toContainEqual({ + source: '[page] /delay (Page)', + }) + expect(getLogs()).not.toContainEqual({ + source: '[page] /delay (Inner)', + }) + + shouldSendResponse.resolve() + await pendingReq.then((res) => res.text()) + + // the request is finished, so after() should run, and the logs should appear now. + await retry(() => { + expect(getLogs()).toContainEqual({ + source: '[page] /delay (Page)', + }) + expect(getLogs()).toContainEqual({ + source: '[page] /delay (Inner)', + }) + }) + } finally { + proxyServer.close() + } + }) + } + + it('runs in generateMetadata()', async () => { + await next.browser('/123/with-metadata') + expect(getLogs()).toContainEqual({ + source: '[metadata] /[id]/with-metadata', + value: '123', + }) + }) + + it('does not allow modifying cookies in a callback', async () => { + const EXPECTED_ERROR = + /An error occurred in a function passed to `unstable_after\(\)`: .+?: Cookies can only be modified in a Server Action or Route Handler\./ + + const browser: BrowserInterface = await next.browser('/123/setting-cookies') + // after() from render + expect(next.cliOutput).toMatch(EXPECTED_ERROR) + + const cookie1 = await browser.elementById('cookie').text() + expect(cookie1).toEqual('Cookie: null') + + try { + await browser.elementByCss('button[type="submit"]').click() + + await retry(async () => { + const cookie1 = await browser.elementById('cookie').text() + expect(cookie1).toEqual('Cookie: "action"') + // const newLogs = next.cliOutput.slice(cliOutputIndex) + // // after() from action + // expect(newLogs).toContain(EXPECTED_ERROR) + }) + } finally { + await browser.eval('document.cookie = "testCookie=;path=/;max-age=-1"') + } + }) + + if (isNextDev) { + // TODO: these are at the end because they destroy the next server. + // is there a cleaner way to do this without making the tests slower? + + describe('invalid usages', () => { + it.each(['error', 'force-static'])( + `errors at compile time with dynamic = "%s"`, + async (dynamicValue) => { + const { session, cleanup } = await sandbox( + next, + new Map([ + [ + 'app/static/page.js', + (await next.readFile('app/static/page.js')).replace( + `// export const dynamic = 'REPLACE_ME'`, + `export const dynamic = '${dynamicValue}'` + ), + ], + ]), + '/static' + ) + + try { + expect(await session.hasRedbox()).toBe(true) + expect(await session.getRedboxDescription()).toContain( + `Route /static with \`dynamic = "${dynamicValue}"\` couldn't be rendered statically because it used \`unstable_after\`` + ) + expect(getLogs()).toHaveLength(0) + } finally { + await cleanup() + } + } + ) + + it('errors at compile time when used in a client module', async () => { + const { session, cleanup } = await sandbox( + next, + new Map([ + [ + 'app/invalid-in-client/page.js', + (await next.readFile('app/invalid-in-client/page.js')).replace( + `// 'use client'`, + `'use client'` + ), + ], + ]), + '/invalid-in-client' + ) + try { + expect(await session.getRedboxSource(true)).toMatch( + /You're importing a component that needs "?unstable_after"?\. That only works in a Server Component but one of its parents is marked with "use client", so it's a Client Component\./ + ) + expect(getLogs()).toHaveLength(0) + } finally { + await cleanup() + } + }) + }) + } +}) + +function promiseWithResolvers() { + let resolve: (value: T) => void = undefined! + let reject: (error: unknown) => void = undefined! + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + return { promise, resolve, reject } +} + +function timeoutPromise(duration: number, message = 'Timeout') { + return new Promise((_, reject) => + AbortSignal.timeout(duration).addEventListener('abort', () => + reject(new Error(message)) + ) + ) +} diff --git a/test/e2e/app-dir/next-after-app/middleware.js b/test/e2e/app-dir/next-after-app/middleware.js new file mode 100644 index 0000000000000..6a1c60397228b --- /dev/null +++ b/test/e2e/app-dir/next-after-app/middleware.js @@ -0,0 +1,24 @@ +import { cookies } from 'next/headers' +import { NextResponse, unstable_after as after } from 'next/server' +import { cliLog } from './utils/log' + +export function middleware( + /** @type {import ('next/server').NextRequest} */ request +) { + const url = new URL(request.url) + if (url.pathname.startsWith('/middleware/redirect-source')) { + const requestId = url.searchParams.get('requestId') + after(() => { + cliLog({ + source: '[middleware] /middleware/redirect-source', + requestId, + cookies: { testCookie: cookies().get('testCookie')?.value }, + }) + }) + return NextResponse.redirect(new URL('/middleware/redirect', request.url)) + } +} + +export const config = { + matcher: '/middleware/:path*', +} diff --git a/test/e2e/app-dir/next-after-app/next.config.js b/test/e2e/app-dir/next-after-app/next.config.js new file mode 100644 index 0000000000000..2c62db0c357fe --- /dev/null +++ b/test/e2e/app-dir/next-after-app/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + experimental: { + after: true, + testProxy: true, + }, +} diff --git a/test/e2e/app-dir/next-after-app/utils/log.js b/test/e2e/app-dir/next-after-app/utils/log.js new file mode 100644 index 0000000000000..5d192c8f87d86 --- /dev/null +++ b/test/e2e/app-dir/next-after-app/utils/log.js @@ -0,0 +1,16 @@ +export function cliLog(/** @type {Record} */ data) { + console.log('' + JSON.stringify(data) + '') +} + +export function readCliLogs(/** @type {string} */ output) { + return output + .split('\n') + .map((line) => { + const match = line.match(/^(?.+?)<\/test-log>$/) + if (!match) { + return null + } + return JSON.parse(match.groups.value) + }) + .filter(Boolean) +} diff --git a/test/e2e/app-dir/next-after-pages/index.test.ts b/test/e2e/app-dir/next-after-pages/index.test.ts new file mode 100644 index 0000000000000..0f54a6dbac945 --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/index.test.ts @@ -0,0 +1,80 @@ +/* eslint-env jest */ +import { nextTestSetup, isNextDev } from 'e2e-utils' +import { getRedboxSource, hasRedbox, retry } from 'next-test-utils' +import * as fs from 'fs' +import * as path from 'path' +import * as os from 'os' +import * as Log from './utils/log' + +// using unstable_after is a compile-time error in build mode. +const _describe = isNextDev ? describe : describe.skip + +_describe('unstable_after() - pages', () => { + const logFileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'logs-')) + const logFile = path.join(logFileDir, 'logs.jsonl') + + const { next } = nextTestSetup({ + files: __dirname, + env: { + PERSISTENT_LOG_FILE: logFile, + }, + }) + + let currentCliOutputIndex = 0 + beforeEach(() => { + currentCliOutputIndex = next.cliOutput.length + }) + + const getLogs = () => { + return Log.readCliLogs(next.cliOutput.slice(currentCliOutputIndex)) + } + + it('runs in middleware', async () => { + const requestId = `${Date.now()}` + const res = await next.fetch( + `/middleware/redirect-source?requestId=${requestId}`, + { + redirect: 'follow', + headers: { + cookie: 'testCookie=testValue', + }, + } + ) + + expect(res.status).toBe(200) + await retry(() => { + expect(getLogs()).toContainEqual({ + source: '[middleware] /middleware/redirect-source', + requestId, + cookies: { testCookie: 'testValue' }, + }) + }) + }) + + describe('invalid usages', () => { + describe('errors at compile time when used in pages dir', () => { + it.each([ + { + title: 'errors when used in getServerSideProps', + path: '/pages-dir/invalid-in-gssp', + }, + { + title: 'errors when used in getStaticProps', + path: '/pages-dir/123/invalid-in-gsp', + }, + { + title: 'errors when used in within a page component', + path: '/pages-dir/invalid-in-page', + }, + ])('$title', async ({ path }) => { + const browser = await next.browser(path) + + expect(await hasRedbox(browser)).toBe(true) + expect(await getRedboxSource(browser)).toMatch( + /You're importing a component that needs "?unstable_after"?\. That only works in a Server Component which is not supported in the pages\/ directory\./ + ) + expect(getLogs()).toHaveLength(0) + }) + }) + }) +}) diff --git a/test/e2e/app-dir/next-after-pages/middleware.js b/test/e2e/app-dir/next-after-pages/middleware.js new file mode 100644 index 0000000000000..6a1c60397228b --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/middleware.js @@ -0,0 +1,24 @@ +import { cookies } from 'next/headers' +import { NextResponse, unstable_after as after } from 'next/server' +import { cliLog } from './utils/log' + +export function middleware( + /** @type {import ('next/server').NextRequest} */ request +) { + const url = new URL(request.url) + if (url.pathname.startsWith('/middleware/redirect-source')) { + const requestId = url.searchParams.get('requestId') + after(() => { + cliLog({ + source: '[middleware] /middleware/redirect-source', + requestId, + cookies: { testCookie: cookies().get('testCookie')?.value }, + }) + }) + return NextResponse.redirect(new URL('/middleware/redirect', request.url)) + } +} + +export const config = { + matcher: '/middleware/:path*', +} diff --git a/test/e2e/app-dir/next-after-pages/next.config.js b/test/e2e/app-dir/next-after-pages/next.config.js new file mode 100644 index 0000000000000..2c62db0c357fe --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +module.exports = { + experimental: { + after: true, + testProxy: true, + }, +} diff --git a/test/e2e/app-dir/next-after-pages/pages/middleware/redirect/index.js b/test/e2e/app-dir/next-after-pages/pages/middleware/redirect/index.js new file mode 100644 index 0000000000000..6321fa7d95987 --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/pages/middleware/redirect/index.js @@ -0,0 +1,3 @@ +export default function Page() { + return
Redirect
+} diff --git a/test/e2e/app-dir/next-after-pages/pages/pages-dir/[id]/invalid-in-gsp.js b/test/e2e/app-dir/next-after-pages/pages/pages-dir/[id]/invalid-in-gsp.js new file mode 100644 index 0000000000000..3bf684c3f428e --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/pages/pages-dir/[id]/invalid-in-gsp.js @@ -0,0 +1,23 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../../utils/log' + +export async function getStaticProps() { + after(() => { + cliLog({ + source: '[pages-dir] /pages-dir/invalid-in-gsp', + }) + }) + return { props: {} } +} + +// prevent this from erroring during build in `next start` mode. +export function getStaticPaths() { + return { + paths: [], + fallback: 'blocking', + } +} + +export default function Page() { + return
Invalid in getStaticProps
+} diff --git a/test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-gssp.js b/test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-gssp.js new file mode 100644 index 0000000000000..71273d3358f53 --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-gssp.js @@ -0,0 +1,15 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +export async function getServerSideProps() { + after(() => { + cliLog({ + source: '[pages-dir] /pages-dir/invalid-in-gssp', + }) + }) + return { props: {} } +} + +export default function Page() { + return
Invalid in getServerSideProps
+} diff --git a/test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-page.js b/test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-page.js new file mode 100644 index 0000000000000..68bb2034171c6 --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/pages/pages-dir/invalid-in-page.js @@ -0,0 +1,15 @@ +import { unstable_after as after } from 'next/server' +import { cliLog } from '../../utils/log' + +export async function getServerSideProps() { + return { props: {} } +} + +export default function Page() { + after(() => { + cliLog({ + source: '[pages-dir] /pages-dir/invalid-in-page', + }) + }) + return
Invalid in Pages router Page component
+} diff --git a/test/e2e/app-dir/next-after-pages/utils/log.js b/test/e2e/app-dir/next-after-pages/utils/log.js new file mode 100644 index 0000000000000..5d192c8f87d86 --- /dev/null +++ b/test/e2e/app-dir/next-after-pages/utils/log.js @@ -0,0 +1,16 @@ +export function cliLog(/** @type {Record} */ data) { + console.log('' + JSON.stringify(data) + '') +} + +export function readCliLogs(/** @type {string} */ output) { + return output + .split('\n') + .map((line) => { + const match = line.match(/^(?.+?)<\/test-log>$/) + if (!match) { + return null + } + return JSON.parse(match.groups.value) + }) + .filter(Boolean) +} From 14a98734e39759506086293b14a56b6c0b357625 Mon Sep 17 00:00:00 2001 From: vercel-release-bot Date: Mon, 20 May 2024 10:35:37 +0000 Subject: [PATCH 2/3] v14.3.0-canary.72 --- lerna.json | 2 +- packages/create-next-app/package.json | 2 +- packages/eslint-config-next/package.json | 4 ++-- packages/eslint-plugin-next/package.json | 2 +- packages/font/package.json | 2 +- packages/next-bundle-analyzer/package.json | 2 +- packages/next-codemod/package.json | 2 +- packages/next-env/package.json | 2 +- packages/next-mdx/package.json | 2 +- packages/next-plugin-storybook/package.json | 2 +- packages/next-polyfill-module/package.json | 2 +- packages/next-polyfill-nomodule/package.json | 2 +- packages/next-swc/package.json | 2 +- packages/next/package.json | 12 ++++++------ packages/react-refresh-utils/package.json | 2 +- packages/third-parties/package.json | 4 ++-- pnpm-lock.yaml | 14 +++++++------- 17 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lerna.json b/lerna.json index 6c37d8d979c30..0b721fc8b4390 100644 --- a/lerna.json +++ b/lerna.json @@ -16,5 +16,5 @@ "registry": "https://registry.npmjs.org/" } }, - "version": "14.3.0-canary.71" + "version": "14.3.0-canary.72" } diff --git a/packages/create-next-app/package.json b/packages/create-next-app/package.json index ab23e5488c37a..7d1ca23bcae34 100644 --- a/packages/create-next-app/package.json +++ b/packages/create-next-app/package.json @@ -1,6 +1,6 @@ { "name": "create-next-app", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "keywords": [ "react", "next", diff --git a/packages/eslint-config-next/package.json b/packages/eslint-config-next/package.json index 5bf66a7590098..4c079919e733c 100644 --- a/packages/eslint-config-next/package.json +++ b/packages/eslint-config-next/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-next", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "description": "ESLint configuration used by Next.js.", "main": "index.js", "license": "MIT", @@ -10,7 +10,7 @@ }, "homepage": "https://nextjs.org/docs/app/building-your-application/configuring/eslint#eslint-config", "dependencies": { - "@next/eslint-plugin-next": "14.3.0-canary.71", + "@next/eslint-plugin-next": "14.3.0-canary.72", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || 7.0.0 - 7.2.0", "eslint-import-resolver-node": "^0.3.6", diff --git a/packages/eslint-plugin-next/package.json b/packages/eslint-plugin-next/package.json index 6291ce42a6348..c4c9fe52e5380 100644 --- a/packages/eslint-plugin-next/package.json +++ b/packages/eslint-plugin-next/package.json @@ -1,6 +1,6 @@ { "name": "@next/eslint-plugin-next", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "description": "ESLint plugin for Next.js.", "main": "dist/index.js", "license": "MIT", diff --git a/packages/font/package.json b/packages/font/package.json index 529825cc77f23..5d92a6538d930 100644 --- a/packages/font/package.json +++ b/packages/font/package.json @@ -1,6 +1,6 @@ { "name": "@next/font", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "repository": { "url": "vercel/next.js", "directory": "packages/font" diff --git a/packages/next-bundle-analyzer/package.json b/packages/next-bundle-analyzer/package.json index 8fca0dac77cd0..136402c7e8f7b 100644 --- a/packages/next-bundle-analyzer/package.json +++ b/packages/next-bundle-analyzer/package.json @@ -1,6 +1,6 @@ { "name": "@next/bundle-analyzer", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "main": "index.js", "types": "index.d.ts", "license": "MIT", diff --git a/packages/next-codemod/package.json b/packages/next-codemod/package.json index e1beb62fc13df..83e06dbf7661c 100644 --- a/packages/next-codemod/package.json +++ b/packages/next-codemod/package.json @@ -1,6 +1,6 @@ { "name": "@next/codemod", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "license": "MIT", "repository": { "type": "git", diff --git a/packages/next-env/package.json b/packages/next-env/package.json index 307bc13841f2d..22d7ac326547f 100644 --- a/packages/next-env/package.json +++ b/packages/next-env/package.json @@ -1,6 +1,6 @@ { "name": "@next/env", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "keywords": [ "react", "next", diff --git a/packages/next-mdx/package.json b/packages/next-mdx/package.json index 27afcb55cbc41..0146d7972182f 100644 --- a/packages/next-mdx/package.json +++ b/packages/next-mdx/package.json @@ -1,6 +1,6 @@ { "name": "@next/mdx", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "main": "index.js", "license": "MIT", "repository": { diff --git a/packages/next-plugin-storybook/package.json b/packages/next-plugin-storybook/package.json index dc1f8a6435f6b..307984d02bd4d 100644 --- a/packages/next-plugin-storybook/package.json +++ b/packages/next-plugin-storybook/package.json @@ -1,6 +1,6 @@ { "name": "@next/plugin-storybook", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "repository": { "url": "vercel/next.js", "directory": "packages/next-plugin-storybook" diff --git a/packages/next-polyfill-module/package.json b/packages/next-polyfill-module/package.json index 6c8ed6fe68be8..6b45a13d70568 100644 --- a/packages/next-polyfill-module/package.json +++ b/packages/next-polyfill-module/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-module", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "description": "A standard library polyfill for ES Modules supporting browsers (Edge 16+, Firefox 60+, Chrome 61+, Safari 10.1+)", "main": "dist/polyfill-module.js", "license": "MIT", diff --git a/packages/next-polyfill-nomodule/package.json b/packages/next-polyfill-nomodule/package.json index 50665cf1d6f6f..136c242df094c 100644 --- a/packages/next-polyfill-nomodule/package.json +++ b/packages/next-polyfill-nomodule/package.json @@ -1,6 +1,6 @@ { "name": "@next/polyfill-nomodule", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "description": "A polyfill for non-dead, nomodule browsers.", "main": "dist/polyfill-nomodule.js", "license": "MIT", diff --git a/packages/next-swc/package.json b/packages/next-swc/package.json index 459b00703a1f6..d76ce72c0fa36 100644 --- a/packages/next-swc/package.json +++ b/packages/next-swc/package.json @@ -1,6 +1,6 @@ { "name": "@next/swc", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "private": true, "scripts": { "clean": "node ../../scripts/rm.mjs native", diff --git a/packages/next/package.json b/packages/next/package.json index a4f967cf9853f..cfe19fb4dd094 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -1,6 +1,6 @@ { "name": "next", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "description": "The React Framework", "main": "./dist/server/next.js", "license": "MIT", @@ -93,7 +93,7 @@ ] }, "dependencies": { - "@next/env": "14.3.0-canary.71", + "@next/env": "14.3.0-canary.72", "@swc/helpers": "0.5.11", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -157,10 +157,10 @@ "@jest/types": "29.5.0", "@mswjs/interceptors": "0.23.0", "@napi-rs/triples": "1.2.0", - "@next/polyfill-module": "14.3.0-canary.71", - "@next/polyfill-nomodule": "14.3.0-canary.71", - "@next/react-refresh-utils": "14.3.0-canary.71", - "@next/swc": "14.3.0-canary.71", + "@next/polyfill-module": "14.3.0-canary.72", + "@next/polyfill-nomodule": "14.3.0-canary.72", + "@next/react-refresh-utils": "14.3.0-canary.72", + "@next/swc": "14.3.0-canary.72", "@opentelemetry/api": "1.6.0", "@playwright/test": "1.41.2", "@swc/core": "1.5.7", diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json index bd6993760d1cb..7f35c51a23622 100644 --- a/packages/react-refresh-utils/package.json +++ b/packages/react-refresh-utils/package.json @@ -1,6 +1,6 @@ { "name": "@next/react-refresh-utils", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "description": "An experimental package providing utilities for React Refresh.", "repository": { "url": "vercel/next.js", diff --git a/packages/third-parties/package.json b/packages/third-parties/package.json index 97b7ac24271f6..6d5207cc97a5f 100644 --- a/packages/third-parties/package.json +++ b/packages/third-parties/package.json @@ -1,6 +1,6 @@ { "name": "@next/third-parties", - "version": "14.3.0-canary.71", + "version": "14.3.0-canary.72", "repository": { "url": "vercel/next.js", "directory": "packages/third-parties" @@ -26,7 +26,7 @@ "third-party-capital": "1.0.20" }, "devDependencies": { - "next": "14.3.0-canary.71", + "next": "14.3.0-canary.72", "outdent": "0.8.0", "prettier": "2.5.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7084fb48ce12e..d1a5dff85c8e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -748,7 +748,7 @@ importers: packages/eslint-config-next: dependencies: '@next/eslint-plugin-next': - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../eslint-plugin-next '@rushstack/eslint-patch': specifier: ^1.3.3 @@ -810,7 +810,7 @@ importers: packages/next: dependencies: '@next/env': - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../next-env '@swc/helpers': specifier: 0.5.11 @@ -938,16 +938,16 @@ importers: specifier: 1.2.0 version: 1.2.0 '@next/polyfill-module': - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../next-polyfill-module '@next/polyfill-nomodule': - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../next-polyfill-nomodule '@next/react-refresh-utils': - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../react-refresh-utils '@next/swc': - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../next-swc '@opentelemetry/api': specifier: 1.6.0 @@ -1568,7 +1568,7 @@ importers: version: 1.0.20 devDependencies: next: - specifier: 14.3.0-canary.71 + specifier: 14.3.0-canary.72 version: link:../next outdent: specifier: 0.8.0 From dd8ee527b86fde5b447eeb09a82c2273023e001b Mon Sep 17 00:00:00 2001 From: Lovell Fuller Date: Mon, 20 May 2024 14:30:09 +0100 Subject: [PATCH 3/3] fix(next/image): prefer sharp defaults, use mozjpeg for JPEG (#65846) ### What? Upgrades sharp to the latest version and relies on more of its default settings (if the default settings are unsuitable, we should consider improving these for all users in sharp itself). - The `sequentialRead` setting is now managed for you based on each input image and the operations to be applied to it. - The concurrency detection is more accurate than `os.cpus()` as it now inspects things like CPU set/affinity as well as the memory allocator. - The (mostly archaic) concept of chroma subsampling is not required for AVIF output. Using full chroma should improve the quality of red/orange edges, as well as slightly reducing file size as it allows greater use of AV1 chroma-from-luma prediction. In addition, this PR also enables the use of mozjpeg features such as trellis quantisation to produce smaller file sizes. The use of `mozjpeg: true` infers `progressive: true`. This aligns JPEG output behaviour with the previously-used squoosh, which always used mozjpeg. /cc @styfle --- packages/next/package.json | 2 +- packages/next/src/server/image-optimizer.ts | 16 ++-- pnpm-lock.yaml | 76 +++++++++---------- .../base-path/test/static.test.ts | 2 +- .../default/test/static.test.ts | 2 +- .../app-dir/test/static.test.ts | 2 +- .../base-path/test/static.test.js | 2 +- .../default/test/static.test.ts | 2 +- 8 files changed, 51 insertions(+), 53 deletions(-) diff --git a/packages/next/package.json b/packages/next/package.json index cfe19fb4dd094..30b172c4e4aed 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -124,7 +124,7 @@ } }, "optionalDependencies": { - "sharp": "^0.33.3" + "sharp": "^0.33.4" }, "devDependencies": { "@ampproject/toolbox-optimizer": "2.8.3", diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index 90eb99f1d796c..ab92901af748b 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -1,6 +1,5 @@ import { createHash } from 'crypto' import { promises } from 'fs' -import { cpus } from 'os' import type { IncomingMessage, ServerResponse } from 'http' import { mediaType } from 'next/dist/compiled/@hapi/accept' import contentDisposition from 'next/dist/compiled/content-disposition' @@ -52,7 +51,9 @@ function getSharp() { // We more aggressively reduce in dev but also reduce in prod. // https://sharp.pixelplumbing.com/api-utility#concurrency const divisor = process.env.NODE_ENV === 'development' ? 4 : 2 - _sharp.concurrency(Math.floor(Math.max(cpus().length / divisor, 1))) + _sharp.concurrency( + Math.floor(Math.max(_sharp.concurrency() / divisor, 1)) + ) } } catch (e: unknown) { if (isError(e) && e.code === 'MODULE_NOT_FOUND') { @@ -444,9 +445,7 @@ export async function optimizeImage({ nextConfigOutput?: 'standalone' | 'export' }): Promise { const sharp = getSharp() - const transformer = sharp(buffer, { sequentialRead: true }) - .timeout({ seconds: 7 }) - .rotate() + const transformer = sharp(buffer).timeout({ seconds: 7 }).rotate() if (height) { transformer.resize(width, height) @@ -457,17 +456,16 @@ export async function optimizeImage({ } if (contentType === AVIF) { - const avifQuality = quality - 15 + const avifQuality = quality - 20 transformer.avif({ - quality: Math.max(avifQuality, 0), - chromaSubsampling: '4:2:0', // same as webp + quality: Math.max(avifQuality, 1), }) } else if (contentType === WEBP) { transformer.webp({ quality }) } else if (contentType === PNG) { transformer.png({ quality }) } else if (contentType === JPEG) { - transformer.jpeg({ quality, progressive: true }) + transformer.jpeg({ quality, mozjpeg: true }) } const optimizedBuffer = await transformer.toBuffer() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1a5dff85c8e2..ebddd6a6400d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -844,8 +844,8 @@ importers: version: 5.1.3(@babel/core@7.22.5)(react@19.0.0-beta-04b058868c-20240508) optionalDependencies: sharp: - specifier: ^0.33.3 - version: 0.33.3 + specifier: ^0.33.4 + version: 0.33.4 devDependencies: '@ampproject/toolbox-optimizer': specifier: 2.8.3 @@ -4138,8 +4138,8 @@ packages: resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} dev: true - /@img/sharp-darwin-arm64@0.33.3: - resolution: {integrity: sha512-FaNiGX1MrOuJ3hxuNzWgsT/mg5OHG/Izh59WW2mk1UwYHUwtfbhk5QNKYZgxf0pLOhx9ctGiGa2OykD71vOnSw==} + /@img/sharp-darwin-arm64@0.33.4: + resolution: {integrity: sha512-p0suNqXufJs9t3RqLBO6vvrgr5OhgbWp76s5gTRvdmxmuv9E1rcaqGUsl3l4mKVmXPkTkTErXediAui4x+8PSA==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [darwin] @@ -4149,8 +4149,8 @@ packages: dev: false optional: true - /@img/sharp-darwin-x64@0.33.3: - resolution: {integrity: sha512-2QeSl7QDK9ru//YBT4sQkoq7L0EAJZA3rtV+v9p8xTKl4U1bUqTIaCnoC7Ctx2kCjQgwFXDasOtPTCT8eCTXvw==} + /@img/sharp-darwin-x64@0.33.4: + resolution: {integrity: sha512-0l7yRObwtTi82Z6ebVI2PnHT8EB2NxBgpK2MiKJZJ7cz32R4lxd001ecMhzzsZig3Yv9oclvqqdV93jo9hy+Dw==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [darwin] @@ -4232,8 +4232,8 @@ packages: dev: false optional: true - /@img/sharp-linux-arm64@0.33.3: - resolution: {integrity: sha512-Zf+sF1jHZJKA6Gor9hoYG2ljr4wo9cY4twaxgFDvlG0Xz9V7sinsPp8pFd1XtlhTzYo0IhDbl3rK7P6MzHpnYA==} + /@img/sharp-linux-arm64@0.33.4: + resolution: {integrity: sha512-2800clwVg1ZQtxwSoTlHvtm9ObgAax7V6MTAB/hDT945Tfyy3hVkmiHpeLPCKYqYR1Gcmv1uDZ3a4OFwkdBL7Q==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] @@ -4243,8 +4243,8 @@ packages: dev: false optional: true - /@img/sharp-linux-arm@0.33.3: - resolution: {integrity: sha512-Q7Ee3fFSC9P7vUSqVEF0zccJsZ8GiiCJYGWDdhEjdlOeS9/jdkyJ6sUSPj+bL8VuOYFSbofrW0t/86ceVhx32w==} + /@img/sharp-linux-arm@0.33.4: + resolution: {integrity: sha512-RUgBD1c0+gCYZGCCe6mMdTiOFS0Zc/XrN0fYd6hISIKcDUbAW5NtSQW9g/powkrXYm6Vzwd6y+fqmExDuCdHNQ==} engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm] os: [linux] @@ -4254,9 +4254,9 @@ packages: dev: false optional: true - /@img/sharp-linux-s390x@0.33.3: - resolution: {integrity: sha512-vFk441DKRFepjhTEH20oBlFrHcLjPfI8B0pMIxGm3+yilKyYeHEVvrZhYFdqIseSclIqbQ3SnZMwEMWonY5XFA==} - engines: {glibc: '>=2.28', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} + /@img/sharp-linux-s390x@0.33.4: + resolution: {integrity: sha512-h3RAL3siQoyzSoH36tUeS0PDmb5wINKGYzcLB5C6DIiAn2F3udeFAum+gj8IbA/82+8RGCTn7XW8WTFnqag4tQ==} + engines: {glibc: '>=2.31', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [s390x] os: [linux] requiresBuild: true @@ -4265,8 +4265,8 @@ packages: dev: false optional: true - /@img/sharp-linux-x64@0.33.3: - resolution: {integrity: sha512-Q4I++herIJxJi+qmbySd072oDPRkCg/SClLEIDh5IL9h1zjhqjv82H0Seupd+q2m0yOfD+/fJnjSoDFtKiHu2g==} + /@img/sharp-linux-x64@0.33.4: + resolution: {integrity: sha512-GoR++s0XW9DGVi8SUGQ/U4AeIzLdNjHka6jidVwapQ/JebGVQIpi52OdyxCNVRE++n1FCLzjDovJNozif7w/Aw==} engines: {glibc: '>=2.26', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] @@ -4276,8 +4276,8 @@ packages: dev: false optional: true - /@img/sharp-linuxmusl-arm64@0.33.3: - resolution: {integrity: sha512-qnDccehRDXadhM9PM5hLvcPRYqyFCBN31kq+ErBSZtZlsAc1U4Z85xf/RXv1qolkdu+ibw64fUDaRdktxTNP9A==} + /@img/sharp-linuxmusl-arm64@0.33.4: + resolution: {integrity: sha512-nhr1yC3BlVrKDTl6cO12gTpXMl4ITBUZieehFvMntlCXFzH2bvKG76tBL2Y/OqhupZt81pR7R+Q5YhJxW0rGgQ==} engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [arm64] os: [linux] @@ -4287,8 +4287,8 @@ packages: dev: false optional: true - /@img/sharp-linuxmusl-x64@0.33.3: - resolution: {integrity: sha512-Jhchim8kHWIU/GZ+9poHMWRcefeaxFIs9EBqf9KtcC14Ojk6qua7ghKiPs0sbeLbLj/2IGBtDcxHyjCdYWkk2w==} + /@img/sharp-linuxmusl-x64@0.33.4: + resolution: {integrity: sha512-uCPTku0zwqDmZEOi4ILyGdmW76tH7dm8kKlOIV1XC5cLyJ71ENAAqarOHQh0RLfpIpbV5KOpXzdU6XkJtS0daw==} engines: {musl: '>=1.2.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [linux] @@ -4298,8 +4298,8 @@ packages: dev: false optional: true - /@img/sharp-wasm32@0.33.3: - resolution: {integrity: sha512-68zivsdJ0koE96stdUfM+gmyaK/NcoSZK5dV5CAjES0FUXS9lchYt8LAB5rTbM7nlWtxaU/2GON0HVN6/ZYJAQ==} + /@img/sharp-wasm32@0.33.4: + resolution: {integrity: sha512-Bmmauh4sXUsUqkleQahpdNXKvo+wa1V9KhT2pDA4VJGKwnKMJXiSTGphn0gnJrlooda0QxCtXc6RX1XAU6hMnQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [wasm32] requiresBuild: true @@ -4308,8 +4308,8 @@ packages: dev: false optional: true - /@img/sharp-win32-ia32@0.33.3: - resolution: {integrity: sha512-CyimAduT2whQD8ER4Ux7exKrtfoaUiVr7HG0zZvO0XTFn2idUWljjxv58GxNTkFb8/J9Ub9AqITGkJD6ZginxQ==} + /@img/sharp-win32-ia32@0.33.4: + resolution: {integrity: sha512-99SJ91XzUhYHbx7uhK3+9Lf7+LjwMGQZMDlO/E/YVJ7Nc3lyDFZPGhjwiYdctoH2BOzW9+TnfqcaMKt0jHLdqw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [ia32] os: [win32] @@ -4317,8 +4317,8 @@ packages: dev: false optional: true - /@img/sharp-win32-x64@0.33.3: - resolution: {integrity: sha512-viT4fUIDKnli3IfOephGnolMzhz5VaTvDRkYqtZxOMIoMQ4MrAziO7pT1nVnOt2FAm7qW5aa+CCc13aEY6Le0g==} + /@img/sharp-win32-x64@0.33.4: + resolution: {integrity: sha512-3QLocdTRVIrFNye5YocZl+KKpYKP+fksi1QhmOArgx7GyhIbQp/WrJRu176jm8IxromS7RIkzMiMINVdBtC8Aw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0', yarn: '>=3.2.0'} cpu: [x64] os: [win32] @@ -22667,8 +22667,8 @@ packages: resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==} dev: true - /sharp@0.33.3: - resolution: {integrity: sha512-vHUeXJU1UvlO/BNwTpT0x/r53WkLUVxrmb5JTgW92fdFCFk0ispLMAeu/jPO2vjkXM1fYUi3K7/qcLF47pwM1A==} + /sharp@0.33.4: + resolution: {integrity: sha512-7i/dt5kGl7qR4gwPRD2biwD2/SvBn3O04J77XKFgL2OnZtQw+AG9wnuS/csmu80nPRHLYE9E41fyEiG8nhH6/Q==} engines: {libvips: '>=8.15.2', node: ^18.17.0 || ^20.3.0 || >=21.0.0} requiresBuild: true dependencies: @@ -22676,8 +22676,8 @@ packages: detect-libc: 2.0.3 semver: 7.6.2 optionalDependencies: - '@img/sharp-darwin-arm64': 0.33.3 - '@img/sharp-darwin-x64': 0.33.3 + '@img/sharp-darwin-arm64': 0.33.4 + '@img/sharp-darwin-x64': 0.33.4 '@img/sharp-libvips-darwin-arm64': 1.0.2 '@img/sharp-libvips-darwin-x64': 1.0.2 '@img/sharp-libvips-linux-arm': 1.0.2 @@ -22686,15 +22686,15 @@ packages: '@img/sharp-libvips-linux-x64': 1.0.2 '@img/sharp-libvips-linuxmusl-arm64': 1.0.2 '@img/sharp-libvips-linuxmusl-x64': 1.0.2 - '@img/sharp-linux-arm': 0.33.3 - '@img/sharp-linux-arm64': 0.33.3 - '@img/sharp-linux-s390x': 0.33.3 - '@img/sharp-linux-x64': 0.33.3 - '@img/sharp-linuxmusl-arm64': 0.33.3 - '@img/sharp-linuxmusl-x64': 0.33.3 - '@img/sharp-wasm32': 0.33.3 - '@img/sharp-win32-ia32': 0.33.3 - '@img/sharp-win32-x64': 0.33.3 + '@img/sharp-linux-arm': 0.33.4 + '@img/sharp-linux-arm64': 0.33.4 + '@img/sharp-linux-s390x': 0.33.4 + '@img/sharp-linux-x64': 0.33.4 + '@img/sharp-linuxmusl-arm64': 0.33.4 + '@img/sharp-linuxmusl-x64': 0.33.4 + '@img/sharp-wasm32': 0.33.4 + '@img/sharp-win32-ia32': 0.33.4 + '@img/sharp-win32-x64': 0.33.4 dev: false optional: true diff --git a/test/integration/next-image-legacy/base-path/test/static.test.ts b/test/integration/next-image-legacy/base-path/test/static.test.ts index bf5dc4b543bcc..ed6f423c59760 100644 --- a/test/integration/next-image-legacy/base-path/test/static.test.ts +++ b/test/integration/next-image-legacy/base-path/test/static.test.ts @@ -78,7 +78,7 @@ const runTests = (isDev = false) => { `style="position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0% 0%;filter:blur(20px);background-image:url(${ isDev ? '"/docs/_next/image?url=%2Fdocs%2F_next%2Fstatic%2Fmedia%2Ftest.fab2915d.jpg&w=8&q=70"' - : '"data:image/jpeg;base64,/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGUB//EABYQAAMAAAAAAAAAAAAAAAAAAAMSE//aAAgBAQABBQJhQ//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABoQAAEFAQAAAAAAAAAAAAAAABIAARETMkH/2gAIAQEABj8CEHsLU8X/xAAYEAACAwAAAAAAAAAAAAAAAAAAEUFhcf/aAAgBAQABPyHSKKiP/9oADAMBAAIAAwAAABAD/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAGRAAAQUAAAAAAAAAAAAAAAAAEQAhMVHR/9oACAEBAAE/EMvICK3JX//Z"' + : '"data:image/jpeg;base64,/9j/2wBDAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/2wBDAQoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAACVA//EABwQAAICAgMAAAAAAAAAAAAAABITERQAAwUVIv/aAAgBAQABPwB3H9YmrsuvN5+VxADn/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwB//9k="' })` ) } diff --git a/test/integration/next-image-legacy/default/test/static.test.ts b/test/integration/next-image-legacy/default/test/static.test.ts index 5baa3f96dc564..58fd2ef0c0874 100644 --- a/test/integration/next-image-legacy/default/test/static.test.ts +++ b/test/integration/next-image-legacy/default/test/static.test.ts @@ -66,7 +66,7 @@ const runTests = () => { }) it('Should add a blur placeholder to statically imported jpg', async () => { expect(html).toContain( - `style="position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0% 0%;filter:blur(20px);background-image:url("data:image/jpeg;base64,/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGUB//EABYQAAMAAAAAAAAAAAAAAAAAAAMSE//aAAgBAQABBQJhQ//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABoQAAEFAQAAAAAAAAAAAAAAABIAARETMkH/2gAIAQEABj8CEHsLU8X/xAAYEAACAwAAAAAAAAAAAAAAAAAAEUFhcf/aAAgBAQABPyHSKKiP/9oADAMBAAIAAwAAABAD/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAGRAAAQUAAAAAAAAAAAAAAAAAEQAhMVHR/9oACAEBAAE/EMvICK3JX//Z")"` + `style="position:absolute;top:0;left:0;bottom:0;right:0;box-sizing:border-box;padding:0;border:none;margin:auto;display:block;width:0;height:0;min-width:100%;max-width:100%;min-height:100%;max-height:100%;background-size:cover;background-position:0% 0%;filter:blur(20px);background-image:url("data:image/jpeg;base64,/9j/2wBDAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/2wBDAQoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAACVA//EABwQAAICAgMAAAAAAAAAAAAAABITERQAAwUVIv/aAAgBAQABPwB3H9YmrsuvN5+VxADn/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwB//9k=")"` ) }) it('Should add a blur placeholder to statically imported png', async () => { diff --git a/test/integration/next-image-new/app-dir/test/static.test.ts b/test/integration/next-image-new/app-dir/test/static.test.ts index 48d31f40d4a7c..446e9bf326af9 100644 --- a/test/integration/next-image-new/app-dir/test/static.test.ts +++ b/test/integration/next-image-new/app-dir/test/static.test.ts @@ -140,7 +140,7 @@ const runTests = (isDev) => { ) } else { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/jpeg;base64,/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGUB//EABYQAAMAAAAAAAAAAAAAAAAAAAMSE//aAAgBAQABBQJhQ//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABoQAAEFAQAAAAAAAAAAAAAAABIAARETMkH/2gAIAQEABj8CEHsLU8X/xAAYEAACAwAAAAAAAAAAAAAAAAAAEUFhcf/aAAgBAQABPyHSKKiP/9oADAMBAAIAAwAAABAD/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAGRAAAQUAAAAAAAAAAAAAAAAAEQAhMVHR/9oACAEBAAE/EMvICK3JX//Z'/%3E%3C/svg%3E")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/jpeg;base64,/9j/2wBDAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/2wBDAQoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAACVA//EABwQAAICAgMAAAAAAAAAAAAAABITERQAAwUVIv/aAAgBAQABPwB3H9YmrsuvN5+VxADn/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwB//9k='/%3E%3C/svg%3E")` ) } } diff --git a/test/integration/next-image-new/base-path/test/static.test.js b/test/integration/next-image-new/base-path/test/static.test.js index 0cb99f4efad47..19170dbc9f4fa 100644 --- a/test/integration/next-image-new/base-path/test/static.test.js +++ b/test/integration/next-image-new/base-path/test/static.test.js @@ -135,7 +135,7 @@ const runTests = (isDev) => { ) } else { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/jpeg;base64,/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGUB//EABYQAAMAAAAAAAAAAAAAAAAAAAMSE//aAAgBAQABBQJhQ//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABoQAAEFAQAAAAAAAAAAAAAAABIAARETMkH/2gAIAQEABj8CEHsLU8X/xAAYEAACAwAAAAAAAAAAAAAAAAAAEUFhcf/aAAgBAQABPyHSKKiP/9oADAMBAAIAAwAAABAD/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAGRAAAQUAAAAAAAAAAAAAAAAAEQAhMVHR/9oACAEBAAE/EMvICK3JX//Z'/%3E%3C/svg%3E")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/jpeg;base64,/9j/2wBDAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/2wBDAQoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAACVA//EABwQAAICAgMAAAAAAAAAAAAAABITERQAAwUVIv/aAAgBAQABPwB3H9YmrsuvN5+VxADn/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwB//9k='/%3E%3C/svg%3E")` ) } } diff --git a/test/integration/next-image-new/default/test/static.test.ts b/test/integration/next-image-new/default/test/static.test.ts index e6a1e8b65dd6c..975f097351878 100644 --- a/test/integration/next-image-new/default/test/static.test.ts +++ b/test/integration/next-image-new/default/test/static.test.ts @@ -140,7 +140,7 @@ const runTests = (isDev) => { ) } else { expect(style).toBe( - `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/jpeg;base64,/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAb/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGUB//EABYQAAMAAAAAAAAAAAAAAAAAAAMSE//aAAgBAQABBQJhQ//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABoQAAEFAQAAAAAAAAAAAAAAABIAARETMkH/2gAIAQEABj8CEHsLU8X/xAAYEAACAwAAAAAAAAAAAAAAAAAAEUFhcf/aAAgBAQABPyHSKKiP/9oADAMBAAIAAwAAABAD/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAGRAAAQUAAAAAAAAAAAAAAAAAEQAhMVHR/9oACAEBAAE/EMvICK3JX//Z'/%3E%3C/svg%3E")` + `color:transparent;background-size:cover;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 240'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/jpeg;base64,/9j/2wBDAAoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/2wBDAQoKCgoKCgsMDAsPEA4QDxYUExMUFiIYGhgaGCIzICUgICUgMy03LCksNy1RQDg4QFFeT0pPXnFlZXGPiI+7u/v/wgARCAAGAAgDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAACVA//EABwQAAICAgMAAAAAAAAAAAAAABITERQAAwUVIv/aAAgBAQABPwB3H9YmrsuvN5+VxADn/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPwB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwB//9k='/%3E%3C/svg%3E")` ) } }