feat(nuxt, vite): inline global and component styles in server response (#7160)

Co-authored-by: Pooya Parsa <pooya@pi0.io>
This commit is contained in:
Daniel Roe 2022-09-03 14:03:30 +01:00 committed by GitHub
parent fe3fc34b12
commit de62520990
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 241 additions and 12 deletions

View File

@ -97,6 +97,7 @@ export async function initNitro (nuxt: Nuxt) {
}, },
replace: { replace: {
'process.env.NUXT_NO_SSR': nuxt.options.ssr === false, 'process.env.NUXT_NO_SSR': nuxt.options.ssr === false,
'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles,
'process.dev': nuxt.options.dev, 'process.dev': nuxt.options.dev,
__VUE_PROD_DEVTOOLS__: false __VUE_PROD_DEVTOOLS__: false
}, },
@ -110,6 +111,10 @@ export async function initNitro (nuxt: Nuxt) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}' 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 // Register nuxt protection patterns
nitroConfig.rollupConfig!.plugins!.push(ImportProtectionPlugin.rollup({ nitroConfig.rollupConfig!.plugins!.push(ImportProtectionPlugin.rollup({
rootDir: nuxt.options.rootDir, rootDir: nuxt.options.rootDir,

View File

@ -37,6 +37,9 @@ const getClientManifest: () => Promise<Manifest> = () => import('#build/dist/ser
// @ts-ignore // @ts-ignore
const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r) const getServerEntry = () => import('#build/dist/server/server.mjs').then(r => r.default || r)
// @ts-ignore
const getSSRStyles = (): Promise<Record<string, () => Promise<string[]>>> => import('#build/dist/server/styles.mjs').then(r => r.default || r)
// -- SSR Renderer -- // -- SSR Renderer --
const getSSRRenderer = lazyCachedFunction(async () => { const getSSRRenderer = lazyCachedFunction(async () => {
// Load client manifest // Load client manifest
@ -137,6 +140,11 @@ export default defineRenderHandler(async (event) => {
// Render meta // Render meta
const renderedMeta = await ssrContext.renderMeta?.() ?? {} const renderedMeta = await ssrContext.renderMeta?.() ?? {}
// Render inline styles
const inlinedStyles = process.env.NUXT_INLINE_STYLES
? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? [])
: ''
// Create render context // Create render context
const htmlContext: NuxtRenderHTMLContext = { const htmlContext: NuxtRenderHTMLContext = {
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]), htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
@ -144,6 +152,7 @@ export default defineRenderHandler(async (event) => {
renderedMeta.headTags, renderedMeta.headTags,
_rendered.renderResourceHints(), _rendered.renderResourceHints(),
_rendered.renderStyles(), _rendered.renderStyles(),
inlinedStyles,
ssrContext.styles ssrContext.styles
]), ]),
bodyAttrs: normalizeChunks([renderedMeta.bodyAttrs!]), bodyAttrs: normalizeChunks([renderedMeta.bodyAttrs!]),
@ -210,3 +219,16 @@ function renderHTMLDocument (html: NuxtRenderHTMLContext) {
<body ${joinAttrs(html.bodyAttrs)}>${joinTags(html.bodyPreprend)}${joinTags(html.body)}${joinTags(html.bodyAppend)}</body> <body ${joinAttrs(html.bodyAttrs)}>${joinTags(html.bodyPreprend)}${joinTags(html.body)}${joinTags(html.bodyAppend)}</body>
</html>` </html>`
} }
async function renderInlineStyles (usedModules: Set<string> | string[]) {
const styleMap = await getSSRStyles()
const inlinedStyles = new Set<string>()
for (const mod of usedModules) {
if (mod in styleMap) {
for (const style of await styleMap[mod]()) {
inlinedStyles.add(`<style>${style}</style>`)
}
}
}
return Array.from(inlinedStyles).join('')
}

View File

@ -48,9 +48,26 @@ export default defineUntypedSchema({
/** /**
* Split server bundle into multiple chunks and dynamically import them. * Split server bundle into multiple chunks and dynamically import them.
* *
*
* @see https://github.com/nuxt/framework/issues/6432 * @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
}
},
} }
}) })

View File

@ -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<string, string[]> = {}
const idRefMap: Record<string, string> = {}
const globalStyles = new Set<string>()
const relativeToSrcDir = (path: string) => relative(options.srcDir, path)
return {
name: 'ssr-styles',
generateBundle (outputOptions) {
const emitted: Record<string, string> = {}
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)
}
}
}
}

View File

@ -9,10 +9,11 @@ import { ViteBuildContext, ViteOptions } from './vite'
import { wpfs } from './utils/wpfs' import { wpfs } from './utils/wpfs'
import { cacheDirPlugin } from './plugins/cache-dir' import { cacheDirPlugin } from './plugins/cache-dir'
import { initViteNodeServer } from './vite-node' import { initViteNodeServer } from './vite-node'
import { ssrStylesPlugin } from './plugins/ssr-styles'
export async function buildServer (ctx: ViteBuildContext) { export async function buildServer (ctx: ViteBuildContext) {
const useAsyncEntry = ctx.nuxt.options.experimental.asyncEntry || 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') ctx.entry = resolve(ctx.nuxt.options.appDir, useAsyncEntry ? 'entry.async' : 'entry')
const _resolve = (id: string) => resolveModule(id, { paths: ctx.nuxt.options.modulesDir }) const _resolve = (id: string) => resolveModule(id, { paths: ctx.nuxt.options.modulesDir })
@ -111,6 +112,15 @@ export async function buildServer (ctx: ViteBuildContext) {
] ]
} as ViteOptions) } 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 // Add type-checking
if (ctx.nuxt.options.typescript.typeCheck === true || (ctx.nuxt.options.typescript.typeCheck === 'build' && !ctx.nuxt.options.dev)) { 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) const checker = await import('vite-plugin-checker').then(r => r.default)

View File

@ -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"}', // <script>
'{--scoped:"scoped"}', // <style lang=css>
'{--postcss:"postcss"}', // <style lang=postcss>
'{--global:"global"}', // entryfile dependency
'{--plugin:"plugin"}', // plugin dependency
'{--functional:"functional"}' // functional component with css import
]) {
expect(html).toContain(style)
}
})
it.todo('does not render style hints for inlined styles')
it.todo('renders client-only styles?', async () => {
const html = await $fetch('/styles')
expect(html).toContain('{--client-only:"client-only"}')
})
})
}
describe('prefetching', () => { describe('prefetching', () => {
it('should prefetch components', async () => { it('should prefetch components', async () => {
await expectNoClientErrors('/prefetch/components') await expectNoClientErrors('/prefetch/components')
@ -437,8 +460,8 @@ describe('dynamic paths', () => {
it('should work with no overrides', async () => { it('should work with no overrides', async () => {
const html: string = await $fetch('/assets') const html: string = await $fetch('/assets')
for (const match of html.matchAll(/(href|src)="(.*?)"/g)) { for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
const url = match[2] const url = match[2] || match[3]
expect(url.startsWith('/_nuxt/') || url === '/public.svg').toBeTruthy() expect(url.startsWith('/_nuxt/') || url === '/public.svg').toBeTruthy()
} }
}) })
@ -450,7 +473,7 @@ describe('dynamic paths', () => {
} }
const html: string = await $fetch('/assets') const html: string = await $fetch('/assets')
const urls = Array.from(html.matchAll(/(href|src)="(.*?)"/g)).map(m => m[2]) const urls = Array.from(html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)).map(m => m[2] || m[3])
const cssURL = urls.find(u => /_nuxt\/assets.*\.css$/.test(u)) const cssURL = urls.find(u => /_nuxt\/assets.*\.css$/.test(u))
expect(cssURL).toBeDefined() expect(cssURL).toBeDefined()
const css: string = await $fetch(cssURL!) const css: string = await $fetch(cssURL!)
@ -471,8 +494,8 @@ describe('dynamic paths', () => {
await startServer() await startServer()
const html = await $fetch('/foo/assets') const html = await $fetch('/foo/assets')
for (const match of html.matchAll(/(href|src)="(.`*?)"/g)) { for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
const url = match[2] const url = match[2] || match[3]
expect( expect(
url.startsWith('/foo/_other/') || url.startsWith('/foo/_other/') ||
url === '/foo/public.svg' || url === '/foo/public.svg' ||
@ -488,8 +511,8 @@ describe('dynamic paths', () => {
await startServer() await startServer()
const html = await $fetch('/assets') const html = await $fetch('/assets')
for (const match of html.matchAll(/(href|src)="(.*?)"/g)) { for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
const url = match[2] const url = match[2] || match[3]
expect( expect(
url.startsWith('./_nuxt/') || url.startsWith('./_nuxt/') ||
url === './public.svg' || url === './public.svg' ||
@ -516,8 +539,8 @@ describe('dynamic paths', () => {
await startServer() await startServer()
const html = await $fetch('/foo/assets') const html = await $fetch('/foo/assets')
for (const match of html.matchAll(/(href|src)="(.*?)"/g)) { for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
const url = match[2] const url = match[2] || match[3]
expect( expect(
url.startsWith('https://example.com/_cdn/') || url.startsWith('https://example.com/_cdn/') ||
url === 'https://example.com/public.svg' || url === 'https://example.com/public.svg' ||

3
test/fixtures/basic/assets/assets.css vendored Normal file
View File

@ -0,0 +1,3 @@
:root {
--assets: 'assets';
}

View File

@ -0,0 +1,3 @@
:root {
--functional: 'functional';
}

3
test/fixtures/basic/assets/global.css vendored Normal file
View File

@ -0,0 +1,3 @@
:root {
--global: 'global';
}

3
test/fixtures/basic/assets/plugin.css vendored Normal file
View File

@ -0,0 +1,3 @@
:root {
--plugin: 'plugin';
}

View File

@ -15,3 +15,9 @@ export default defineNuxtComponent({
<slot name="test" /> <slot name="test" />
</div> </div>
</template> </template>
<style>
:root {
--client-only: 'client-only';
}
</style>

View File

@ -0,0 +1,5 @@
import '~/assets/functional.css'
export default defineComponent({
render: () => 'hi'
})

View File

@ -12,6 +12,7 @@ export default defineNuxtConfig({
buildDir: process.env.NITRO_BUILD_DIR, buildDir: process.env.NITRO_BUILD_DIR,
builder: process.env.TEST_WITH_WEBPACK ? 'webpack' : 'vite', builder: process.env.TEST_WITH_WEBPACK ? 'webpack' : 'vite',
theme: './extends/bar', theme: './extends/bar',
css: ['~/assets/global.css'],
extends: [ extends: [
'./extends/node_modules/foo' './extends/node_modules/foo'
], ],

22
test/fixtures/basic/pages/styles.vue vendored Normal file
View File

@ -0,0 +1,22 @@
<template>
<div>
<ClientOnlyScript />
<FunctionalComponent />
</div>
</template>
<script setup>
import '~/assets/assets.css'
</script>
<style lang="postcss">
:root {
--postcss: 'postcss';
}
</style>
<style scoped>
div {
--scoped: 'scoped';
}
</style>

5
test/fixtures/basic/plugins/style.ts vendored Normal file
View File

@ -0,0 +1,5 @@
import '~/assets/plugin.css'
export default defineNuxtPlugin(() => {
//
})