fix(nuxt): inline css directly in root component (#21573)

This commit is contained in:
Daniel Roe 2023-06-20 19:28:44 +01:00 committed by GitHub
parent 2c9ac8dd80
commit 343a46d5f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 200 additions and 48 deletions

View File

@ -66,6 +66,8 @@ const getClientManifest: () => Promise<Manifest> = () => import('#build/dist/ser
.then(r => r.default || r) .then(r => r.default || r)
.then(r => typeof r === 'function' ? r() : r) as Promise<ClientManifest> .then(r => typeof r === 'function' ? r() : r) as Promise<ClientManifest>
const getEntryId: () => Promise<string> = () => getClientManifest().then(r => Object.values(r).find(r => r.isEntry)!.src!)
// @ts-expect-error virtual file // @ts-expect-error virtual file
const getStaticRenderedHead = (): Promise<NuxtMeta> => import('#head-static').then(r => r.default || r) const getStaticRenderedHead = (): Promise<NuxtMeta> => import('#head-static').then(r => r.default || r)
@ -283,6 +285,15 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// Render meta // Render meta
const renderedMeta = await ssrContext.renderMeta?.() ?? {} const renderedMeta = await ssrContext.renderMeta?.() ?? {}
if (process.env.NUXT_INLINE_STYLES && !islandContext) {
const entryId = await getEntryId()
if (ssrContext.modules) {
ssrContext.modules.add(entryId)
} else if (ssrContext._registeredComponents) {
ssrContext._registeredComponents.add(entryId)
}
}
// Render inline styles // Render inline styles
const inlinedStyles = (process.env.NUXT_INLINE_STYLES || Boolean(islandContext)) const inlinedStyles = (process.env.NUXT_INLINE_STYLES || Boolean(islandContext))
? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? []) ? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? [])

View File

