diff --git a/packages/vite/src/client.ts b/packages/vite/src/client.ts index 1964fbbd4e7..1a1e6865292 100644 --- a/packages/vite/src/client.ts +++ b/packages/vite/src/client.ts @@ -14,7 +14,6 @@ import type { OutputOptions } from 'rollup' import { cacheDirPlugin } from './plugins/cache-dir' import { wpfs } from './utils/wpfs' import type { ViteBuildContext, ViteOptions } from './vite' -import { writeManifest } from './manifest' import { devStyleSSRPlugin } from './plugins/dev-ssr-css' import { viteNodePlugin } from './vite-node' @@ -140,6 +139,4 @@ export async function buildClient (ctx: ViteBuildContext) { await ctx.nuxt.callHook('build:resources', wpfs) logger.info(`Client built in ${Date.now() - start}ms`) } - - await writeManifest(ctx) } diff --git a/packages/vite/src/plugins/ssr-styles.ts b/packages/vite/src/plugins/ssr-styles.ts index ba964a3eb5d..84232ed7216 100644 --- a/packages/vite/src/plugins/ssr-styles.ts +++ b/packages/vite/src/plugins/ssr-styles.ts @@ -9,11 +9,12 @@ import { isCSS } from '../utils' interface SSRStylePluginOptions { srcDir: string + chunksWithInlinedCSS: Set shouldInline?: (id?: string) => boolean } export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { - const cssMap: Record = {} + const cssMap: Record = {} const idRefMap: Record = {} const globalStyles = new Set() @@ -24,7 +25,9 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { generateBundle (outputOptions) { const emitted: Record = {} for (const file in cssMap) { - if (!cssMap[file].length) { continue } + const { files, inBundle } = cssMap[file] + // File has been tree-shaken out of build (or there are no styles to inline) + if (!files.length || !inBundle) { continue } const base = typeof outputOptions.assetFileNames === 'string' ? outputOptions.assetFileNames @@ -38,14 +41,19 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { type: 'asset', name: `${filename(file)}-styles.mjs`, source: [ - ...cssMap[file].map((css, i) => `import style_${i} from './${relative(dirname(base), this.getFileName(css))}';`), - `export default [${cssMap[file].map((_, i) => `style_${i}`).join(', ')}]` + ...files.map((css, i) => `import style_${i} from './${relative(dirname(base), this.getFileName(css))}';`), + `export default [${files.map((_, i) => `style_${i}`).join(', ')}]` ].join('\n') }) } const globalStylesArray = Array.from(globalStyles).map(css => idRefMap[css] && this.getFileName(idRefMap[css])).filter(Boolean) + for (const key in emitted) { + // Track the chunks we are inlining CSS for so we can omit including links to the .css files + options.chunksWithInlinedCSS.add(key) + } + this.emitFile({ type: 'asset', fileName: 'styles.mjs', @@ -61,11 +69,21 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { }) }, renderChunk (_code, chunk) { - if (!chunk.isEntry) { return null } - // Entry - for (const mod in chunk.modules) { - if (isCSS(mod) && !mod.includes('&used')) { - globalStyles.add(relativeToSrcDir(mod)) + if (!chunk.facadeModuleId) { return null } + const id = relativeToSrcDir(chunk.facadeModuleId) + for (const file in chunk.modules) { + const relativePath = relativeToSrcDir(file) + if (relativePath in cssMap) { + cssMap[relativePath].inBundle = cssMap[relativePath].inBundle ?? !!id + } + } + + if (chunk.isEntry) { + // Entry + for (const mod in chunk.modules) { + if (isCSS(mod) && !mod.includes('&used')) { + globalStyles.add(relativeToSrcDir(mod)) + } } } return null @@ -77,7 +95,7 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { if (options.shouldInline && !options.shouldInline(id)) { return } const relativeId = relativeToSrcDir(id) - cssMap[relativeId] = cssMap[relativeId] || [] + cssMap[relativeId] = cssMap[relativeId] || { files: [] } let styleCtr = 0 for (const i of findStaticImports(code)) { @@ -94,7 +112,7 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { }) idRefMap[relativeToSrcDir(resolved.id)] = ref - cssMap[relativeId].push(ref) + cssMap[relativeId].files.push(ref) } } } diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts index a6eedeb21ae..d03546d1774 100644 --- a/packages/vite/src/server.ts +++ b/packages/vite/src/server.ts @@ -10,6 +10,7 @@ import { wpfs } from './utils/wpfs' import { cacheDirPlugin } from './plugins/cache-dir' import { initViteNodeServer } from './vite-node' import { ssrStylesPlugin } from './plugins/ssr-styles' +import { writeManifest } from './manifest' export async function buildServer (ctx: ViteBuildContext) { const useAsyncEntry = ctx.nuxt.options.experimental.asyncEntry || @@ -113,12 +114,35 @@ export async function buildServer (ctx: ViteBuildContext) { } as ViteOptions) if (ctx.nuxt.options.experimental.inlineSSRStyles) { + const chunksWithInlinedCSS = new Set() serverConfig.plugins!.push(ssrStylesPlugin({ srcDir: ctx.nuxt.options.srcDir, + chunksWithInlinedCSS, shouldInline: typeof ctx.nuxt.options.experimental.inlineSSRStyles === 'function' ? ctx.nuxt.options.experimental.inlineSSRStyles : undefined })) + + // 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 = [] + } + // Add entry CSS as prefetch (non-blocking) + if (entry.isEntry) { + manifest[key + '-css'] = { + file: '', + css: entry.css + } + entry.css = [] + entry.dynamicImports = entry.dynamicImports || [] + entry.dynamicImports.push(key + '-css') + } + } + }) } // Add type-checking @@ -140,6 +164,8 @@ export async function buildServer (ctx: ViteBuildContext) { const start = Date.now() logger.info('Building server...') await vite.build(serverConfig) + // Write production client manifest + await writeManifest(ctx) await onBuild() logger.success(`Server built in ${Date.now() - start}ms`) return @@ -150,6 +176,9 @@ export async function buildServer (ctx: ViteBuildContext) { return } + // Write dev client manifest + await writeManifest(ctx) + // Start development server const viteServer = await vite.createServer(serverConfig) ctx.ssrServer = viteServer diff --git a/test/basic.test.ts b/test/basic.test.ts index e236183e0b5..00c80e211f4 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { joinURL } from 'ufo' // import { isWindows } from 'std-env' -import { setup, fetch, $fetch, startServer } from '@nuxt/test-utils' +import { setup, fetch, $fetch, startServer, createPage } from '@nuxt/test-utils' // eslint-disable-next-line import/order import { expectNoClientErrors, renderPage } from './utils' @@ -384,8 +384,23 @@ if (!process.env.NUXT_TEST_DEV && !process.env.TEST_WITH_WEBPACK) { expect(html).toContain(style) } }) - it.todo('does not render style hints for inlined styles') - it.todo('renders client-only styles?', async () => { + + it('only renders prefetch for entry styles', async () => { + const html: string = await $fetch('/styles') + expect(html.match(/]*href="[^"]*\.css">/)?.map(m => m.replace(/\.[^.]*\.css/, '.css'))).toMatchInlineSnapshot(` + [ + "", + ] + `) + }) + + it('still downloads client-only styles', async () => { + const page = await createPage('/styles') + await page.waitForLoadState('networkidle') + expect(await page.$eval('.client-only-css', e => getComputedStyle(e).color)).toBe('rgb(50, 50, 50)') + }) + + it.todo('renders client-only styles only', async () => { const html = await $fetch('/styles') expect(html).toContain('{--client-only:"client-only"}') }) diff --git a/test/fixtures/basic/components/ClientOnlyScript.client.vue b/test/fixtures/basic/components/ClientOnlyScript.client.vue index 743bba189fd..a0eba9bb74e 100644 --- a/test/fixtures/basic/components/ClientOnlyScript.client.vue +++ b/test/fixtures/basic/components/ClientOnlyScript.client.vue @@ -11,7 +11,9 @@ export default defineNuxtComponent({ @@ -21,3 +23,9 @@ export default defineNuxtComponent({ --client-only: 'client-only'; } + + diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index cad7ada709c..255c4713300 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -36,6 +36,7 @@ export default defineNuxtConfig({ } }, experimental: { + inlineSSRStyles: id => !id.includes('assets.vue'), reactivityTransform: true, treeshakeClientOnly: true },