From 409d37615ba1941b2d6e7ab429fde6ef5529d549 Mon Sep 17 00:00:00 2001 From: Jiachi Liu Date: Mon, 11 Jul 2022 17:23:21 +0200 Subject: [PATCH] Enable css import in rsc server side (#38418) * Enable css in server components * inject server css into flight * refactor and fix test * fix lint * resolve css from module deps * fix dev & prod inconsistentce, collect client css * simplify * dedupe duplicated css chunks * remove ssr link injection and css flight Co-authored-by: Shu Ding --- .../build/webpack/config/blocks/css/index.ts | 6 +- .../next-flight-client-entry-loader.ts | 17 +-- .../webpack/plugins/client-entry-plugin.ts | 22 ++-- .../webpack/plugins/flight-manifest-plugin.ts | 108 +++++++++++++++--- packages/next/client/app-index.tsx | 50 +++++++- packages/next/server/app-render.tsx | 73 ++++++------ packages/next/server/load-components.ts | 3 +- test/e2e/app-dir/app/app/dashboard/global.css | 3 + .../app/app/dashboard/layout.server.js | 9 +- .../app-dir/app/app/dashboard/page.server.js | 3 +- test/e2e/app-dir/app/app/dashboard/style.css | 3 + test/e2e/app-dir/index.test.ts | 18 +++ 12 files changed, 235 insertions(+), 80 deletions(-) create mode 100644 test/e2e/app-dir/app/app/dashboard/global.css create mode 100644 test/e2e/app-dir/app/app/dashboard/style.css diff --git a/packages/next/build/webpack/config/blocks/css/index.ts b/packages/next/build/webpack/config/blocks/css/index.ts index 14d408ecde26..a674efec6527 100644 --- a/packages/next/build/webpack/config/blocks/css/index.ts +++ b/packages/next/build/webpack/config/blocks/css/index.ts @@ -360,7 +360,11 @@ export const css = curry(async function css( sideEffects: true, test: regexCssGlobal, issuer: { - and: [ctx.rootDirectory, /\.(js|mjs|jsx|ts|tsx)$/], + or: [ + { and: [ctx.rootDirectory, /\.(js|mjs|jsx|ts|tsx)$/] }, + // Also match the virtual client entry which doesn't have file path + (filePath) => !filePath, + ], }, use: getGlobalCssLoader(ctx, lazyPostCSSInitializer), }), diff --git a/packages/next/build/webpack/loaders/next-flight-client-entry-loader.ts b/packages/next/build/webpack/loaders/next-flight-client-entry-loader.ts index 3600de1f1495..cd9f02d1210d 100644 --- a/packages/next/build/webpack/loaders/next-flight-client-entry-loader.ts +++ b/packages/next/build/webpack/loaders/next-flight-client-entry-loader.ts @@ -6,13 +6,15 @@ export default async function transformSource(this: any): Promise { modules = modules ? [modules] : [] } - return ( - modules - .map( - (request: string) => `import(/* webpackMode: "eager" */ '${request}')` - ) - .join(';') + + const requests = modules as string[] + const code = + requests + .map((request) => `import(/* webpackMode: "eager" */ '${request}')`) + .join(';\n') + ` + export const __next_rsc_css__ = ${JSON.stringify( + requests.filter((request) => request.endsWith('.css')) + )}; export const __next_rsc__ = { server: false, __webpack_require__ @@ -25,5 +27,6 @@ export default async function transformSource(this: any): Promise { : ssr ? `export const __N_SSP = true;` : `export const __N_SSG = true;`) - ) + + return code } diff --git a/packages/next/build/webpack/plugins/client-entry-plugin.ts b/packages/next/build/webpack/plugins/client-entry-plugin.ts index 34f9f5af60d8..93f1cb29801a 100644 --- a/packages/next/build/webpack/plugins/client-entry-plugin.ts +++ b/packages/next/build/webpack/plugins/client-entry-plugin.ts @@ -22,6 +22,7 @@ type Options = { const PLUGIN_NAME = 'ClientEntryPlugin' export const injectedClientEntries = new Map() +const regexCssGlobal = /(?((res, rej) => { compilation.addEntry( diff --git a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts index b1899faf9e28..fa93c1b99d1d 100644 --- a/packages/next/build/webpack/plugins/flight-manifest-plugin.ts +++ b/packages/next/build/webpack/plugins/flight-manifest-plugin.ts @@ -9,7 +9,6 @@ import { webpack, sources } from 'next/dist/compiled/webpack/webpack' import { FLIGHT_MANIFEST } from '../../../shared/lib/constants' import { clientComponentRegex } from '../loaders/utils' import { relative } from 'path' -import { getEntrypointFiles } from './build-manifest-plugin' import type { webpack5 } from 'next/dist/compiled/webpack/webpack' // This is the module that will be used to anchor all client references to. @@ -79,13 +78,7 @@ export class FlightManifestPlugin { mod: any ) { const resource: string = mod.resource - - // TODO: Hook into deps instead of the target module. - // That way we know by the type of dep whether to include. - // It also resolves conflicts when the same module is in multiple chunks. - if (!resource || !clientComponentRegex.test(resource)) { - return - } + if (!resource) return const moduleExports: any = manifest[resource] || {} const moduleIdMapping: any = manifest.__ssr_module_mapping__ || {} @@ -98,6 +91,67 @@ export class FlightManifestPlugin { if (!ssrNamedModuleId.startsWith('.')) ssrNamedModuleId = `./${ssrNamedModuleId}` + if ( + mod.request && + /(? + item.loader.includes('next-style-loader/index.js') + ) + : mod.loaders.some((item: any) => + item.loader.includes('mini-css-extract-plugin/loader.js') + )) + ) { + if (!manifest[resource]) { + if (dev) { + const chunkIdNameMapping = (chunk.ids || []).map((chunkId) => { + return ( + chunkId + + ':' + + (chunk.name || chunk.id) + + (dev ? '' : '-' + chunk.hash) + ) + }) + manifest[resource] = { + default: { + id, + name: 'default', + chunks: chunkIdNameMapping, + }, + } + moduleIdMapping[id]['default'] = { + id: ssrNamedModuleId, + name: 'default', + chunks: chunkIdNameMapping, + } + manifest.__ssr_module_mapping__ = moduleIdMapping + } else { + const chunks = [...chunk.files].filter((f) => f.endsWith('.css')) + manifest[resource] = { + default: { + id, + name: 'default', + chunks, + }, + } + moduleIdMapping[id]['default'] = { + id: ssrNamedModuleId, + name: 'default', + chunks, + } + manifest.__ssr_module_mapping__ = moduleIdMapping + } + } + return + } + + // TODO: Hook into deps instead of the target module. + // That way we know by the type of dep whether to include. + // It also resolves conflicts when the same module is in multiple chunks. + if (!clientComponentRegex.test(resource)) { + return + } + const exportsInfo = compilation.moduleGraph.getExportsInfo(mod) const cjsExports = [ ...new Set( @@ -132,16 +186,36 @@ export class FlightManifestPlugin { ) .filter((name) => name !== null) - // Get all CSS files imported in that chunk. - const cssChunks: string[] = [] - for (const entrypoint of chunk.groupsIterable) { - const files = getEntrypointFiles(entrypoint) - for (const file of files) { - if (file.endsWith('.css')) { - cssChunks.push(file) - } + // Get all CSS files imported from the module's dependencies. + const visitedModule = new Set() + const cssChunks: Set = new Set() + + function collectClientImportedCss(module: any) { + if (!module) return + + const modRequest = module.userRequest + if (visitedModule.has(modRequest)) return + visitedModule.add(modRequest) + + if (/\.css$/.test(modRequest)) { + // collect relative imported css chunks + compilation.chunkGraph.getModuleChunks(module).forEach((c) => { + ;[...c.files] + .filter((file) => file.endsWith('.css')) + .forEach((file) => cssChunks.add(file)) + }) } + + const connections = Array.from( + compilation.moduleGraph.getOutgoingConnections(module) + ) + connections.forEach((connection) => { + collectClientImportedCss( + compilation.moduleGraph.getResolvedModule(connection.dependency!) + ) + }) } + collectClientImportedCss(mod) moduleExportedKeys.forEach((name) => { let requiredChunks = [] @@ -167,7 +241,7 @@ export class FlightManifestPlugin { moduleExports[name] = { id, name, - chunks: requiredChunks.concat(cssChunks), + chunks: requiredChunks.concat([...cssChunks]), } } if (!moduleIdMapping[id][name]) { diff --git a/packages/next/client/app-index.tsx b/packages/next/client/app-index.tsx index 13ba7f0bed17..bb5f49a1304a 100644 --- a/packages/next/client/app-index.tsx +++ b/packages/next/client/app-index.tsx @@ -30,8 +30,7 @@ __webpack_require__.u = (chunkId: any) => { self.__next_require__ = __webpack_require__ // eslint-disable-next-line no-undef -// @ts-expect-error TODO: fix type -self.__next_chunk_load__ = (chunk) => { +;(self as any).__next_chunk_load__ = (chunk: string) => { if (chunk.endsWith('.css')) { const link = document.createElement('link') link.rel = 'stylesheet' @@ -153,7 +152,52 @@ function useInitialServerResponse(cacheKey: string) { nextServerDataRegisterWriter(controller) }, }) - const newResponse = createFromReadableStream(readable) + + async function loadCss(cssChunkInfoJson: string) { + const data = JSON.parse(cssChunkInfoJson) + await Promise.all( + data.chunks.map((chunkId: string) => { + // load css related chunks + return (self as any).__next_chunk_load__(chunkId) + }) + ) + // In development mode, import css in dev when it's wrapped by style loader. + // In production mode, css are standalone chunk that doesn't need to be imported. + if (data.id) { + ;(self as any).__next_require__(data.id) + } + } + + const loadCssFromStreamData = (data: string) => { + const seg = data.split(':') + if (seg[0] === 'CSS') { + loadCss(seg.slice(1).join(':')) + } + } + + let buffer = '' + const loadCssFromFlight = new TransformStream({ + transform(chunk, controller) { + const data = new TextDecoder().decode(chunk) + buffer += data + let index + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index) + buffer = buffer.slice(index + 1) + loadCssFromStreamData(line) + } + if (!data.startsWith('CSS:')) { + controller.enqueue(chunk) + } + }, + flush() { + loadCssFromStreamData(buffer) + }, + }) + + const newResponse = createFromReadableStream( + readable.pipeThrough(loadCssFromFlight) + ) rscCache.set(cacheKey, newResponse) return newResponse diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index ae60c44abccc..c1b1059af191 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -16,6 +16,7 @@ import { renderToInitialStream, createBufferedTransformStream, continueFromInitialStream, + createSuffixStream, } from './node-web-streams-helper' import { isDynamicRoute } from '../shared/lib/router/utils' import { tryGetPreviewData } from './api-utils/node' @@ -124,7 +125,6 @@ function useFlightResponse( rscCache.set(id, entry) let bootstrapped = false - let remainingFlightResponse = '' const forwardReader = forwardStream.getReader() const writer = writable.getWriter() function process() { @@ -144,40 +144,11 @@ function useFlightResponse( writer.close() } else { const responsePartial = decodeText(value) - const css = responsePartial - .split('\n') - .map((partialLine) => { - const line = remainingFlightResponse + partialLine - remainingFlightResponse = '' - - try { - const match = line.match(/^M\d+:(.+)/) - if (match) { - return JSON.parse(match[1]) - .chunks.filter((chunkId: string) => - chunkId.endsWith('.css') - ) - .map( - (file: string) => - `` - ) - .join('') - } - return '' - } catch (err) { - // The JSON is partial - remainingFlightResponse = line - return '' - } - }) - .join('') - writer.write( encodeText( - css + - `` + `` ) ) process() @@ -217,8 +188,9 @@ function createServerComponentRenderer( globalThis.__next_chunk_load__ = () => Promise.resolve() } - let RSCStream: ReadableStream + const cssFlight = getCssFlight(ComponentMod, serverComponentManifest) + let RSCStream: ReadableStream const createRSCStream = () => { if (!RSCStream) { RSCStream = renderToReadableStream( @@ -227,7 +199,7 @@ function createServerComponentRenderer( { context: serverContexts, } - ) + ).pipeThrough(createSuffixStream(cssFlight)) } return RSCStream } @@ -350,6 +322,30 @@ function getSegmentParam(segment: string): { return null } +function getCssFlight(ComponentMod: any, serverComponentManifest: any) { + const importedServerCSSFiles: string[] = + ComponentMod.__client__?.__next_rsc_css__ || [] + + const cssFiles = importedServerCSSFiles.map( + (css) => serverComponentManifest[css].default + ) + if (process.env.NODE_ENV === 'development') { + return cssFiles.map((css) => `CSS:${JSON.stringify(css)}`).join('\n') + } + + // Multiple css chunks could be merged into one by mini-css-extract-plugin, + // we use a set here to dedupe the css chunks in production. + const cssSet = cssFiles.reduce((res, css) => { + res.add(...css.chunks) + return res + }, new Set()) + + const cssFlight = Array.from(cssSet) + .map((css) => `CSS:${JSON.stringify({ chunks: [css] })}`) + .join('\n') + return cssFlight +} + export async function renderToHTML( req: IncomingMessage, res: ServerResponse, @@ -764,6 +760,7 @@ export async function renderToHTML( return [actualSegment] } + const cssFlight = getCssFlight(ComponentMod, serverComponentManifest) const flightData: FlightData = [ // TODO: change walk to output without '' walkTreeWithFlightRouterState(tree, {}, providedFlightRouterState).slice( @@ -772,9 +769,9 @@ export async function renderToHTML( ] return new RenderResult( - renderToReadableStream(flightData, serverComponentManifest).pipeThrough( - createBufferedTransformStream() - ) + renderToReadableStream(flightData, serverComponentManifest) + .pipeThrough(createSuffixStream(cssFlight)) + .pipeThrough(createBufferedTransformStream()) ) } diff --git a/packages/next/server/load-components.ts b/packages/next/server/load-components.ts index 6d7737469340..68ed0c65499b 100644 --- a/packages/next/server/load-components.ts +++ b/packages/next/server/load-components.ts @@ -125,12 +125,13 @@ export async function loadComponents( if (hasServerComponents) { try { // Make sure to also load the client entry in cache. - await requirePage( + const __client__ = await requirePage( normalizePagePath(pathname) + NEXT_CLIENT_SSR_ENTRY_SUFFIX, distDir, serverless, appDirEnabled ) + ComponentMod.__client__ = __client__ } catch (_) { // This page might not be a server component page, so there is no // client entry to load. diff --git a/test/e2e/app-dir/app/app/dashboard/global.css b/test/e2e/app-dir/app/app/dashboard/global.css new file mode 100644 index 000000000000..a83c888e2301 --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/global.css @@ -0,0 +1,3 @@ +.dangerous-text { + color: red; +} diff --git a/test/e2e/app-dir/app/app/dashboard/layout.server.js b/test/e2e/app-dir/app/app/dashboard/layout.server.js index 35bece01cfd7..ff3ee2994787 100644 --- a/test/e2e/app-dir/app/app/dashboard/layout.server.js +++ b/test/e2e/app-dir/app/app/dashboard/layout.server.js @@ -1,8 +1,11 @@ +import './style.css' +import './global.css' + export default function DashboardLayout(props) { return ( - <> -

Dashboard

+
+

Dashboard

{props.children} - +
) } diff --git a/test/e2e/app-dir/app/app/dashboard/page.server.js b/test/e2e/app-dir/app/app/dashboard/page.server.js index b29dd4fa9f0a..02f5972711fb 100644 --- a/test/e2e/app-dir/app/app/dashboard/page.server.js +++ b/test/e2e/app-dir/app/app/dashboard/page.server.js @@ -1,7 +1,8 @@ export default function DashboardPage(props) { return ( <> -

hello from app/dashboard

+

hello from app/dashboard

+

this is green

) } diff --git a/test/e2e/app-dir/app/app/dashboard/style.css b/test/e2e/app-dir/app/app/dashboard/style.css new file mode 100644 index 000000000000..fa95e11ba9ef --- /dev/null +++ b/test/e2e/app-dir/app/app/dashboard/style.css @@ -0,0 +1,3 @@ +.green { + color: green; +} diff --git a/test/e2e/app-dir/index.test.ts b/test/e2e/app-dir/index.test.ts index 98caaf0045c1..69ead33046f4 100644 --- a/test/e2e/app-dir/index.test.ts +++ b/test/e2e/app-dir/index.test.ts @@ -393,6 +393,24 @@ describe('app dir', () => { }) describe('css support', () => { + it('should support global css inside server component layouts', async () => { + const browser = await webdriver(next.url, '/dashboard') + + // Should body text in red + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('.p')).color` + ) + ).toBe('rgb(255, 0, 0)') + + // Should inject global css for .green selectors + expect( + await browser.eval( + `window.getComputedStyle(document.querySelector('.green')).color` + ) + ).toBe('rgb(0, 128, 0)') + }) + it('should support css modules inside client layouts', async () => { const browser = await webdriver(next.url, '/client-nested')