diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 4436a19ca9c..59006df2742 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -97,6 +97,7 @@ export async function initNitro (nuxt: Nuxt) { }, replace: { 'process.env.NUXT_NO_SSR': nuxt.options.ssr === false, + 'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles, 'process.dev': nuxt.options.dev, __VUE_PROD_DEVTOOLS__: false }, @@ -110,6 +111,10 @@ export async function initNitro (nuxt: Nuxt) { nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}' } + if (!nuxt.options.experimental.inlineSSRStyles) { + nitroConfig.virtual!['#build/dist/server/styles.mjs'] = 'export default {}' + } + // Register nuxt protection patterns nitroConfig.rollupConfig!.plugins!.push(ImportProtectionPlugin.rollup({ rootDir: nuxt.options.rootDir, diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index d1163460bdb..658a65eaa55 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -37,6 +37,9 @@ const getClientManifest: () => Promise = () => import('#build/dist/ser // @ts-ignore const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r) +// @ts-ignore +const getSSRStyles = (): Promise Promise>> => import('#build/dist/server/styles.mjs').then(r => r.default || r) + // -- SSR Renderer -- const getSSRRenderer = lazyCachedFunction(async () => { // Load client manifest @@ -137,6 +140,11 @@ export default defineRenderHandler(async (event) => { // Render meta const renderedMeta = await ssrContext.renderMeta?.() ?? {} + // Render inline styles + const inlinedStyles = process.env.NUXT_INLINE_STYLES + ? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? []) + : '' + // Create render context const htmlContext: NuxtRenderHTMLContext = { htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]), @@ -144,6 +152,7 @@ export default defineRenderHandler(async (event) => { renderedMeta.headTags, _rendered.renderResourceHints(), _rendered.renderStyles(), + inlinedStyles, ssrContext.styles ]), bodyAttrs: normalizeChunks([renderedMeta.bodyAttrs!]), @@ -210,3 +219,16 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) { ${joinTags(html.bodyPreprend)}${joinTags(html.body)}${joinTags(html.bodyAppend)} ` } + +async function renderInlineStyles (usedModules: Set | string[]) { + const styleMap = await getSSRStyles() + const inlinedStyles = new Set() + for (const mod of usedModules) { + if (mod in styleMap) { + for (const style of await styleMap[mod]()) { + inlinedStyles.add(``) + } + } + } + return Array.from(inlinedStyles).join('') +} diff --git a/packages/schema/src/config/experimental.ts b/packages/schema/src/config/experimental.ts index 491cb3f82f1..da1ba4b3e17 100644 --- a/packages/schema/src/config/experimental.ts +++ b/packages/schema/src/config/experimental.ts @@ -48,9 +48,26 @@ export default defineUntypedSchema({ /** * Split server bundle into multiple chunks and dynamically import them. * - * * @see https://github.com/nuxt/framework/issues/6432 */ - viteServerDynamicImports: true + viteServerDynamicImports: true, + + /** + * Inline styles when rendering HTML (currently vite only). + * + * You can also pass a function that receives the path of a Vue component + * and returns a boolean indicating whether to inline the styles for that component. + * + * @type {boolean | ((id?: string) => boolean)} + */ + inlineSSRStyles: { + $resolve(val, get) { + if (val === false || get('dev') || get('ssr') === false || get('builder') === '@nuxt/webpack-builder') { + return false + } + // Enabled by default for vite prod with ssr + return val ?? true + } + }, } }) diff --git a/packages/vite/src/plugins/ssr-styles.ts b/packages/vite/src/plugins/ssr-styles.ts new file mode 100644 index 00000000000..ba964a3eb5d --- /dev/null +++ b/packages/vite/src/plugins/ssr-styles.ts @@ -0,0 +1,101 @@ +import { pathToFileURL } from 'node:url' +import { Plugin } from 'vite' +import { findStaticImports } from 'mlly' +import { dirname, relative } from 'pathe' +import { genObjectFromRawEntries } from 'knitwork' +import { filename } from 'pathe/utils' +import { parseQuery, parseURL } from 'ufo' +import { isCSS } from '../utils' + +interface SSRStylePluginOptions { + srcDir: string + shouldInline?: (id?: string) => boolean +} + +export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin { + const cssMap: Record = {} + const idRefMap: Record = {} + const globalStyles = new Set() + + const relativeToSrcDir = (path: string) => relative(options.srcDir, path) + + return { + name: 'ssr-styles', + generateBundle (outputOptions) { + const emitted: Record = {} + for (const file in cssMap) { + if (!cssMap[file].length) { continue } + + const base = typeof outputOptions.assetFileNames === 'string' + ? outputOptions.assetFileNames + : outputOptions.assetFileNames({ + type: 'asset', + name: `${filename(file)}-styles.mjs`, + source: '' + }) + + emitted[file] = this.emitFile({ + 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(', ')}]` + ].join('\n') + }) + } + + const globalStylesArray = Array.from(globalStyles).map(css => idRefMap[css] && this.getFileName(idRefMap[css])).filter(Boolean) + + this.emitFile({ + type: 'asset', + fileName: 'styles.mjs', + source: + [ + ...globalStylesArray.map((css, i) => `import style_${i} from './${css}';`), + `const globalStyles = [${globalStylesArray.map((_, i) => `style_${i}`).join(', ')}]`, + 'const resolveStyles = r => globalStyles.concat(r.default || r || [])', + `export default ${genObjectFromRawEntries( + Object.entries(emitted).map(([key, value]) => [key, `() => import('./${this.getFileName(value)}').then(resolveStyles)`]) + )}` + ].join('\n') + }) + }, + 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)) + } + } + return null + }, + async transform (code, id) { + const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) + const query = parseQuery(search) + if (!pathname.match(/\.(vue|((c|m)?j|t)sx?)$/g) || query.macro) { return } + if (options.shouldInline && !options.shouldInline(id)) { return } + + const relativeId = relativeToSrcDir(id) + cssMap[relativeId] = cssMap[relativeId] || [] + + let styleCtr = 0 + for (const i of findStaticImports(code)) { + const { type } = parseQuery(i.specifier) + if (type !== 'style' && !i.specifier.endsWith('.css')) { continue } + + const resolved = await this.resolve(i.specifier, id) + if (!resolved) { continue } + + const ref = this.emitFile({ + type: 'chunk', + name: `${filename(id)}-styles-${++styleCtr}.mjs`, + id: resolved.id + '?inline&used' + }) + + idRefMap[relativeToSrcDir(resolved.id)] = ref + cssMap[relativeId].push(ref) + } + } + } +} diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts index 0983ae264b7..0caba90c4c9 100644 --- a/packages/vite/src/server.ts +++ b/packages/vite/src/server.ts @@ -9,10 +9,11 @@ import { ViteBuildContext, ViteOptions } from './vite' import { wpfs } from './utils/wpfs' import { cacheDirPlugin } from './plugins/cache-dir' import { initViteNodeServer } from './vite-node' +import { ssrStylesPlugin } from './plugins/ssr-styles' export async function buildServer (ctx: ViteBuildContext) { const useAsyncEntry = ctx.nuxt.options.experimental.asyncEntry || - (ctx.nuxt.options.vite.devBundler === 'vite-node' && ctx.nuxt.options.dev) + (ctx.nuxt.options.vite.devBundler === 'vite-node' && ctx.nuxt.options.dev) ctx.entry = resolve(ctx.nuxt.options.appDir, useAsyncEntry ? 'entry.async' : 'entry') const _resolve = (id: string) => resolveModule(id, { paths: ctx.nuxt.options.modulesDir }) @@ -111,6 +112,15 @@ export async function buildServer (ctx: ViteBuildContext) { ] } as ViteOptions) + if (ctx.nuxt.options.experimental.inlineSSRStyles) { + serverConfig.plugins!.push(ssrStylesPlugin({ + srcDir: ctx.nuxt.options.srcDir, + shouldInline: typeof ctx.nuxt.options.experimental.inlineSSRStyles === 'function' + ? ctx.nuxt.options.experimental.inlineSSRStyles + : undefined + })) + } + // Add type-checking if (ctx.nuxt.options.typescript.typeCheck === true || (ctx.nuxt.options.typescript.typeCheck === 'build' && !ctx.nuxt.options.dev)) { const checker = await import('vite-plugin-checker').then(r => r.default) diff --git a/test/basic.test.ts b/test/basic.test.ts index 1572a4a98c3..d15b0919376 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -393,6 +393,29 @@ describe('automatically keyed composables', () => { }) }) +if (!process.env.NUXT_TEST_DEV && !process.env.TEST_WITH_WEBPACK) { + describe('inlining component styles', () => { + it('should inline styles', async () => { + const html = await $fetch('/styles') + for (const style of [ + '{--assets:"assets"}', // + + + + diff --git a/test/fixtures/basic/plugins/style.ts b/test/fixtures/basic/plugins/style.ts new file mode 100644 index 00000000000..50de6f2cb42 --- /dev/null +++ b/test/fixtures/basic/plugins/style.ts @@ -0,0 +1,5 @@ +import '~/assets/plugin.css' + +export default defineNuxtPlugin(() => { + // +})