From 343a46d5f9fd590bfce1f9e9783a63c9ac8f5ffd Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 20 Jun 2023 19:28:44 +0100 Subject: [PATCH] fix(nuxt): inline css directly in root component (#21573) --- .../nuxt/src/core/runtime/nitro/renderer.ts | 11 ++ packages/vite/src/plugins/paths.ts | 5 +- packages/vite/src/plugins/ssr-styles.ts | 104 ++++++++++++++++-- packages/vite/src/server.ts | 22 ---- packages/vite/src/vite.ts | 30 +++++ test/basic.test.ts | 63 +++++++++-- test/bundle.test.ts | 6 +- .../components/ServerOnlyComponent.server.vue | 6 + test/fixtures/basic/pages/styles.vue | 1 + 9 files changed, 200 insertions(+), 48 deletions(-) diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index a72c1e520a..4244cbf143 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -66,6 +66,8 @@ const getClientManifest: () => Promise = () => import('#build/dist/ser .then(r => r.default || r) .then(r => typeof r === 'function' ? r() : r) as Promise +const getEntryId: () => Promise = () => getClientManifest().then(r => Object.values(r).find(r => r.isEntry)!.src!) + // @ts-expect-error virtual file const getStaticRenderedHead = (): Promise => import('#head-static').then(r => r.default || r) @@ -283,6 +285,15 @@ export default defineRenderHandler(async (event): Promise vue files if (pathname.endsWith('.vue')) { diff --git a/packages/vite/src/plugins/ssr-styles.ts b/packages/vite/src/plugins/ssr-styles.ts index 0ef84cefb0..66f8491ed0 100644 --- a/packages/vite/src/plugins/ssr-styles.ts +++ b/packages/vite/src/plugins/ssr-styles.ts @@ -1,17 +1,24 @@ import { pathToFileURL } from 'node:url' import type { Plugin } from 'vite' -import { findStaticImports } from 'mlly' import { dirname, relative } from 'pathe' -import { genObjectFromRawEntries } from 'knitwork' +import { genImport, genObjectFromRawEntries } from 'knitwork' import { filename } from 'pathe/utils' import { parseQuery, parseURL } from 'ufo' import type { Component } from '@nuxt/schema' +import MagicString from 'magic-string' +import { findStaticImports } from 'mlly' + +import { isCSS } from '../utils' interface SSRStylePluginOptions { srcDir: string chunksWithInlinedCSS: Set shouldInline?: ((id?: string) => boolean) | boolean components: Component[] + clientCSSMap: Record> + entry: string + globalCSS: string[] + mode: 'server' | 'client' } const SUPPORTED_FILES_RE = /\.(vue|((c|m)?j|t)sx?)$/ @@ -33,10 +40,17 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { name: 'ssr-styles', resolveId: { order: 'pre', - async handler (id, importer, options) { - if (!id.endsWith('.vue')) { return } + async handler (id, importer, _options) { + // We deliberately prevent importing `#build/css` to avoid including it in the client bundle + // in its entirety. We will instead include _just_ the styles that can't be inlined, + // in the component below + if (options.mode === 'client' && id === '#build/css' && (options.shouldInline === true || (typeof options.shouldInline === 'function' && options.shouldInline(importer)))) { + return this.resolve('unenv/runtime/mock/empty', importer, _options) + } - const res = await this.resolve(id, importer, { ...options, skipSelf: true }) + if (options.mode === 'client' || !id.endsWith('.vue')) { return } + + const res = await this.resolve(id, importer, { ..._options, skipSelf: true }) if (res) { return { ...res, @@ -46,6 +60,8 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { } }, generateBundle (outputOptions) { + if (options.mode === 'client') { return } + const emitted: Record = {} for (const file in cssMap) { const { files, inBundle } = cssMap[file] @@ -75,6 +91,8 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { options.chunksWithInlinedCSS.add(key) } + // TODO: remove css from vite preload arrays + this.emitFile({ type: 'asset', fileName: 'styles.mjs', @@ -89,6 +107,19 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { }, renderChunk (_code, chunk) { if (!chunk.facadeModuleId) { return null } + + // 'Teleport' CSS chunks that made it into the bundle on the client side + // to be inlined on server rendering + if (options.mode === 'client') { + options.clientCSSMap[chunk.facadeModuleId] ||= new Set() + for (const id of chunk.moduleIds) { + if (isCSS(id)) { + options.clientCSSMap[chunk.facadeModuleId].add(id) + } + } + return + } + const id = relativeToSrcDir(chunk.facadeModuleId) for (const file in chunk.modules) { const relativePath = relativeToSrcDir(file) @@ -100,10 +131,41 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { return null }, async transform (code, id) { - const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) - const query = parseQuery(search) + if (options.mode === 'client') { + // We will either teleport global CSS to the 'entry' chunk on the server side + // or include it here in the client build so it is emitted in the CSS. + if (id === options.entry && (options.shouldInline === true || (typeof options.shouldInline === 'function' && options.shouldInline(id)))) { + const s = new MagicString(code) + options.clientCSSMap[id] ||= new Set() + for (const file of options.globalCSS) { + const resolved = await this.resolve(file, id) + const res = await this.resolve(file + '?inline&used', id) + if (!resolved || !res) { + if (!warnCache.has(file)) { + warnCache.add(file) + this.warn(`[nuxt] Cannot extract styles for \`${file}\`. Its styles will not be inlined when server-rendering.`) + } + s.prepend(`${genImport(file)}\n`) + continue + } + options.clientCSSMap[id].add(resolved.id) + } + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ hires: true }) + } + } + } + return + } - if (!SUPPORTED_FILES_RE.test(pathname) || query.macro || query.nuxt_component) { return } + const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + + if (!(id in options.clientCSSMap) && !islands.some(c => c.filePath === pathname)) { return } + + const query = parseQuery(search) + if (query.macro || query.nuxt_component) { return } if (!islands.some(c => c.filePath === pathname)) { if (options.shouldInline === false || (typeof options.shouldInline === 'function' && !options.shouldInline(id))) { return } @@ -112,7 +174,32 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { const relativeId = relativeToSrcDir(id) cssMap[relativeId] = cssMap[relativeId] || { files: [] } + const emittedIds = new Set() + let styleCtr = 0 + const ids = options.clientCSSMap[id] || [] + for (const file of ids) { + const resolved = await this.resolve(file, id) + if (!resolved || !(await this.resolve(file + '?inline&used', id))) { + if (!warnCache.has(file)) { + warnCache.add(file) + this.warn(`[nuxt] Cannot extract styles for \`${file}\`. Its styles will not be inlined when server-rendering.`) + } + continue + } + if (emittedIds.has(file)) { continue } + const ref = this.emitFile({ + type: 'chunk', + name: `${filename(id)}-styles-${++styleCtr}.mjs`, + id: file + '?inline&used' + }) + + idRefMap[relativeToSrcDir(file)] = ref + cssMap[relativeId].files.push(ref) + } + + if (!SUPPORTED_FILES_RE.test(pathname)) { return } + for (const i of findStaticImports(code)) { const { type } = parseQuery(i.specifier) if (type !== 'style' && !i.specifier.endsWith('.css')) { continue } @@ -127,6 +214,7 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { continue } + if (emittedIds.has(resolved.id)) { continue } const ref = this.emitFile({ type: 'chunk', name: `${filename(id)}-styles-${++styleCtr}.mjs`, diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts index c98a22a8b9..8c1886e3e7 100644 --- a/packages/vite/src/server.ts +++ b/packages/vite/src/server.ts @@ -8,7 +8,6 @@ import type { ViteConfig } from '@nuxt/schema' import type { ViteBuildContext } from './vite' import { createViteLogger } from './utils/logger' import { initViteNodeServer } from './vite-node' -import { ssrStylesPlugin } from './plugins/ssr-styles' import { pureAnnotationsPlugin } from './plugins/pure-annotations' import { writeManifest } from './manifest' import { transpile } from './utils/transpile' @@ -120,27 +119,6 @@ export async function buildServer (ctx: ViteBuildContext) { serverConfig.customLogger = createViteLogger(serverConfig) - if (!ctx.nuxt.options.dev) { - const chunksWithInlinedCSS = new Set() - serverConfig.plugins!.push(ssrStylesPlugin({ - srcDir: ctx.nuxt.options.srcDir, - chunksWithInlinedCSS, - shouldInline: ctx.nuxt.options.experimental.inlineSSRStyles, - components: ctx.nuxt.apps.default.components - })) - - // Remove CSS entries for files that will have inlined styles - ctx.nuxt.hook('build:manifest', (manifest) => { - for (const key in manifest) { - const entry = manifest[key] - const shouldRemoveCSS = chunksWithInlinedCSS.has(key) - if (shouldRemoveCSS) { - entry.css = [] - } - } - }) - } - await ctx.nuxt.callHook('vite:extendConfig', serverConfig, { isClient: false, isServer: true }) serverConfig.plugins!.unshift( diff --git a/packages/vite/src/vite.ts b/packages/vite/src/vite.ts index 67e5cbfd68..3ca8f86c6c 100644 --- a/packages/vite/src/vite.ts +++ b/packages/vite/src/vite.ts @@ -16,6 +16,7 @@ import { warmupViteServer } from './utils/warmup' import { resolveCSSOptions } from './css' import { composableKeysPlugin } from './plugins/composable-keys' import { logLevelMap } from './utils/logger' +import { ssrStylesPlugin } from './plugins/ssr-styles' export interface ViteBuildContext { nuxt: Nuxt @@ -143,6 +144,35 @@ export async function bundle (nuxt: Nuxt) { await nuxt.callHook('vite:extend', ctx) + if (!ctx.nuxt.options.dev) { + const chunksWithInlinedCSS = new Set() + const clientCSSMap = {} + + nuxt.hook('vite:extendConfig', (config, { isServer }) => { + config.plugins!.push(ssrStylesPlugin({ + srcDir: ctx.nuxt.options.srcDir, + clientCSSMap, + chunksWithInlinedCSS, + shouldInline: ctx.nuxt.options.experimental.inlineSSRStyles, + components: ctx.nuxt.apps.default.components, + globalCSS: ctx.nuxt.options.css, + mode: isServer ? 'server' : 'client', + entry: ctx.entry + })) + }) + + // Remove CSS entries for files that will have inlined styles + ctx.nuxt.hook('build:manifest', (manifest) => { + for (const key in manifest) { + const entry = manifest[key] + const shouldRemoveCSS = chunksWithInlinedCSS.has(key) && !entry.isEntry + if (shouldRemoveCSS && entry.css) { + entry.css = [] + } + } + }) + } + nuxt.hook('vite:serverCreated', (server: vite.ViteDevServer, env) => { // Invalidate virtual modules when templates are re-generated ctx.nuxt.hook('app:templatesGenerated', () => { diff --git a/test/basic.test.ts b/test/basic.test.ts index e121f4a7ee..70b468d58b 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1168,17 +1168,47 @@ describe('automatically keyed composables', () => { }) describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { + const inlinedCSS = [ + '{--plugin:"plugin"}', // CSS imported ambiently in JS/TS + '{--global:"global";', // global css from nuxt.config + '{--assets:"assets"}', //