diff --git a/src/index.ts b/src/index.ts index 890dc1cb3..5648adf50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import type { SFCTemplateCompileOptions, SFCScriptCompileOptions, } from 'vue/compiler-sfc' + import { selectBlock } from './select' import { genHotReloadCode } from './hotReload' import { genCSSModulesCode } from './cssModules' @@ -363,6 +364,18 @@ export default function loader( .join(`\n`) + `\n` } + if (isServer) { + code += `\nimport { useSSRContext } from 'vue'\n` + code += `const _setup = script.setup\n` + ;(code += `script.setup = (props, ctx) => {`), + (code += ` const ssrContext = useSSRContext()`), + (code += ` ;(ssrContext._registeredComponents || (ssrContext._registeredComponents = new Set())).add(${JSON.stringify( + hash(loaderContext.request) + )});`) + code += ` return _setup ? _setup(props, ctx) : undefined` + code += `}\n` + } + // finalize if (!propsToAttach.length) { code += `\n\nconst __exports__ = script;` diff --git a/test/fixtures/functional-style.vue b/test/fixtures/functional-style.vue new file mode 100644 index 000000000..a625668b8 --- /dev/null +++ b/test/fixtures/functional-style.vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/ssr-entry.js b/test/fixtures/ssr-entry.js new file mode 100644 index 000000000..19ea19c93 --- /dev/null +++ b/test/fixtures/ssr-entry.js @@ -0,0 +1,17 @@ +import { renderToString } from 'vue/server-renderer' +import { createSSRApp } from 'vue' + +import Component from '~target' +import * as exports from '~target' + +export async function main() { + const instance = createSSRApp(Component) + const ssrContext = {} + const markup = await renderToString(instance, ssrContext) + return { + instance, + markup, + componentModule: Component, + ssrContext, + } +} diff --git a/test/fixtures/ssr-style.vue b/test/fixtures/ssr-style.vue new file mode 100644 index 000000000..55d15f674 --- /dev/null +++ b/test/fixtures/ssr-style.vue @@ -0,0 +1,25 @@ + + + + + + + diff --git a/test/ssr.spec.ts b/test/ssr.spec.ts new file mode 100644 index 000000000..a21f9afab --- /dev/null +++ b/test/ssr.spec.ts @@ -0,0 +1,79 @@ +import { mockServerBundleAndRun, genId, DEFAULT_VUE_USE } from './utils' + +test('SSR style and moduleId extraction', async () => { + const { markup, ssrContext } = await mockServerBundleAndRun({ + entry: 'ssr-style.vue', + }) + + expect(markup).toContain('

Hello

') + expect(markup).toContain('Hello from Component A!') + expect(markup).toContain('
functional
') + // collect component identifiers during render + expect(Array.from(ssrContext._registeredComponents).length).toBe(3) +}) + +test('SSR with scoped CSS', async () => { + const { markup } = await mockServerBundleAndRun({ + entry: 'scoped-css.vue', + }) + + const shortId = genId('scoped-css.vue') + const id = `data-v-${shortId}` + + expect(markup).toContain(`
`) + expect(markup).toContain(``) +}) + +test('SSR + CSS Modules', async () => { + const testWithIdent = async ( + localIdentName: string | undefined, + regexToMatch: RegExp + ) => { + const baseLoaders = [ + 'style-loader', + { + loader: 'css-loader', + options: { + modules: { + localIdentName, + }, + }, + }, + ] + + const { componentModule } = await mockServerBundleAndRun({ + entry: 'css-modules.vue', + modify: (config: any) => { + config!.module!.rules = [ + { + test: /\.vue$/, + use: [DEFAULT_VUE_USE], + }, + { + test: /\.css$/, + use: baseLoaders, + }, + { + test: /\.stylus$/, + use: [...baseLoaders, 'stylus-loader'], + }, + ] + }, + }) + + const instance = componentModule.__cssModules + + // get local class name + const className = instance!.$style.red + expect(className).toMatch(regexToMatch) + } + + // default ident + await testWithIdent(undefined, /^\w{21,}/) + + // custom ident + await testWithIdent( + '[path][name]---[local]---[hash:base64:5]', + /css-modules---red---\w{5}/ + ) +}) diff --git a/test/utils.ts b/test/utils.ts index 836f4ef37..046f6a0f3 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -1,6 +1,7 @@ /* env jest */ import * as path from 'path' import * as crypto from 'crypto' +import * as vm from 'vm' import webpack from 'webpack' import merge from 'webpack-merge' // import MiniCssExtractPlugin from 'mini-css-extract-plugin' @@ -8,6 +9,7 @@ import { fs as mfs } from 'memfs' import { JSDOM, VirtualConsole } from 'jsdom' import { VueLoaderPlugin } from '..' import type { VueLoaderOptions } from '..' +import type { Component, App } from 'vue' function hash(text: string): string { return crypto.createHash('sha256').update(text).digest('hex').substring(0, 8) @@ -168,6 +170,34 @@ export function bundle( }) } +export function bundleSSR( + options: BundleOptions, + wontThrowError?: boolean +): Promise<{ + code: string + stats: webpack.Stats +}> { + if (typeof options.entry === 'string' && /\.vue/.test(options.entry)) { + const vueFile = options.entry + options = merge(options, { + entry: require.resolve('./fixtures/ssr-entry'), + resolve: { + alias: { + '~target': path.resolve(__dirname, './fixtures', vueFile), + }, + }, + }) + } + options = merge(options, { + target: 'node', + output: { + libraryTarget: 'commonjs2', + }, + externals: ['vue'], + }) + return bundle(options, wontThrowError) +} + export async function mockBundleAndRun( options: BundleOptions, wontThrowError?: boolean @@ -203,6 +233,52 @@ export async function mockBundleAndRun( } } +export async function mockServerBundleAndRun( + options: BundleOptions, + wontThrowError?: boolean +) { + const { code, stats } = await bundleSSR(options, wontThrowError) + + const dom = new JSDOM( + ``, + { + runScripts: 'outside-only', + virtualConsole: new VirtualConsole(), + } + ) + + const contextObject = { + module: {} as NodeModule, + require: require, + window: dom.window, + document: dom.window.document, + } + vm.runInNewContext(code, contextObject) + + const { + instance, + markup, + componentModule, + ssrContext, + }: { + instance: App + markup: string + componentModule: Component & { + __cssModules?: { $style: Record } + } + ssrContext: { _registeredComponents: Set } + } = await contextObject.module.exports.main() + + return { + markup, + componentModule, + instance, + ssrContext, + code, + stats, + } +} + export function normalizeNewline(input: string): string { return input.replace(new RegExp('\r\n', 'g'), '\n') }