diff --git a/crates/next-api/src/app.rs b/crates/next-api/src/app.rs index 8f52c25f35926..df2876bea5f48 100644 --- a/crates/next-api/src/app.rs +++ b/crates/next-api/src/app.rs @@ -1190,6 +1190,7 @@ impl AppEndpoint { ssr_chunking_context, this.app_project.project().next_config(), runtime, + this.app_project.project().next_mode(), ) .to_resolved() .await?; diff --git a/crates/next-core/src/next_config.rs b/crates/next-core/src/next_config.rs index 55ef29cdd54f6..8a4ac6e876874 100644 --- a/crates/next-core/src/next_config.rs +++ b/crates/next-core/src/next_config.rs @@ -551,6 +551,7 @@ pub struct ExperimentalConfig { fully_specified: Option, gzip_size: Option, + pub inline_css: Option, instrumentation_hook: Option, client_trace_metadata: Option>, large_page_data_bytes: Option, diff --git a/crates/next-core/src/next_manifests/client_reference_manifest.rs b/crates/next-core/src/next_manifests/client_reference_manifest.rs index 52904f3c6ee6e..3ad123c852dcf 100644 --- a/crates/next-core/src/next_manifests/client_reference_manifest.rs +++ b/crates/next-core/src/next_manifests/client_reference_manifest.rs @@ -4,7 +4,7 @@ use turbo_rcstr::RcStr; use turbo_tasks::{FxIndexSet, TryJoinIterExt, Value, ValueToString, Vc}; use turbo_tasks_fs::{File, FileSystemPath}; use turbopack_core::{ - asset::AssetContent, + asset::{Asset, AssetContent}, chunk::{ availability_info::AvailabilityInfo, ChunkItem, ChunkItemExt, ChunkableModule, ChunkingContext, ModuleId as TurbopackModuleId, @@ -14,8 +14,9 @@ use turbopack_core::{ }; use turbopack_ecmascript::utils::StringifyJs; -use super::{ClientReferenceManifest, ManifestNode, ManifestNodeEntry, ModuleId}; +use super::{ClientReferenceManifest, CssResource, ManifestNode, ManifestNodeEntry, ModuleId}; use crate::{ + mode::NextMode, next_app::ClientReferencesChunks, next_client_reference::{ClientReferenceGraphResult, ClientReferenceType}, next_config::NextConfig, @@ -37,6 +38,7 @@ impl ClientReferenceManifest { ssr_chunking_context: Option>>, next_config: Vc, runtime: NextRuntime, + mode: Vc, ) -> Result>> { let mut entry_manifest: ClientReferenceManifest = Default::default(); let mut references = FxIndexSet::default(); @@ -241,40 +243,66 @@ impl ClientReferenceManifest { for (server_component, client_chunks) in client_references_chunks.layout_segment_client_chunks.iter() { - let client_chunks = &client_chunks.await?; - - let client_chunks_paths = client_chunks - .iter() - .map(|chunk| chunk.ident().path()) - .try_join() - .await?; - let server_component_name = server_component .server_path() .with_extension("".into()) .to_string() .await?; - let entry_css_files = entry_manifest - .entry_css_files - .entry(server_component_name.clone_value()) - .or_default(); - + let mut entry_css_files_with_chunk = Vec::new(); let entry_js_files = entry_manifest .entry_js_files .entry(server_component_name.clone_value()) .or_default(); - for chunk_path in client_chunks_paths { + let client_chunks = &client_chunks.await?; + let client_chunks_with_path = client_chunks + .iter() + .map(|chunk| async move { Ok((chunk, chunk.ident().path().await?)) }) + .try_join() + .await?; + + for (chunk, chunk_path) in client_chunks_with_path { if let Some(path) = client_relative_path.get_path_to(&chunk_path) { let path = path.into(); if chunk_path.extension_ref() == Some("css") { - entry_css_files.insert(path); + entry_css_files_with_chunk.push((path, chunk)); } else { entry_js_files.insert(path); } } } + + let inlined = next_config.await?.experimental.inline_css.unwrap_or(false) + && mode.await?.is_production(); + let entry_css_files_vec = entry_css_files_with_chunk + .into_iter() + .map(|(path, chunk)| async { + let content = if inlined { + if let Some(content_file) = + chunk.content().file_content().await?.as_content() + { + Some(content_file.content().to_str()?.into()) + } else { + Some("".into()) + } + } else { + None + }; + Ok(CssResource { + path, + inlined, + content, + }) + }) + .try_join() + .await?; + + let entry_css_files = entry_manifest + .entry_css_files + .entry(server_component_name.clone_value()) + .or_default(); + entry_css_files.extend(entry_css_files_vec); } let client_reference_manifest_json = serde_json::to_string(&entry_manifest).unwrap(); diff --git a/crates/next-core/src/next_manifests/mod.rs b/crates/next-core/src/next_manifests/mod.rs index ca9b0b122c2cc..4e8eaac370469 100644 --- a/crates/next-core/src/next_manifests/mod.rs +++ b/crates/next-core/src/next_manifests/mod.rs @@ -355,12 +355,20 @@ pub struct ClientReferenceManifest { pub edge_rsc_module_mapping: HashMap, /// Mapping of server component path to required CSS client chunks. #[serde(rename = "entryCSSFiles")] - pub entry_css_files: HashMap>, + pub entry_css_files: HashMap>, /// Mapping of server component path to required JS client chunks. #[serde(rename = "entryJSFiles")] pub entry_js_files: HashMap>, } +#[derive(Serialize, Debug, Clone, Eq, Hash, PartialEq)] +pub struct CssResource { + pub path: RcStr, + pub inlined: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + #[derive(Serialize, Default, Debug)] #[serde(rename_all = "camelCase")] pub struct ModuleLoading { diff --git a/packages/next/src/build/webpack-config.ts b/packages/next/src/build/webpack-config.ts index fd9e9f0661840..b36da1033e656 100644 --- a/packages/next/src/build/webpack-config.ts +++ b/packages/next/src/build/webpack-config.ts @@ -1895,6 +1895,7 @@ export default async function getBaseWebpackConfig( ? new ClientReferenceManifestPlugin({ dev, appDir, + experimentalInlineCss: !!config.experimental.inlineCss, }) : new FlightClientEntryPlugin({ appDir, 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 55664a13e537c..7dd4e7a945771 100644 --- a/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/src/build/webpack/plugins/flight-manifest-plugin.ts @@ -32,6 +32,7 @@ import type { ModuleInfo } from './flight-client-entry-plugin' interface Options { dev: boolean appDir: string + experimentalInlineCss: boolean } /** @@ -83,6 +84,19 @@ export interface ClientReferenceManifestForRsc { } } +export type CssResource = InlinedCssFile | UninlinedCssFile + +interface InlinedCssFile { + path: string + inlined: true + content: string +} + +interface UninlinedCssFile { + path: string + inlined: false +} + export interface ClientReferenceManifest extends ClientReferenceManifestForRsc { readonly moduleLoading: { prefix: string @@ -95,7 +109,7 @@ export interface ClientReferenceManifest extends ClientReferenceManifestForRsc { [moduleId: string]: ManifestNode } entryCSSFiles: { - [entry: string]: string[] + [entry: string]: CssResource[] } entryJSFiles?: { [entry: string]: string[] @@ -200,11 +214,13 @@ export class ClientReferenceManifestPlugin { dev: Options['dev'] = false appDir: Options['appDir'] appDirBase: string + experimentalInlineCss: Options['experimentalInlineCss'] constructor(options: Options) { this.dev = options.dev this.appDir = options.appDir this.appDirBase = path.dirname(this.appDir) + path.sep + this.experimentalInlineCss = options.experimentalInlineCss } apply(compiler: webpack.Compiler) { @@ -296,9 +312,29 @@ export class ClientReferenceManifestPlugin { /[\\/]/g, path.sep ) + manifest.entryCSSFiles[chunkEntryName] = entrypoint .getFiles() .filter((f) => !f.startsWith('static/css/pages/') && f.endsWith('.css')) + .map((file) => { + const source = compilation.assets[file].source() + if ( + this.experimentalInlineCss && + // Inline CSS currently does not work properly with HMR, so we only + // inline CSS in production. + !this.dev + ) { + return { + inlined: true, + path: file, + content: typeof source === 'string' ? source : source.toString(), + } + } + return { + inlined: false, + path: file, + } + }) const requiredChunks = getAppPathRequiredChunks(entrypoint, rootMainFiles) const recordModule = (modId: ModuleId, mod: webpack.NormalModule) => { diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 66bd9af69eab7..7d346dc174d4a 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -358,6 +358,7 @@ async function exportAppImpl( expireTime: nextConfig.expireTime, after: nextConfig.experimental.after ?? false, dynamicIO: nextConfig.experimental.dynamicIO ?? false, + inlineCss: nextConfig.experimental.inlineCss ?? false, }, reactMaxHeadersLength: nextConfig.reactMaxHeadersLength, } diff --git a/packages/next/src/server/app-render/create-component-styles-and-scripts.tsx b/packages/next/src/server/app-render/create-component-styles-and-scripts.tsx index b3273c97b2cdb..46d3c861df364 100644 --- a/packages/next/src/server/app-render/create-component-styles-and-scripts.tsx +++ b/packages/next/src/server/app-render/create-component-styles-and-scripts.tsx @@ -4,6 +4,7 @@ import { getLinkAndScriptTags } from './get-css-inlined-link-tags' import type { AppRenderContext } from './app-render' import { getAssetQueryString } from './get-asset-query-string' import { encodeURIPath } from '../../shared/lib/encode-uri-path' +import { renderCssResource } from './render-css-resource' export async function createComponentStylesAndScripts({ filePath, @@ -18,40 +19,14 @@ export async function createComponentStylesAndScripts({ injectedJS: Set ctx: AppRenderContext }): Promise<[React.ComponentType, React.ReactNode, React.ReactNode]> { - const { styles: cssHrefs, scripts: jsHrefs } = getLinkAndScriptTags( + const { styles: entryCssFiles, scripts: jsHrefs } = getLinkAndScriptTags( ctx.clientReferenceManifest, filePath, injectedCSS, injectedJS ) - const styles = cssHrefs - ? cssHrefs.map((href, index) => { - const fullHref = `${ctx.assetPrefix}/_next/${encodeURIPath( - href - )}${getAssetQueryString(ctx, true)}` - - // `Precedence` is an opt-in signal for React to handle resource - // loading and deduplication, etc. It's also used as the key to sort - // resources so they will be injected in the correct order. - // During HMR, it's critical to use different `precedence` values - // for different stylesheets, so their order will be kept. - // https://github.com/facebook/react/pull/25060 - const precedence = - process.env.NODE_ENV === 'development' ? 'next_' + href : 'next' - - return ( - - ) - }) - : null + const styles = renderCssResource(entryCssFiles, ctx) const scripts = jsHrefs ? jsHrefs.map((href, index) => ( diff --git a/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx b/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx index b177883e371fa..cfb4ad9760eee 100644 --- a/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx +++ b/packages/next/src/server/app-render/get-css-inlined-link-tags.tsx @@ -1,4 +1,7 @@ -import type { ClientReferenceManifest } from '../../build/webpack/plugins/flight-manifest-plugin' +import type { + ClientReferenceManifest, + CssResource, +} from '../../build/webpack/plugins/flight-manifest-plugin' import type { DeepReadonly } from '../../shared/lib/deep-readonly' /** @@ -10,9 +13,9 @@ export function getLinkAndScriptTags( injectedCSS: Set, injectedScripts: Set, collectNewImports?: boolean -): { styles: string[]; scripts: string[] } { +): { styles: CssResource[]; scripts: string[] } { const filePathWithoutExt = filePath.replace(/\.[^.]+$/, '') - const cssChunks = new Set() + const cssChunks = new Set() const jsChunks = new Set() const entryCSSFiles = @@ -21,12 +24,12 @@ export function getLinkAndScriptTags( clientReferenceManifest.entryJSFiles?.[filePathWithoutExt] ?? [] if (entryCSSFiles) { - for (const file of entryCSSFiles) { - if (!injectedCSS.has(file)) { + for (const css of entryCSSFiles) { + if (!injectedCSS.has(css.path)) { if (collectNewImports) { - injectedCSS.add(file) + injectedCSS.add(css.path) } - cssChunks.add(file) + cssChunks.add(css) } } } diff --git a/packages/next/src/server/app-render/get-layer-assets.tsx b/packages/next/src/server/app-render/get-layer-assets.tsx index b69483e01bf60..7d2b7485afd17 100644 --- a/packages/next/src/server/app-render/get-layer-assets.tsx +++ b/packages/next/src/server/app-render/get-layer-assets.tsx @@ -5,6 +5,7 @@ import type { AppRenderContext } from './app-render' import { getAssetQueryString } from './get-asset-query-string' import { encodeURIPath } from '../../shared/lib/encode-uri-path' import type { PreloadCallbacks } from './types' +import { renderCssResource } from './render-css-resource' export function getLayerAssets({ ctx, @@ -72,47 +73,7 @@ export function getLayerAssets({ } } - const styles = styleTags - ? styleTags.map((href, index) => { - // In dev, Safari and Firefox will cache the resource during HMR: - // - https://github.com/vercel/next.js/issues/5860 - // - https://bugs.webkit.org/show_bug.cgi?id=187726 - // Because of this, we add a `?v=` query to bypass the cache during - // development. We need to also make sure that the number is always - // increasing. - const fullHref = `${ctx.assetPrefix}/_next/${encodeURIPath( - href - )}${getAssetQueryString(ctx, true)}` - - // `Precedence` is an opt-in signal for React to handle resource - // loading and deduplication, etc. It's also used as the key to sort - // resources so they will be injected in the correct order. - // During HMR, it's critical to use different `precedence` values - // for different stylesheets, so their order will be kept. - // https://github.com/facebook/react/pull/25060 - const precedence = - process.env.NODE_ENV === 'development' ? 'next_' + href : 'next' - - preloadCallbacks.push(() => { - ctx.componentMod.preloadStyle( - fullHref, - ctx.renderOpts.crossOrigin, - ctx.nonce - ) - }) - return ( - - ) - }) - : [] + const styles = renderCssResource(styleTags, ctx, preloadCallbacks) const scripts = scriptTags ? scriptTags.map((href, index) => { diff --git a/packages/next/src/server/app-render/render-css-resource.tsx b/packages/next/src/server/app-render/render-css-resource.tsx new file mode 100644 index 0000000000000..9ed009fa6c991 --- /dev/null +++ b/packages/next/src/server/app-render/render-css-resource.tsx @@ -0,0 +1,73 @@ +import type { CssResource } from '../../build/webpack/plugins/flight-manifest-plugin' +import { encodeURIPath } from '../../shared/lib/encode-uri-path' +import type { AppRenderContext } from './app-render' +import { getAssetQueryString } from './get-asset-query-string' +import type { PreloadCallbacks } from './types' + +/** + * Abstracts the rendering of CSS files based on whether they are inlined or not. + * For inlined CSS, renders a + ) + } + + preloadCallbacks?.push(() => { + ctx.componentMod.preloadStyle( + fullHref, + ctx.renderOpts.crossOrigin, + ctx.nonce + ) + }) + + return ( + + ) + }) +} diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index ce503eca5e511..e05756be3c0e2 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -182,6 +182,7 @@ export interface RenderOptsPartial { clientTraceMetadata: string[] | undefined after: boolean dynamicIO: boolean + inlineCss: boolean } postponed?: string diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index 0ec3d24faf2e1..a7846af902afd 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -593,6 +593,7 @@ export default abstract class Server< clientTraceMetadata: this.nextConfig.experimental.clientTraceMetadata, after: this.nextConfig.experimental.after ?? false, dynamicIO: this.nextConfig.experimental.dynamicIO ?? false, + inlineCss: this.nextConfig.experimental.inlineCss ?? false, }, onInstrumentationRequestError: this.instrumentationOnRequestError.bind(this), diff --git a/packages/next/src/server/config-schema.ts b/packages/next/src/server/config-schema.ts index ace53d76eb274..4467cc378f8b3 100644 --- a/packages/next/src/server/config-schema.ts +++ b/packages/next/src/server/config-schema.ts @@ -293,6 +293,7 @@ export const configSchema: zod.ZodType = z.lazy(() => disableOptimizedLoading: z.boolean().optional(), disablePostcssPresetEnv: z.boolean().optional(), dynamicIO: z.boolean().optional(), + inlineCss: z.boolean().optional(), esmExternals: z.union([z.boolean(), z.literal('loose')]).optional(), serverActions: z .object({ diff --git a/packages/next/src/server/config-shared.ts b/packages/next/src/server/config-shared.ts index 49fece7e9c7c1..551a9a55fa940 100644 --- a/packages/next/src/server/config-shared.ts +++ b/packages/next/src/server/config-shared.ts @@ -556,6 +556,12 @@ export interface ExperimentalConfig { * unless explicitly cached. */ dynamicIO?: boolean + + /** + * Render