Skip to content
This repository has been archived by the owner on Apr 6, 2023. It is now read-only.

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

Merged
merged 13 commits into from Sep 3, 2022
5 changes: 5 additions & 0 deletions packages/nuxt/src/core/nitro.ts
Expand Up @@ -97,6 +97,7 @@ export async function initNitro (nuxt: Nuxt) {
},
replace: {
'process.env.NUXT_NO_SSR': nuxt.options.ssr === false,
'process.env.NUXT_NO_INLINE': !nuxt.options.experimental.renderInlineStyles,
'process.dev': nuxt.options.dev,
__VUE_PROD_DEVTOOLS__: false
},
Expand All @@ -110,6 +111,10 @@ export async function initNitro (nuxt: Nuxt) {
nitroConfig.virtual!['#build/dist/server/server.mjs'] = 'export default () => {}'
}

if (nuxt.options.dev || !nuxt.options.ssr || !nuxt.options.experimental.renderInlineStyles || nuxt.options.builder as any !== '@nuxt/vite-builder') {
nitroConfig.virtual!['#build/dist/server/styles.mjs'] = 'export default {}'
}

// Register nuxt protection patterns
nitroConfig.rollupConfig!.plugins!.push(ImportProtectionPlugin.rollup({
rootDir: nuxt.options.rootDir,
Expand Down
22 changes: 22 additions & 0 deletions packages/nuxt/src/core/runtime/nitro/renderer.ts
Expand Up @@ -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
Expand Down Expand Up @@ -137,13 +140,19 @@ export default defineRenderHandler(async (event) => {
// Render meta
const renderedMeta = await ssrContext.renderMeta?.() ?? {}

// Render inline styles
const inlinedStyles = !process.env.NUXT_NO_INLINE && !ssrContext.noInlineStyles
? await renderInlineStyles(ssrContext.modules ?? ssrContext._registeredComponents ?? [])
: ''

// Create render context
const htmlContext: NuxtRenderHTMLContext = {
htmlAttrs: normalizeChunks([renderedMeta.htmlAttrs]),
head: normalizeChunks([
renderedMeta.headTags,
_rendered.renderResourceHints(),
_rendered.renderStyles(),
inlinedStyles,
ssrContext.styles
]),
bodyAttrs: normalizeChunks([renderedMeta.bodyAttrs!]),
Expand Down Expand Up @@ -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('')
}
13 changes: 11 additions & 2 deletions packages/schema/src/config/experimental.ts
Expand Up @@ -48,9 +48,18 @@ 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)}
*/
renderInlineStyles: true,
}
})
104 changes: 104 additions & 0 deletions packages/vite/src/plugins/ssr-styles.ts
@@ -0,0 +1,104 @@
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 ssrStylePlugin (options: SSRStylePluginOptions): Plugin {
const cssMap: Record<string, string[]> = {}
const idRefMap: Record<string, string> = {}
const globalStyles = new Set<string>()

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)}-css.mjs`,
source: ''
})

emitted[file] = this.emitFile({
type: 'asset',
name: `${filename(file)}-css.mjs`,
source: [
...cssMap[file].map((css, i) => `import s${i} from './${relative(dirname(base), this.getFileName(css))}';`),
`export default [${cssMap[file].map((_, i) => `s${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 s${i} from './${css}';`),
`const globalStyles = [${globalStylesArray.map((_, i) => `s${i}`).join(', ')}]`,
'const interopDefault = r => r.default || r',
`export default ${genObjectFromRawEntries(
Object.entries(emitted).map(([key, value]) => [key, `() => import('./${this.getFileName(value)}').then(interopDefault).then(r => r.concat(globalStyles))`])
danielroe marked this conversation as resolved.
Show resolved Hide resolved
)}`
].join('\n')
})
},
renderChunk (_code, chunk) {
if (!chunk.isEntry) { return null }

for (const mod in chunk.modules) {
if (isCSS(mod) && !mod.includes('&used')) {
globalStyles.add(relative(options.srcDir, 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 _id = relative(options.srcDir, id)

cssMap[_id] ||= []

const styleImports = findStaticImports(code)
if (!styleImports.length) { return }

for (const [index, { specifier }] of styleImports.entries()) {
const { type } = parseQuery(specifier)
if (type !== 'style' && !specifier.endsWith('.css')) { continue }

const resolution = await this.resolve(specifier, id)

if (resolution) {
const ref = this.emitFile({
type: 'chunk',
name: `${filename(id)}-css-${index}.mjs`,
id: resolution.id + '?inline&used'
})

idRefMap[relative(options.srcDir, resolution.id)] = ref
cssMap[_id].push(ref)
}
}
}
}
}
12 changes: 11 additions & 1 deletion packages/vite/src/server.ts
Expand Up @@ -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 { ssrStylePlugin } 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 })
Expand Down Expand Up @@ -111,6 +112,15 @@ export async function buildServer (ctx: ViteBuildContext) {
]
} as ViteOptions)

if (ctx.nuxt.options.experimental.renderInlineStyles) {
serverConfig.plugins!.push(ssrStylePlugin({
srcDir: ctx.nuxt.options.srcDir,
shouldInline: typeof ctx.nuxt.options.experimental.renderInlineStyles === 'function'
? ctx.nuxt.options.experimental.renderInlineStyles
: 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)
Expand Down
41 changes: 32 additions & 9 deletions test/basic.test.ts
Expand Up @@ -387,6 +387,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')
Expand Down Expand Up @@ -431,8 +454,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()
}
})
Expand All @@ -444,7 +467,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!)
Expand All @@ -465,8 +488,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' ||
Expand All @@ -482,8 +505,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' ||
Expand All @@ -510,8 +533,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' ||
Expand Down
3 changes: 3 additions & 0 deletions test/fixtures/basic/assets/assets.css
@@ -0,0 +1,3 @@
:root {
--assets: 'assets';
}
3 changes: 3 additions & 0 deletions test/fixtures/basic/assets/functional.css
@@ -0,0 +1,3 @@
:root {
--functional: 'functional';
}
3 changes: 3 additions & 0 deletions test/fixtures/basic/assets/global.css
@@ -0,0 +1,3 @@
:root {
--global: 'global';
}
3 changes: 3 additions & 0 deletions test/fixtures/basic/assets/plugin.css
@@ -0,0 +1,3 @@
:root {
--plugin: 'plugin';
}
6 changes: 6 additions & 0 deletions test/fixtures/basic/components/ClientOnlyScript.client.vue
Expand Up @@ -15,3 +15,9 @@ export default defineNuxtComponent({
<slot name="test" />
</div>
</template>

<style>
:root {
--client-only: 'client-only';
}
</style>
5 changes: 5 additions & 0 deletions test/fixtures/basic/components/FunctionalComponent.ts
@@ -0,0 +1,5 @@
import '~/assets/functional.css'

export default defineComponent({
render: () => 'hi'
})
1 change: 1 addition & 0 deletions test/fixtures/basic/nuxt.config.ts
Expand Up @@ -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'
],
Expand Down
22 changes: 22 additions & 0 deletions test/fixtures/basic/pages/styles.vue
@@ -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 changes: 5 additions & 0 deletions test/fixtures/basic/plugins/style.ts
@@ -0,0 +1,5 @@
import '~/assets/plugin.css'

export default defineNuxtPlugin(() => {
//
})