diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index cf462c5af887c..fedf22331bec4 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -88,6 +88,7 @@ import { getBabelLoader, getReactCompilerLoader, } from './get-babel-loader-config' +import type { NextFlightLoaderOptions } from './webpack/loaders/next-flight-loader' type ExcludesFalse = (x: T | false) => x is T type ClientEntries = { @@ -525,7 +526,7 @@ export default async function getBaseWebpackConfig( loader: 'next-flight-loader', options: { isEdgeServer, - }, + } satisfies NextFlightLoaderOptions, } const appServerLayerLoaders = hasAppDir diff --git a/packages/next/src/build/webpack/loaders/next-flight-loader/index.ts b/packages/next/src/build/webpack/loaders/next-flight-loader/index.ts index 7792df2c3143b..3f9a43c2228e6 100644 --- a/packages/next/src/build/webpack/loaders/next-flight-loader/index.ts +++ b/packages/next/src/build/webpack/loaders/next-flight-loader/index.ts @@ -2,19 +2,35 @@ import type { webpack } from 'next/dist/compiled/webpack/webpack' import { RSC_MOD_REF_PROXY_ALIAS } from '../../../../lib/constants' import { BARREL_OPTIMIZATION_PREFIX, + DEFAULT_RUNTIME_WEBPACK, + EDGE_RUNTIME_WEBPACK, RSC_MODULE_TYPES, } from '../../../../shared/lib/constants' import { warnOnce } from '../../../../shared/lib/utils/warn-once' import { getRSCModuleInformation } from '../../../analysis/get-page-static-info' import { formatBarrelOptimizedResource } from '../../utils' import { getModuleBuildInfo } from '../get-module-build-info' +import type { + javascript, + LoaderContext, +} from 'next/dist/compiled/webpack/webpack' +import picomatch from 'next/dist/compiled/picomatch' + +export interface NextFlightLoaderOptions { + isEdgeServer: boolean +} + +type SourceType = javascript.JavascriptParser['sourceType'] | 'commonjs' const noopHeadPath = require.resolve('next/dist/client/components/noop-head') // For edge runtime it will be aliased to esm version by webpack const MODULE_PROXY_PATH = 'next/dist/build/webpack/loaders/next-flight-loader/module-proxy' -type SourceType = 'auto' | 'commonjs' | 'module' +const isSharedRuntime = picomatch('**/next/dist/**/*.shared-runtime.js', { + dot: true, // required for .pnpm paths +}) + export function getAssumedSourceType( mod: webpack.Module, sourceType: SourceType @@ -45,7 +61,7 @@ export function getAssumedSourceType( } export default function transformSource( - this: any, + this: LoaderContext, source: string, sourceMap: any ) { @@ -56,10 +72,11 @@ export default function transformSource( const options = this.getOptions() const { isEdgeServer } = options + const module = this._module! // Assign the RSC meta information to buildInfo. // Exclude next internal files which are not marked as client files - const buildInfo = getModuleBuildInfo(this._module) + const buildInfo = getModuleBuildInfo(module) buildInfo.rsc = getRSCModuleInformation(source, true) // Resource key is the unique identifier for the resource. When RSC renders @@ -75,19 +92,20 @@ export default function transformSource( // Because of that, we must add another query param to the resource key to // differentiate them. let resourceKey: string = this.resourcePath - if (this._module?.matchResource?.startsWith(BARREL_OPTIMIZATION_PREFIX)) { + if (module.matchResource?.startsWith(BARREL_OPTIMIZATION_PREFIX)) { resourceKey = formatBarrelOptimizedResource( resourceKey, - this._module.matchResource + module.matchResource ) } // A client boundary. if (buildInfo.rsc?.type === RSC_MODULE_TYPES.client) { const assumedSourceType = getAssumedSourceType( - this._module, - this._module?.parser?.sourceType + module, + (module.parser as javascript.JavascriptParser).sourceType ) + const clientRefs = buildInfo.rsc.clientRefs! if (assumedSourceType === 'module') { @@ -100,6 +118,18 @@ export default function transformSource( return } + if (!isSharedRuntime(resourceKey)) { + // Prevent module concatenation, and prevent export names from being + // mangled, in production builds, so that exports of client reference + // modules can be resolved by React using the metadata from the client + // manifest. + this._compilation!.moduleGraph.getExportsInfo( + module + ).setUsedInUnknownWay( + isEdgeServer ? EDGE_RUNTIME_WEBPACK : DEFAULT_RUNTIME_WEBPACK + ) + } + // `proxy` is the module proxy that we treat the module as a client boundary. // For ESM, we access the property of the module proxy directly for each export. // This is bit hacky that treating using a CJS like module proxy for ESM's exports, diff --git a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts index 94229fce95772..13900a16db20e 100644 --- a/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-client-entry-plugin.ts @@ -94,6 +94,9 @@ const pluginState = getProxiedPluginState({ serverModuleIds: {} as Record, edgeServerModuleIds: {} as Record, + rscModuleIds: {} as Record, + edgeRscModuleIds: {} as Record, + injectedClientEntries: {} as Record, }) @@ -209,6 +212,20 @@ export class FlightClientEntryPlugin { : modPath + modQuery : mod.resource + if (typeof modId !== 'undefined' && modResource) { + if (mod.layer === WEBPACK_LAYERS.reactServerComponents) { + const key = path + .relative(compiler.context, modResource) + .replace(/\/next\/dist\/esm\//, '/next/dist/') + + if (this.isEdgeServer) { + pluginState.edgeRscModuleIds[key] = modId + } else { + pluginState.rscModuleIds[key] = modId + } + } + } + if (mod.layer !== WEBPACK_LAYERS.serverSideRendering) { return } diff --git a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts index b1b5c6732fc3d..58f859be36a25 100644 --- a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts @@ -44,6 +44,9 @@ export type ManifestChunks = Array const pluginState = getProxiedPluginState({ serverModuleIds: {} as Record, edgeServerModuleIds: {} as Record, + + rscModuleIds: {} as Record, + edgeRscModuleIds: {} as Record, }) export interface ManifestNode { @@ -86,6 +89,12 @@ export type ClientReferenceManifest = { entryJSFiles?: { [entry: string]: string[] } + rscModuleMapping: { + [moduleId: string]: ManifestNode + } + edgeRscModuleMapping: { + [moduleId: string]: ManifestNode + } } function getAppPathRequiredChunks( @@ -173,6 +182,11 @@ function mergeManifest( manifestToMerge.edgeSSRModuleMapping ) Object.assign(manifest.entryCSSFiles, manifestToMerge.entryCSSFiles) + Object.assign(manifest.rscModuleMapping, manifestToMerge.rscModuleMapping) + Object.assign( + manifest.edgeRscModuleMapping, + manifestToMerge.edgeRscModuleMapping + ) } const PLUGIN_NAME = 'ClientReferenceManifestPlugin' @@ -268,6 +282,8 @@ export class ClientReferenceManifestPlugin { edgeSSRModuleMapping: {}, clientModules: {}, entryCSSFiles: {}, + rscModuleMapping: {}, + edgeRscModuleMapping: {}, } // Absolute path without the extension @@ -295,6 +311,9 @@ export class ClientReferenceManifestPlugin { const moduleIdMapping = manifest.ssrModuleMapping const edgeModuleIdMapping = manifest.edgeSSRModuleMapping + const rscIdMapping = manifest.rscModuleMapping + const edgeRscIdMapping = manifest.edgeRscModuleMapping + // Note that this isn't that reliable as webpack is still possible to assign // additional queries to make sure there's no conflict even using the `named` // module ID strategy. @@ -303,6 +322,11 @@ export class ClientReferenceManifestPlugin { mod.resourceResolveData?.path || resource ) + const rscNamedModuleId = relative( + context, + mod.resourceResolveData?.path || resource + ) + if (!ssrNamedModuleId.startsWith('.')) ssrNamedModuleId = `./${ssrNamedModuleId.replace(/\\/g, '/')}` @@ -345,7 +369,6 @@ export class ClientReferenceManifestPlugin { function addSSRIdMapping() { const exportName = resource if ( - // TODO: Add mapping from client module IDs to RSC module IDs typeof pluginState.serverModuleIds[ssrNamedModuleId] !== 'undefined' ) { moduleIdMapping[modId] = moduleIdMapping[modId] || {} @@ -375,12 +398,47 @@ export class ClientReferenceManifestPlugin { } } + function addRSCIdMapping() { + const exportName = resource + if ( + typeof pluginState.rscModuleIds[rscNamedModuleId] !== 'undefined' + ) { + rscIdMapping[modId] = rscIdMapping[modId] || {} + rscIdMapping[modId]['*'] = { + ...manifest.clientModules[exportName], + // During SSR, we don't have external chunks to load on the server + // side with our architecture of Webpack / Turbopack. We can keep + // this field empty to save some bytes. + chunks: [], + id: pluginState.rscModuleIds[rscNamedModuleId], + } + } + + if ( + typeof pluginState.edgeRscModuleIds[rscNamedModuleId] !== + 'undefined' + ) { + edgeRscIdMapping[modId] = edgeRscIdMapping[modId] || {} + edgeRscIdMapping[modId]['*'] = { + ...manifest.clientModules[exportName], + // During SSR, we don't have external chunks to load on the server + // side with our architecture of Webpack / Turbopack. We can keep + // this field empty to save some bytes. + chunks: [], + id: pluginState.edgeRscModuleIds[rscNamedModuleId], + } + } + } + addClientReference() addSSRIdMapping() + addRSCIdMapping() manifest.clientModules = moduleReferences manifest.ssrModuleMapping = moduleIdMapping manifest.edgeSSRModuleMapping = edgeModuleIdMapping + manifest.rscModuleMapping = rscIdMapping + manifest.edgeRscModuleMapping = edgeRscIdMapping } const checkedChunkGroups = new Set() @@ -479,6 +537,8 @@ export class ClientReferenceManifestPlugin { edgeSSRModuleMapping: {}, clientModules: {}, entryCSSFiles: {}, + rscModuleMapping: {}, + edgeRscModuleMapping: {}, } const segments = [...entryNameToGroupName(pageName).split('/'), 'page'] diff --git a/packages/next/src/server/app-render/encryption.ts b/packages/next/src/server/app-render/encryption.ts index 70f864c8ba415..862eef490fb7b 100644 --- a/packages/next/src/server/app-render/encryption.ts +++ b/packages/next/src/server/app-render/encryption.ts @@ -23,7 +23,7 @@ import { stringToUint8Array, } from './encryption-utils' -import type { ManifestNode } from '../../build/webpack/plugins/flight-manifest-plugin' +const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' const textEncoder = new TextEncoder() const textDecoder = new TextDecoder() @@ -96,16 +96,11 @@ export async function decryptActionBoundArgs( actionId: string, encrypted: Promise ) { + const clientReferenceManifestSingleton = getClientReferenceManifestSingleton() + // Decrypt the serialized string with the action id as the salt. const decryped = await decodeActionBoundArg(actionId, await encrypted) - // TODO: We can't use the client reference manifest to resolve the modules - // on the server side - instead they need to be recovered as the module - // references (proxies) again. - // For now, we'll just use an empty module map. - const ssrModuleMap: { - [moduleExport: string]: ManifestNode - } = {} // Using Flight to deserialize the args from the string. const deserialized = await createFromReadableStream( new ReadableStream({ @@ -120,7 +115,9 @@ export async function decryptActionBoundArgs( // to be added to the current execution. Instead, we'll wait for any ClientReference // to be emitted which themselves will handle the preloading. moduleLoading: null, - moduleMap: ssrModuleMap, + moduleMap: isEdgeRuntime + ? clientReferenceManifestSingleton.edgeRscModuleMapping + : clientReferenceManifestSingleton.rscModuleMapping, }, } ) diff --git a/packages/next/src/server/use-cache/use-cache-wrapper.ts b/packages/next/src/server/use-cache/use-cache-wrapper.ts index 81e9e1e30478d..96b0e8849f07e 100644 --- a/packages/next/src/server/use-cache/use-cache-wrapper.ts +++ b/packages/next/src/server/use-cache/use-cache-wrapper.ts @@ -25,7 +25,7 @@ import { getServerModuleMap, } from '../app-render/encryption-utils' -import type { ManifestNode } from '../../build/webpack/plugins/flight-manifest-plugin' +const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' type CacheEntry = { value: ReadableStream @@ -280,6 +280,11 @@ export function cache(kind: string, id: string, fn: any) { let entry: undefined | CacheEntry = await cacheHandler.get(serializedCacheKey) + // Get the clientReferenceManifestSingleton while we're still in the outer Context. + // In case getClientReferenceManifestSingleton is implemented using AsyncLocalStorage. + const clientReferenceManifestSingleton = + getClientReferenceManifestSingleton() + let stream if ( entry === undefined || @@ -297,11 +302,6 @@ export function cache(kind: string, id: string, fn: any) { // Note: It is important that we await at least once before this because it lets us // pop out of any stack specific contexts as well - aka "Sync" Local Storage. - // Get the clientReferenceManifestSingleton while we're still in the outer Context. - // In case getClientReferenceManifestSingleton is implemented using AsyncLocalStorage. - const clientReferenceManifestSingleton = - getClientReferenceManifestSingleton() - stream = await generateCacheEntry( workStore, clientReferenceManifestSingleton, @@ -315,8 +315,6 @@ export function cache(kind: string, id: string, fn: any) { if (entry.stale) { // If this is stale, and we're not in a prerender (i.e. this is dynamic render), // then we should warm up the cache with a fresh revalidated entry. - const clientReferenceManifestSingleton = - getClientReferenceManifestSingleton() const ignoredStream = await generateCacheEntry( workStore, clientReferenceManifestSingleton, @@ -338,20 +336,14 @@ export function cache(kind: string, id: string, fn: any) { // the server, which is required to pick it up for replaying again on the client. const replayConsoleLogs = true - // TODO: We can't use the client reference manifest to resolve the modules - // on the server side - instead they need to be recovered as the module - // references (proxies) again. - // For now, we'll just use an empty module map. - const ssrModuleMap: { - [moduleExport: string]: ManifestNode - } = {} - const ssrManifest = { // moduleLoading must be null because we don't want to trigger preloads of ClientReferences // to be added to the consumer. Instead, we'll wait for any ClientReference to be emitted // which themselves will handle the preloading. moduleLoading: null, - moduleMap: ssrModuleMap, + moduleMap: isEdgeRuntime + ? clientReferenceManifestSingleton.edgeRscModuleMapping + : clientReferenceManifestSingleton.rscModuleMapping, } return createFromReadableStream(stream, { ssrManifest, diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index ba1de837ef409..7b629c14609f9 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -561,6 +561,7 @@ declare module 'next/dist/compiled/webpack/webpack' { ModuleFilenameHelpers, } from 'webpack' export type { + javascript, LoaderDefinitionFunction, LoaderContext, ModuleGraph, diff --git a/test/e2e/app-dir/use-cache/app/client.tsx b/test/e2e/app-dir/use-cache/app/client.tsx new file mode 100644 index 0000000000000..87e5b5f029d6e --- /dev/null +++ b/test/e2e/app-dir/use-cache/app/client.tsx @@ -0,0 +1,5 @@ +'use client' + +export function Foo() { + return 'foo' +} diff --git a/test/e2e/app-dir/use-cache/app/page.tsx b/test/e2e/app-dir/use-cache/app/page.tsx index 09ba77896e320..ebe11f94e23a8 100644 --- a/test/e2e/app-dir/use-cache/app/page.tsx +++ b/test/e2e/app-dir/use-cache/app/page.tsx @@ -1,8 +1,12 @@ -async function getCachedRandom(x: number) { +import { Foo } from './client' + +async function getCachedRandom(x: number, children: React.ReactNode) { 'use cache' return { x, y: Math.random(), + z: , + r: children, } } @@ -12,11 +16,16 @@ export default async function Page({ searchParams: Promise<{ n: string }> }) { const n = +(await searchParams).n - const values = await getCachedRandom(n) + const values = await getCachedRandom( + n, +