@ -2,14 +2,13 @@ import { pathToFileURL } from 'node:url'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { parseQuery, parseURL } from 'ufo' import { parseQuery, parseURL } from 'ufo'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { isCSS } from '../utils'
export interface RuntimePathsOptions { export interface RuntimePathsOptions {
sourcemap?: boolean sourcemap?: boolean
} }
const VITE_ASSET_RE = /__VITE_ASSET__|__VITE_PUBLIC_ASSET__/ const VITE_ASSET_RE = /__VITE_ASSET__|__VITE_PUBLIC_ASSET__/
const CSS_RE =
/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)$/
export function runtimePathsPlugin (options: RuntimePathsOptions): Plugin { export function runtimePathsPlugin (options: RuntimePathsOptions): Plugin {
return { return {
@ -19,7 +18,7 @@ export function runtimePathsPlugin (options: RuntimePathsOptions): Plugin {
const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
// skip import into css files // skip import into css files
if (CSS_RE.test(pathname)) { return } if (isCSS(pathname)) { return }
// skip import into <style> vue files // skip import into <style> vue files
if (pathname.endsWith('.vue')) { if (pathname.endsWith('.vue')) {

View File

@ -1,17 +1,24 @@
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import type { Plugin } from 'vite' import type { Plugin } from 'vite'
import { findStaticImports } from 'mlly'
import { dirname, relative } from 'pathe' import { dirname, relative } from 'pathe'
import { genObjectFromRawEntries } from 'knitwork' import { genImport, genObjectFromRawEntries } from 'knitwork'
import { filename } from 'pathe/utils' import { filename } from 'pathe/utils'
import { parseQuery, parseURL } from 'ufo' import { parseQuery, parseURL } from 'ufo'
import type { Component } from '@nuxt/schema' import type { Component } from '@nuxt/schema'
import MagicString from 'magic-string'
import { findStaticImports } from 'mlly'
import { isCSS } from '../utils'
interface SSRStylePluginOptions { interface SSRStylePluginOptions {
srcDir: string srcDir: string
chunksWithInlinedCSS: Set<string> chunksWithInlinedCSS: Set<string>
shouldInline?: ((id?: string) => boolean) | boolean shouldInline?: ((id?: string) => boolean) | boolean
components: Component[] components: Component[]
clientCSSMap: Record<string, Set<string>>
entry: string
globalCSS: string[]
mode: 'server' | 'client'
} }
const SUPPORTED_FILES_RE = /\.(vue|((c|m)?j|t)sx?)$/ const SUPPORTED_FILES_RE = /\.(vue|((c|m)?j|t)sx?)$/
@ -33,10 +40,17 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
name: 'ssr-styles', name: 'ssr-styles',
resolveId: { resolveId: {
order: 'pre', order: 'pre',
async handler (id, importer, options) { async handler (id, importer, _options) {
if (!id.endsWith('.vue')) { return } // 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 <NuxtRoot> 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) { if (res) {
return { return {
...res, ...res,
@ -46,6 +60,8 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
} }
}, },
generateBundle (outputOptions) { generateBundle (outputOptions) {
if (options.mode === 'client') { return }
const emitted: Record<string, string> = {} const emitted: Record<string, string> = {}
for (const file in cssMap) { for (const file in cssMap) {
const { files, inBundle } = cssMap[file] const { files, inBundle } = cssMap[file]
@ -75,6 +91,8 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
options.chunksWithInlinedCSS.add(key) options.chunksWithInlinedCSS.add(key)
} }
// TODO: remove css from vite preload arrays
this.emitFile({ this.emitFile({
type: 'asset', type: 'asset',
fileName: 'styles.mjs', fileName: 'styles.mjs',
@ -89,6 +107,19 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
}, },
renderChunk (_code, chunk) { renderChunk (_code, chunk) {
if (!chunk.facadeModuleId) { return null } 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) const id = relativeToSrcDir(chunk.facadeModuleId)
for (const file in chunk.modules) { for (const file in chunk.modules) {
const relativePath = relativeToSrcDir(file) const relativePath = relativeToSrcDir(file)
@ -100,10 +131,41 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
return null return null
}, },
async transform (code, id) { async transform (code, id) {
const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href)) if (options.mode === 'client') {
const query = parseQuery(search) // 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 (!islands.some(c => c.filePath === pathname)) {
if (options.shouldInline === false || (typeof options.shouldInline === 'function' && !options.shouldInline(id))) { return } 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) const relativeId = relativeToSrcDir(id)
cssMap[relativeId] = cssMap[relativeId] || { files: [] } cssMap[relativeId] = cssMap[relativeId] || { files: [] }
const emittedIds = new Set<string>()
let styleCtr = 0 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)) { for (const i of findStaticImports(code)) {
const { type } = parseQuery(i.specifier) const { type } = parseQuery(i.specifier)
if (type !== 'style' && !i.specifier.endsWith('.css')) { continue } if (type !== 'style' && !i.specifier.endsWith('.css')) { continue }
@ -127,6 +214,7 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
continue continue
} }
if (emittedIds.has(resolved.id)) { continue }
const ref = this.emitFile({ const ref = this.emitFile({
type: 'chunk', type: 'chunk',
name: `${filename(id)}-styles-${++styleCtr}.mjs`, name: `${filename(id)}-styles-${++styleCtr}.mjs`,

View File

@ -8,7 +8,6 @@ import type { ViteConfig } from '@nuxt/schema'
import type { ViteBuildContext } from './vite' import type { ViteBuildContext } from './vite'
import { createViteLogger } from './utils/logger' import { createViteLogger } from './utils/logger'
import { initViteNodeServer } from './vite-node' import { initViteNodeServer } from './vite-node'
import { ssrStylesPlugin } from './plugins/ssr-styles'
import { pureAnnotationsPlugin } from './plugins/pure-annotations' import { pureAnnotationsPlugin } from './plugins/pure-annotations'
import { writeManifest } from './manifest' import { writeManifest } from './manifest'
import { transpile } from './utils/transpile' import { transpile } from './utils/transpile'
@ -120,27 +119,6 @@ export async function buildServer (ctx: ViteBuildContext) {
serverConfig.customLogger = createViteLogger(serverConfig) serverConfig.customLogger = createViteLogger(serverConfig)
if (!ctx.nuxt.options.dev) {
const chunksWithInlinedCSS = new Set<string>()
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 }) await ctx.nuxt.callHook('vite:extendConfig', serverConfig, { isClient: false, isServer: true })
serverConfig.plugins!.unshift( serverConfig.plugins!.unshift(

View File

@ -16,6 +16,7 @@ import { warmupViteServer } from './utils/warmup'
import { resolveCSSOptions } from './css' import { resolveCSSOptions } from './css'
import { composableKeysPlugin } from './plugins/composable-keys' import { composableKeysPlugin } from './plugins/composable-keys'
import { logLevelMap } from './utils/logger' import { logLevelMap } from './utils/logger'
import { ssrStylesPlugin } from './plugins/ssr-styles'
export interface ViteBuildContext { export interface ViteBuildContext {
nuxt: Nuxt nuxt: Nuxt
@ -143,6 +144,35 @@ export async function bundle (nuxt: Nuxt) {
await nuxt.callHook('vite:extend', ctx) await nuxt.callHook('vite:extend', ctx)
if (!ctx.nuxt.options.dev) {
const chunksWithInlinedCSS = new Set<string>()
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) => { nuxt.hook('vite:serverCreated', (server: vite.ViteDevServer, env) => {
// Invalidate virtual modules when templates are re-generated // Invalidate virtual modules when templates are re-generated
ctx.nuxt.hook('app:templatesGenerated', () => { ctx.nuxt.hook('app:templatesGenerated', () => {

View File

@ -1168,17 +1168,47 @@ describe('automatically keyed composables', () => {
}) })
describe.skipIf(isDev() || isWebpack)('inlining component styles', () => { 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"}', // <script>
'{--postcss:"postcss"}', // <style lang=postcss>
'{--scoped:"scoped"}' // <style lang=css>
// TODO: ideally both client/server components would have inlined css when used
// '{--client-only:"client-only"}', // client-only component not in server build
// '{--server-only:"server-only"}' // server-only component not in client build
// TODO: currently functional component not associated with ssrContext (upstream bug or perf optimization?)
// '{--functional:"functional"}', // CSS imported ambiently in a functional component
]
it('should inline styles', async () => { it('should inline styles', async () => {
const html = await $fetch('/styles') const html = await $fetch('/styles')
for (const style of [ for (const style of inlinedCSS) {
'{--assets:"assets"}', // <script>
'{--scoped:"scoped"}', // <style lang=css>
'{--postcss:"postcss"}' // <style lang=postcss>
]) {
expect(html).toContain(style) expect(html).toContain(style)
} }
}) })
it('should not include inlined CSS in generated CSS file', async () => {
const html: string = await $fetch('/styles')
const cssFiles = new Set([...html.matchAll(/<link [^>]*href="([^"]*\.css)">/g)].map(m => m[1]))
let css = ''
for (const file of cssFiles || []) {
css += await $fetch(file)
}
// should not include inlined CSS in generated CSS files
for (const style of inlinedCSS) {
// TODO: remove 'ambient global' CSS from generated CSS file
if (style === '{--plugin:"plugin"}') { continue }
expect.soft(css).not.toContain(style)
}
// should include unloadable CSS in generated CSS file
expect.soft(css).toContain('--virtual:red')
expect.soft(css).toContain('--functional:"functional"')
expect.soft(css).toContain('--client-only:"client-only"')
})
it('does not load stylesheet for page styles', async () => { it('does not load stylesheet for page styles', async () => {
const html: string = await $fetch('/styles') const html: string = await $fetch('/styles')
expect(html.match(/<link [^>]*href="[^"]*\.css">/g)?.filter(m => m.includes('entry'))?.map(m => m.replace(/\.[^.]*\.css/, '.css'))).toMatchInlineSnapshot(` expect(html.match(/<link [^>]*href="[^"]*\.css">/g)?.filter(m => m.includes('entry'))?.map(m => m.replace(/\.[^.]*\.css/, '.css'))).toMatchInlineSnapshot(`
@ -1468,15 +1498,10 @@ describe('component islands', () => {
link.key = link.key.replace(/-[a-zA-Z0-9]+$/, '') link.key = link.key.replace(/-[a-zA-Z0-9]+$/, '')
} }
} }
result.head.style = result.head.style.map(s => ({
...s,
innerHTML: (s.innerHTML || '').replace(/data-v-[a-z0-9]+/, 'data-v-xxxxx'),
key: s.key.replace(/-[a-zA-Z0-9]+$/, '')
}))
// TODO: fix rendering of styles in webpack // TODO: fix rendering of styles in webpack
if (!isDev() && !isWebpack) { if (!isDev() && !isWebpack) {
expect(result.head).toMatchInlineSnapshot(` expect(normaliseIslandResult(result).head).toMatchInlineSnapshot(`
{ {
"link": [], "link": [],
"style": [ "style": [
@ -1486,7 +1511,7 @@ describe('component islands', () => {
}, },
], ],
} }
`) `)
} else if (isDev() && !isWebpack) { } else if (isDev() && !isWebpack) {
expect(result.head).toMatchInlineSnapshot(` expect(result.head).toMatchInlineSnapshot(`
{ {
@ -1676,3 +1701,17 @@ describe.runIf(isDev())('component testing', () => {
expect(comp2).toContain('12 x 4 = 48') expect(comp2).toContain('12 x 4 = 48')
}) })
}) })
function normaliseIslandResult (result: NuxtIslandResponse) {
return {
...result,
head: {
...result.head,
style: result.head.style.map(s => ({
...s,
innerHTML: (s.innerHTML || '').replace(/data-v-[a-z0-9]+/, 'data-v-xxxxx').replace(/\.[a-zA-Z0-9]+\.svg/, '.svg'),
key: s.key.replace(/-[a-zA-Z0-9]+$/, '')
}))
}
}
}

View File

@ -25,7 +25,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
it('default client bundle size', async () => { it('default client bundle size', async () => {
stats.client = await analyzeSizes('**/*.js', publicDir) stats.client = await analyzeSizes('**/*.js', publicDir)
expect(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"96.7k"') expect.soft(roundToKilobytes(stats.client.totalBytes)).toMatchInlineSnapshot('"96.7k"')
expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(` expect(stats.client.files.map(f => f.replace(/\..*\.js/, '.js'))).toMatchInlineSnapshot(`
[ [
"_nuxt/entry.js", "_nuxt/entry.js",
@ -35,10 +35,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
it('default server bundle size', async () => { it('default server bundle size', async () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"61.0k"') expect.soft(roundToKilobytes(stats.server.totalBytes)).toMatchInlineSnapshot('"61.3k"')
const modules = await analyzeSizes('node_modules/**/*', serverDir) const modules = await analyzeSizes('node_modules/**/*', serverDir)
expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2295k"') expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2295k"')
const packages = modules.files const packages = modules.files
.filter(m => m.endsWith('package.json')) .filter(m => m.endsWith('package.json'))

View File

@ -3,3 +3,9 @@
server-only component server-only component
</div> </div>
</template> </template>
<style>
:root {
--server-only: 'server-only';
}
</style>

View File

@ -2,6 +2,7 @@
<div> <div>
<ClientOnlyScript /> <ClientOnlyScript />
<FunctionalComponent /> <FunctionalComponent />
<ServerOnlyComponent />
</div> </div>
</template> </template>