Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(vite): extract styles for shared chunks #25455

Merged
merged 12 commits into from Jan 28, 2024
38 changes: 22 additions & 16 deletions packages/vite/src/plugins/ssr-styles.ts
Expand Up @@ -8,7 +8,7 @@ import type { Component } from '@nuxt/schema'
import MagicString from 'magic-string'
import { findStaticImports } from 'mlly'

import { isCSS } from '../utils'
import { isCSS, isVue } from '../utils'

interface SSRStylePluginOptions {
srcDir: string
Expand Down Expand Up @@ -107,25 +107,31 @@ 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)
const isEntry = chunk.facadeModuleId === options.entry
if (isEntry) {
options.clientCSSMap[chunk.facadeModuleId!] ||= new Set()
}
for (const moduleId of [chunk.facadeModuleId, ...chunk.moduleIds].filter(Boolean) as string[]) {
// '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[moduleId] ||= new Set()
if (isCSS(moduleId)) {
// Vue files can (also) be their own entrypoints as they are tracked separately
if (isVue(moduleId)) {
options.clientCSSMap[moduleId].add(moduleId)
}
// This is required to track CSS in entry chunk
if (isEntry) {
options.clientCSSMap[chunk.facadeModuleId!].add(moduleId)
}
}
continue
}
return
}

const id = relativeToSrcDir(chunk.facadeModuleId)
for (const file in chunk.modules) {
const relativePath = relativeToSrcDir(file)
const relativePath = relativeToSrcDir(moduleId)
if (relativePath in cssMap) {
cssMap[relativePath].inBundle = cssMap[relativePath].inBundle ?? !!id
cssMap[relativePath].inBundle = cssMap[relativePath].inBundle ?? ((isVue(moduleId) && relativeToSrcDir(moduleId)) || isEntry)
}
}

Expand Down
35 changes: 35 additions & 0 deletions packages/vite/src/utils/index.ts
@@ -1,4 +1,6 @@
import { pathToFileURL } from 'node:url'
import { hash } from 'ohash'
import { parseQuery, parseURL } from 'ufo'

export function uniq<T> (arr: T[]): T[] {
return Array.from(new Set(arr))
Expand All @@ -11,6 +13,39 @@ export function isCSS (file: string) {
return IS_CSS_RE.test(file)
}

export function isVue (id: string, opts: { type?: Array<'template' | 'script' | 'style'> } = {}) {
danielroe marked this conversation as resolved.
Show resolved Hide resolved
// Bare `.vue` file (in Vite)
const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
if (id.endsWith('.vue') && !search) {
return true
}

if (!search) {
return false
}

const query = parseQuery(search)

// Component async/lazy wrapper
if (query.nuxt_component) {
return false
}

// Macro
if (query.macro && (search === '?macro=true' || !opts.type || opts.type.includes('script'))) {
return true
}

// Non-Vue or Styles
const type = 'setup' in query ? 'script' : query.type as 'script' | 'template' | 'style'
if (!('vue' in query) || (opts.type && !opts.type.includes(type))) {
return false
}

// Query `?vue&type=template` (in Webpack or external template)
return true
}

export function hashId (id: string) {
return '$id_' + hash(id)
}
Expand Down
15 changes: 11 additions & 4 deletions test/basic.test.ts
Expand Up @@ -112,7 +112,7 @@ describe('pages', () => {
// should apply attributes to client-only components
expect(html).toContain('<div style="color:red;" class="client-only"></div>')
// should render server-only components
expect(html.replace(/ data-island-uid="[^"]*"/, '')).toContain('<div class="server-only" style="background-color:gray;"> server-only component </div>')
expect(html.replace(/ data-island-uid="[^"]*"/, '')).toContain('<div class="server-only" style="background-color:gray;"> server-only component <div> server-only component child (non-server-only) </div></div>')
// should register global components automatically
expect(html).toContain('global component registered automatically')
expect(html).toContain('global component via suffix')
Expand Down Expand Up @@ -1382,6 +1382,8 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
'{--assets:"assets"}', // <script>
'{--postcss:"postcss"}', // <style lang=postcss>
'{--scoped:"scoped"}', // <style lang=css>
'{--shared-component:"shared-component"}', // styles in a chunk shared between pages
'{--server-only-child:"server-only-child"}', // child of a server-only component
'{--server-only:"server-only"}' // server-only component not in client build
// TODO: ideally both client/server components would have inlined css when used
// '{--client-only:"client-only"}', // client-only component not in server build
Expand All @@ -1392,7 +1394,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
it('should inline styles', async () => {
const html = await $fetch('/styles')
for (const style of inlinedCSS) {
expect(html).toContain(style)
expect.soft(html).toContain(style)
}
})

Expand All @@ -1403,7 +1405,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
]
const html = await $fetch('/route-rules/spa')
for (const style of globalCSS) {
expect(html).toContain(style)
expect.soft(html).toContain(style)
}
})

Expand All @@ -1414,7 +1416,7 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
expect(files.map(m => m.replace(/\.\w+(\.\w+)$/, '$1'))).toContain('css-only-asset.svg')
})

it('should not include inlined CSS in generated CSS file', async () => {
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 = ''
Expand Down Expand Up @@ -1950,6 +1952,11 @@ describe('component islands', () => {
expect(result.head).toMatchInlineSnapshot(`
{
"link": [
{
"href": "/_nuxt/components/SharedComponent.vue?vue&type=style&index=0&scoped=3ee84738&lang.css",
"key": "island-link",
"rel": "stylesheet",
},
Comment on lines +1955 to +1959
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not a product of changes in the code; this is vite behaviour for adding a shared component (also on main).

{
"href": "/_nuxt/components/islands/PureComponent.vue?vue&type=style&index=0&scoped=c0c0cf89&lang.css",
"key": "island-link",
Expand Down
Expand Up @@ -5,6 +5,7 @@ prerenderRoutes(['/some/url/from/server-only/component'])
<template>
<div>
server-only component
<ServerOnlyComponentChild />
</div>
</template>

Expand Down
11 changes: 11 additions & 0 deletions test/fixtures/basic/components/ServerOnlyComponentChild.vue
@@ -0,0 +1,11 @@
<template>
<div>
server-only component child (non-server-only)
</div>
</template>

<style>
:root {
--server-only-child: 'server-only-child';
}
</style>
9 changes: 9 additions & 0 deletions test/fixtures/basic/components/SharedComponent.vue
@@ -0,0 +1,9 @@
<template>
<span class="shared-component" />
</template>

<style scoped>
.shared-component {
--shared-component: 'shared-component';
}
</style>
1 change: 1 addition & 0 deletions test/fixtures/basic/pages/styles.vue
Expand Up @@ -3,6 +3,7 @@
<ClientOnlyScript />
<FunctionalComponent />
<ServerOnlyComponent />
<SharedComponent />
</div>
</template>

Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/basic/pages/vueuse-head.vue
Expand Up @@ -21,5 +21,7 @@ useLegacyVueUseHead()
<template>
<div>
<h1>VueUse head polyfill test</h1>
<!-- This component is only here to make it a shared chunk for test in `styles.vue` -->
<SharedComponent />
</div>
</template>