rnd{Math.random()}

// This should not invalidate the cache + ) return ( <>

{values.x}

{values.y}

+

{values.z}

+ {values.r} ) } diff --git a/test/e2e/app-dir/use-cache/use-cache.test.ts b/test/e2e/app-dir/use-cache/use-cache.test.ts index d946bcb2f1037..a11b76d26726b 100644 --- a/test/e2e/app-dir/use-cache/use-cache.test.ts +++ b/test/e2e/app-dir/use-cache/use-cache.test.ts @@ -1,15 +1,20 @@ -// @ts-check +/* eslint-disable jest/no-standalone-expect */ import { nextTestSetup } from 'e2e-utils' const GENERIC_RSC_ERROR = 'An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error.' describe('use-cache', () => { - const { next, isNextDev, isNextDeploy } = nextTestSetup({ + const { next, isNextDev, isNextDeploy, isTurbopack } = nextTestSetup({ files: __dirname, }) - it('should cache results', async () => { + const itSkipTurbopack = isTurbopack ? it.skip : it + + // TODO: Fix the following error with Turbopack: + // Error: Module [project]/app/client.tsx [app-client] (ecmascript) was + // instantiated because it was required from module... + itSkipTurbopack('should cache results', async () => { const browser = await next.browser('/?n=1') expect(await browser.waitForElementByCss('#x').text()).toBe('1') const random1a = await browser.waitForElementByCss('#y').text() @@ -27,6 +32,12 @@ describe('use-cache', () => { // The navigation to n=2 should be some other random value. expect(random1a).not.toBe(random2) + + // Client component should have rendered. + expect(await browser.waitForElementByCss('#z').text()).toBe('foo') + + // Client component child should have rendered but not invalidated the cache. + expect(await browser.waitForElementByCss('#r').text()).toContain('rnd') }) it('should dedupe with react cache inside "use cache"', async () => {