mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 05:35:13 +00:00
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:
parent
fe3fc34b12
commit
de62520990
@ -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,
|
||||
|
@ -37,6 +37,9 @@ const getClientManifest: () => Promise<Manifest> = () => import('#build/dist/ser
|
||||
// @ts-ignore
|
||||
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 --
|
||||
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) {
|
||||
<body ${joinAttrs(html.bodyAttrs)}>${joinTags(html.bodyPreprend)}${joinTags(html.body)}${joinTags(html.bodyAppend)}</body>
|
||||
</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('')
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
101
packages/vite/src/plugins/ssr-styles.ts
Normal file
101
packages/vite/src/plugins/ssr-styles.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@ 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 ||
|
||||
@ -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)
|
||||
|
@ -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', () => {
|
||||
it('should prefetch components', async () => {
|
||||
await expectNoClientErrors('/prefetch/components')
|
||||
@ -437,8 +460,8 @@ describe('dynamic paths', () => {
|
||||
|
||||
it('should work with no overrides', async () => {
|
||||
const html: string = await $fetch('/assets')
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"/g)) {
|
||||
const url = match[2]
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
|
||||
const url = match[2] || match[3]
|
||||
expect(url.startsWith('/_nuxt/') || url === '/public.svg').toBeTruthy()
|
||||
}
|
||||
})
|
||||
@ -450,7 +473,7 @@ describe('dynamic paths', () => {
|
||||
}
|
||||
|
||||
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))
|
||||
expect(cssURL).toBeDefined()
|
||||
const css: string = await $fetch(cssURL!)
|
||||
@ -471,8 +494,8 @@ describe('dynamic paths', () => {
|
||||
await startServer()
|
||||
|
||||
const html = await $fetch('/foo/assets')
|
||||
for (const match of html.matchAll(/(href|src)="(.`*?)"/g)) {
|
||||
const url = match[2]
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
|
||||
const url = match[2] || match[3]
|
||||
expect(
|
||||
url.startsWith('/foo/_other/') ||
|
||||
url === '/foo/public.svg' ||
|
||||
@ -488,8 +511,8 @@ describe('dynamic paths', () => {
|
||||
await startServer()
|
||||
|
||||
const html = await $fetch('/assets')
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"/g)) {
|
||||
const url = match[2]
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
|
||||
const url = match[2] || match[3]
|
||||
expect(
|
||||
url.startsWith('./_nuxt/') ||
|
||||
url === './public.svg' ||
|
||||
@ -516,8 +539,8 @@ describe('dynamic paths', () => {
|
||||
await startServer()
|
||||
|
||||
const html = await $fetch('/foo/assets')
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"/g)) {
|
||||
const url = match[2]
|
||||
for (const match of html.matchAll(/(href|src)="(.*?)"|url\(([^)]*?)\)/g)) {
|
||||
const url = match[2] || match[3]
|
||||
expect(
|
||||
url.startsWith('https://example.com/_cdn/') ||
|
||||
url === 'https://example.com/public.svg' ||
|
||||
|
3
test/fixtures/basic/assets/assets.css
vendored
Normal file
3
test/fixtures/basic/assets/assets.css
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
:root {
|
||||
--assets: 'assets';
|
||||
}
|
3
test/fixtures/basic/assets/functional.css
vendored
Normal file
3
test/fixtures/basic/assets/functional.css
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
:root {
|
||||
--functional: 'functional';
|
||||
}
|
3
test/fixtures/basic/assets/global.css
vendored
Normal file
3
test/fixtures/basic/assets/global.css
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
:root {
|
||||
--global: 'global';
|
||||
}
|
3
test/fixtures/basic/assets/plugin.css
vendored
Normal file
3
test/fixtures/basic/assets/plugin.css
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
:root {
|
||||
--plugin: 'plugin';
|
||||
}
|
@ -15,3 +15,9 @@ export default defineNuxtComponent({
|
||||
<slot name="test" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--client-only: 'client-only';
|
||||
}
|
||||
</style>
|
||||
|
5
test/fixtures/basic/components/FunctionalComponent.ts
vendored
Normal file
5
test/fixtures/basic/components/FunctionalComponent.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
import '~/assets/functional.css'
|
||||
|
||||
export default defineComponent({
|
||||
render: () => 'hi'
|
||||
})
|
1
test/fixtures/basic/nuxt.config.ts
vendored
1
test/fixtures/basic/nuxt.config.ts
vendored
@ -12,6 +12,7 @@ export default defineNuxtConfig({
|
||||
buildDir: process.env.NITRO_BUILD_DIR,
|
||||
builder: process.env.TEST_WITH_WEBPACK ? 'webpack' : 'vite',
|
||||
theme: './extends/bar',
|
||||
css: ['~/assets/global.css'],
|
||||
extends: [
|
||||
'./extends/node_modules/foo'
|
||||
],
|
||||
|
22
test/fixtures/basic/pages/styles.vue
vendored
Normal file
22
test/fixtures/basic/pages/styles.vue
vendored
Normal 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
5
test/fixtures/basic/plugins/style.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
import '~/assets/plugin.css'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
//
|
||||
})
|
Loading…
Reference in New Issue
Block a user