mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
fix(nuxt): inline css directly in root component (#21573)
This commit is contained in:
parent
2c9ac8dd80
commit
343a46d5f9
@ -66,6 +66,8 @@ const getClientManifest: () => Promise<Manifest> = () => import('#build/dist/ser
|
||||
.then(r => r.default || r)
|
||||
.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
|
||||
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
|
||||
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
|
||||
const inlinedStyles = (process.env.NUXT_INLINE_STYLES || Boolean(islandContext))
|
||||
? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? [])
|
||||
|
@ -2,14 +2,13 @@ import { pathToFileURL } from 'node:url'
|
||||
import MagicString from 'magic-string'
|
||||
import { parseQuery, parseURL } from 'ufo'
|
||||
import type { Plugin } from 'vite'
|
||||
import { isCSS } from '../utils'
|
||||
|
||||
export interface RuntimePathsOptions {
|
||||
sourcemap?: boolean
|
||||
}
|
||||
|
||||
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 {
|
||||
return {
|
||||
@ -19,7 +18,7 @@ export function runtimePathsPlugin (options: RuntimePathsOptions): Plugin {
|
||||
const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
|
||||
|
||||
// skip import into css files
|
||||
if (CSS_RE.test(pathname)) { return }
|
||||
if (isCSS(pathname)) { return }
|
||||
|
||||
// skip import into <style> vue files
|
||||
if (pathname.endsWith('.vue')) {
|
||||
|
@ -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<string>
|
||||
shouldInline?: ((id?: string) => boolean) | boolean
|
||||
components: Component[]
|
||||
clientCSSMap: Record<string, Set<string>>
|
||||
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 <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) {
|
||||
return {
|
||||
...res,
|
||||
@ -46,6 +60,8 @@ export function ssrStylesPlugin (options: SSRStylePluginOptions): Plugin {
|
||||
}
|
||||
},
|
||||
generateBundle (outputOptions) {
|
||||
if (options.mode === 'client') { return }
|
||||
|
||||
const emitted: Record<string, string> = {}
|
||||
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<string>()
|
||||
|
||||
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`,
|
||||
|
@ -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<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 })
|
||||
|
||||
serverConfig.plugins!.unshift(
|
||||
|
@ -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<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) => {
|
||||
// Invalidate virtual modules when templates are re-generated
|
||||
ctx.nuxt.hook('app:templatesGenerated', () => {
|
||||
|
@ -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"}', // <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 () => {
|
||||
const html = await $fetch('/styles')
|
||||
for (const style of [
|
||||
'{--assets:"assets"}', // <script>
|
||||
'{--scoped:"scoped"}', // <style lang=css>
|
||||
'{--postcss:"postcss"}' // <style lang=postcss>
|
||||
]) {
|
||||
for (const style of inlinedCSS) {
|
||||
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 () => {
|
||||
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(`
|
||||
@ -1468,15 +1498,10 @@ describe('component islands', () => {
|
||||
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
|
||||
if (!isDev() && !isWebpack) {
|
||||
expect(result.head).toMatchInlineSnapshot(`
|
||||
expect(normaliseIslandResult(result).head).toMatchInlineSnapshot(`
|
||||
{
|
||||
"link": [],
|
||||
"style": [
|
||||
@ -1676,3 +1701,17 @@ describe.runIf(isDev())('component testing', () => {
|
||||
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]+$/, '')
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
||||
|
||||
it('default client bundle size', async () => {
|
||||
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(`
|
||||
[
|
||||
"_nuxt/entry.js",
|
||||
@ -35,10 +35,10 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM
|
||||
|
||||
it('default server bundle size', async () => {
|
||||
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)
|
||||
expect(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2295k"')
|
||||
expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"2295k"')
|
||||
|
||||
const packages = modules.files
|
||||
.filter(m => m.endsWith('package.json'))
|
||||
|
@ -3,3 +3,9 @@
|
||||
server-only component
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--server-only: 'server-only';
|
||||
}
|
||||
</style>
|
||||
|
1
test/fixtures/basic/pages/styles.vue
vendored
1
test/fixtures/basic/pages/styles.vue
vendored
@ -2,6 +2,7 @@
|
||||
<div>
|
||||
<ClientOnlyScript />
|
||||
<FunctionalComponent />
|
||||
<ServerOnlyComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user