diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index a40d39c8a..02054a7ee 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -93,7 +93,8 @@ export default defineConfig({ { text: 'Stubs and Shallow Mount', link: '/guide/advanced/stubs-shallow-mount' - } + }, + { text: 'Server-side rendering', link: '/guide/advanced/ssr' } ] }, { diff --git a/docs/guide/advanced/ssr.md b/docs/guide/advanced/ssr.md new file mode 100644 index 000000000..9cebb7e0e --- /dev/null +++ b/docs/guide/advanced/ssr.md @@ -0,0 +1,42 @@ +# Testing Server-side Rendering + +Vue Test Utils provides `renderToString` to test Vue applications that use server-side rendering (SSR). +This guide will walk you through the process of testing a Vue application that uses SSR. + +## `renderToString` + +`renderToString` is a function that renders a Vue component to a string. +It is an asynchronous function that returns a Promise, +and accepts the same parameters as `mount` or `shallowMount`. + +Let's consider a simple component that uses the `onServerPrefetch` hook: + +```ts +function fakeFetch(text: string) { + return Promise.resolve(text) +} + +const Component = defineComponent({ + template: '
{{ text }}
', + setup() { + const text = ref(null) + + onServerPrefetch(async () => { + text.value = await fakeFetch('onServerPrefetch') + }) + + return { text } + } +}) +``` + +You can write a test for this component using `renderToString`: + +```ts +import { renderToString } from '@vue/test-utils' + +it('renders the value returned by onServerPrefetch', async () => { + const contents = await renderToString(Component) + expect(contents).toBe('
onServerPrefetch
') +}) +``` diff --git a/package.json b/package.json index b9fca18d0..337453376 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@vue/compat": "3.2.47", "@vue/compiler-dom": "3.2.47", "@vue/compiler-sfc": "3.2.47", + "@vue/server-renderer": "3.2.47", "c8": "7.12.0", "eslint": "8.33.0", "eslint-config-prettier": "8.6.0", @@ -65,10 +66,12 @@ }, "peerDependencies": { "@vue/compiler-dom": "^3.0.1", + "@vue/server-renderer": "^3.0.1", "vue": "^3.0.1" }, "optionalDependencies": { - "@vue/compiler-dom": "^3.0.1" + "@vue/compiler-dom": "^3.0.1", + "@vue/server-renderer": "^3.0.1" }, "author": { "name": "Lachlan Miller", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29e318454..2249c07d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ specifiers: '@vue/compat': 3.2.47 '@vue/compiler-dom': ^3.0.1 '@vue/compiler-sfc': 3.2.47 + '@vue/server-renderer': ^3.0.1 c8: 7.12.0 eslint: 8.33.0 eslint-config-prettier: 8.6.0 @@ -45,6 +46,7 @@ dependencies: optionalDependencies: '@vue/compiler-dom': 3.2.47 + '@vue/server-renderer': 3.2.47_vue@3.2.47 devDependencies: '@rollup/plugin-commonjs': 24.0.1_rollup@3.14.0 @@ -1456,14 +1458,12 @@ packages: magic-string: 0.25.9 postcss: 8.4.20 source-map: 0.6.1 - dev: true /@vue/compiler-ssr/3.2.47: resolution: {integrity: sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==} dependencies: '@vue/compiler-dom': 3.2.47 '@vue/shared': 3.2.47 - dev: true /@vue/devtools-api/6.2.0: resolution: {integrity: sha512-pF1G4wky+hkifDiZSWn8xfuLOJI1ZXtuambpBEYaf7Xaf6zC/pM29rvAGpd3qaGXnr4BAXU1Pxz/VfvBGwexGA==} @@ -1481,7 +1481,6 @@ packages: '@vue/shared': 3.2.47 estree-walker: 2.0.2 magic-string: 0.25.9 - dev: true /@vue/reactivity/3.2.45: resolution: {integrity: sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==} @@ -1493,14 +1492,12 @@ packages: resolution: {integrity: sha512-7khqQ/75oyyg+N/e+iwV6lpy1f5wq759NdlS1fpAhFXa8VeAIKGgk2E/C4VF59lx5b+Ezs5fpp/5WsRYXQiKxQ==} dependencies: '@vue/shared': 3.2.47 - dev: true /@vue/runtime-core/3.2.47: resolution: {integrity: sha512-RZxbLQIRB/K0ev0K9FXhNbBzT32H9iRtYbaXb0ZIz2usLms/D55dJR2t6cIEUn6vyhS3ALNvNthI+Q95C+NOpA==} dependencies: '@vue/reactivity': 3.2.47 '@vue/shared': 3.2.47 - dev: true /@vue/runtime-dom/3.2.47: resolution: {integrity: sha512-ArXrFTjS6TsDei4qwNvgrdmHtD930KgSKGhS5M+j8QxXrDJYLqYw4RRcDy1bz1m1wMmb6j+zGLifdVHtkXA7gA==} @@ -1508,7 +1505,6 @@ packages: '@vue/runtime-core': 3.2.47 '@vue/shared': 3.2.47 csstype: 2.6.20 - dev: true /@vue/server-renderer/3.2.47_vue@3.2.47: resolution: {integrity: sha512-dN9gc1i8EvmP9RCzvneONXsKfBRgqFeFZLurmHOveL7oH6HiFXJw5OGu294n1nHc/HMgTy6LulU/tv5/A7f/LA==} @@ -1518,7 +1514,6 @@ packages: '@vue/compiler-ssr': 3.2.47 '@vue/shared': 3.2.47 vue: 3.2.47 - dev: true /@vue/shared/3.2.45: resolution: {integrity: sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==} @@ -1967,7 +1962,6 @@ packages: /csstype/2.6.20: resolution: {integrity: sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==} - dev: true /data-urls/3.0.2: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} @@ -3254,7 +3248,6 @@ packages: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: sourcemap-codec: 1.4.8 - dev: true /magic-string/0.26.7: resolution: {integrity: sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==} @@ -3395,7 +3388,6 @@ packages: resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /natural-compare-lite/1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -3564,7 +3556,6 @@ packages: /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true /picomatch/2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -3598,7 +3589,6 @@ packages: nanoid: 3.3.4 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /postcss/8.4.21: resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} @@ -3898,7 +3888,6 @@ packages: /source-map-js/1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} - dev: true /source-map-support/0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -3914,7 +3903,6 @@ packages: /sourcemap-codec/1.4.8: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} deprecated: Please use @jridgewell/sourcemap-codec instead - dev: true /stackback/0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -4460,7 +4448,6 @@ packages: '@vue/runtime-dom': 3.2.47 '@vue/server-renderer': 3.2.47_vue@3.2.47 '@vue/shared': 3.2.47 - dev: true /vuex/4.1.0_vue@3.2.47: resolution: {integrity: sha512-hmV6UerDrPcgbSy9ORAtNXDr9M4wlNP4pEFKye4ujJF8oqgFFuxDCdOLS3eNoRTtq5O3hoBDh9Doj1bQMYHRbQ==} diff --git a/rollup.config.ts b/rollup.config.ts index a161cdc2a..247378b21 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -25,7 +25,10 @@ function createEntry(options) { 'vue', isEsmBrowser ? '@vue/compiler-dom/dist/compiler-dom.esm-browser' - : '@vue/compiler-dom' + : '@vue/compiler-dom', + isEsmBrowser + ? '@vue/server-renderer/dist/compiler-dom.esm-browser' + : '@vue/server-renderer' ], plugins: [ replace({ @@ -47,7 +50,8 @@ function createEntry(options) { format, globals: { vue: 'Vue', - '@vue/compiler-dom': 'VueCompilerDOM' + '@vue/compiler-dom': 'VueCompilerDOM', + '@vue/server-renderer': 'VueServerRenderer' } } } diff --git a/src/createInstance.ts b/src/createInstance.ts new file mode 100644 index 000000000..0a2779574 --- /dev/null +++ b/src/createInstance.ts @@ -0,0 +1,367 @@ +import { + h, + createApp, + defineComponent, + reactive, + shallowReactive, + isRef, + ref, + AppConfig, + ComponentOptions, + ConcreteComponent, + DefineComponent, + transformVNodeArgs +} from 'vue' + +import { MountingOptions, Slot } from './types' +import { + getComponentsFromStubs, + getDirectivesFromStubs, + isFunctionalComponent, + isObject, + isObjectComponent, + isScriptSetup, + mergeGlobalProperties +} from './utils' +import { processSlot } from './utils/compileSlots' +import { attachEmitListener } from './emit' +import { registerStub } from './stubs' +import { + isLegacyFunctionalComponent, + unwrapLegacyVueExtendComponent +} from './utils/vueCompatSupport' +import { createVNodeTransformer } from './vnodeTransformers/util' +import { + addToDoNotStubComponents, + createStubComponentsTransformer +} from './vnodeTransformers/stubComponentsTransformer' +import { createStubDirectivesTransformer } from './vnodeTransformers/stubDirectivesTransformer' + +const MOUNT_OPTIONS: ReadonlyArray> = [ + 'attachTo', + 'attrs', + 'data', + 'props', + 'slots', + 'global', + 'shallow' +] as const + +function getInstanceOptions( + options: MountingOptions & Record +): Record { + if (options.methods) { + console.warn( + "Passing a `methods` option to mount was deprecated on Vue Test Utils v1, and it won't have any effect on v2. For additional info: https://vue-test-utils.vuejs.org/upgrading-to-v1/#setmethods-and-mountingoptions-methods" + ) + delete options.methods + } + + const resultOptions = { ...options } + for (const key of Object.keys(options)) { + if (MOUNT_OPTIONS.includes(key as keyof MountingOptions)) { + delete resultOptions[key] + } + } + return resultOptions +} + +// implementation +export function createInstance( + inputComponent: DefineComponent<{}, {}, any>, + options?: MountingOptions & Record +) { + // normalize the incoming component + const originalComponent = unwrapLegacyVueExtendComponent(inputComponent) + let component: ConcreteComponent + const instanceOptions = getInstanceOptions(options ?? {}) + + if ( + isFunctionalComponent(originalComponent) || + isLegacyFunctionalComponent(originalComponent) + ) { + component = defineComponent({ + compatConfig: { + MODE: 3, + INSTANCE_LISTENERS: false, + INSTANCE_ATTRS_CLASS_STYLE: false, + COMPONENT_FUNCTIONAL: isLegacyFunctionalComponent(originalComponent) + ? 'suppress-warning' + : false + }, + props: originalComponent.props || {}, + setup: + (props, { attrs, slots }) => + () => + h(originalComponent, { ...props, ...attrs }, slots), + ...instanceOptions + }) + addToDoNotStubComponents(originalComponent) + } else if (isObjectComponent(originalComponent)) { + component = { ...originalComponent, ...instanceOptions } + } else { + component = originalComponent + } + + addToDoNotStubComponents(component) + // We've just replaced our component with its copy + // Let's register it as a stub so user can find it + registerStub({ source: originalComponent, stub: component }) + + const el = document.createElement('div') + + if (options?.attachTo) { + let to: Element | null + if (typeof options.attachTo === 'string') { + to = document.querySelector(options.attachTo) + if (!to) { + throw new Error( + `Unable to find the element matching the selector ${options.attachTo} given as the \`attachTo\` option` + ) + } + } else { + to = options.attachTo + } + + to.appendChild(el) + } + + function slotToFunction(slot: Slot) { + switch (typeof slot) { + case 'function': + return slot + case 'object': + return () => h(slot) + case 'string': + return processSlot(slot) + default: + throw Error(`Invalid slot received.`) + } + } + + // handle any slots passed via mounting options + const slots = + options?.slots && + Object.entries(options.slots).reduce( + ( + acc: { [key: string]: Function }, + [name, slot]: [string, Slot] + ): { [key: string]: Function } => { + if (Array.isArray(slot)) { + const normalized = slot.map(slotToFunction) + acc[name] = (args: unknown) => normalized.map((f) => f(args)) + return acc + } + + acc[name] = slotToFunction(slot) + return acc + }, + {} + ) + + // override component data with mounting options data + if (options?.data) { + const providedData = options.data() + if (isObjectComponent(originalComponent)) { + // component is guaranteed to be the same type as originalComponent + const objectComponent = component as ComponentOptions + const originalDataFn = originalComponent.data || (() => ({})) + objectComponent.data = (vm) => ({ + ...originalDataFn.call(vm, vm), + ...providedData + }) + } else { + throw new Error( + 'data() option is not supported on functional and class components' + ) + } + } + + const MOUNT_COMPONENT_REF = 'VTU_COMPONENT' + // we define props as reactive so that way when we update them with `setProps` + // Vue's reactivity system will cause a rerender. + const refs = shallowReactive>({}) + const props = reactive>({}) + + Object.entries({ + ...options?.attrs, + ...options?.propsData, + ...options?.props, + ref: MOUNT_COMPONENT_REF + }).forEach(([k, v]) => { + if (isRef(v)) { + refs[k] = v + } else { + props[k] = v + } + }) + + const global = mergeGlobalProperties(options?.global) + if (isObjectComponent(component)) { + component.components = { ...component.components, ...global.components } + } + + const componentRef = ref(null) + // create the wrapper component + const Parent = defineComponent({ + name: 'VTU_ROOT', + setup() { + return { + [MOUNT_COMPONENT_REF]: componentRef + } + }, + render() { + return h(component as ComponentOptions, { ...props, ...refs }, slots) + } + }) + + // create the app + const app = createApp(Parent) + // the Parent type must not be stubbed + // but we can't add it directly, as createApp creates a copy + // and store it in app._component (since v3.2.32) + // So we store this one instead + addToDoNotStubComponents(app._component) + + // add tracking for emitted events + // this must be done after `createApp`: https://github.com/vuejs/test-utils/issues/436 + attachEmitListener() + + // global mocks mixin + if (global?.mocks) { + const mixin = defineComponent({ + beforeCreate() { + // we need to differentiate components that are or not not `script setup` + // otherwise we run into a proxy set error + // due to https://github.com/vuejs/core/commit/f73925d76a76ee259749b8b48cb68895f539a00f#diff-ea4d1ddabb7e22e17e80ada458eef70679af4005df2a1a6b73418fec897603ceR404 + // introduced in Vue v3.2.45 + // Also ensures not to include option API components in this block + // since they can also have setup state but need to be patched using + // the regular method. + if (isScriptSetup(this)) { + // add the mocks to setupState + for (const [k, v] of Object.entries( + global.mocks as { [key: string]: any } + )) { + // we do this in a try/catch, as some properties might be read-only + try { + this.$.setupState[k] = v + // eslint-disable-next-line no-empty + } catch (e) {} + } + // also intercept the proxy calls to make the mocks available on the instance + // (useful when a template access a global function like $t and the developer wants to mock it) + ;(this.$ as any).proxy = new Proxy((this.$ as any).proxy, { + get(target, key) { + if (key in global.mocks) { + return global.mocks[key as string] + } + return target[key] + } + }) + } else { + for (const [k, v] of Object.entries( + global.mocks as { [key: string]: any } + )) { + ;(this as any)[k] = v + } + } + } + }) + + app.mixin(mixin) + } + + // AppConfig + if (global.config) { + for (const [k, v] of Object.entries(global.config) as [ + keyof Omit, + any + ][]) { + app.config[k] = isObject(app.config[k]) + ? Object.assign(app.config[k]!, v) + : v + } + } + + // use and plugins from mounting options + if (global.plugins) { + for (const plugin of global.plugins) { + if (Array.isArray(plugin)) { + app.use(plugin[0], ...plugin.slice(1)) + continue + } + app.use(plugin) + } + } + + // use any mixins from mounting options + if (global.mixins) { + for (const mixin of global.mixins) app.mixin(mixin) + } + + if (global.components) { + for (const key of Object.keys(global.components)) { + // avoid registering components that are stubbed twice + if (!(key in global.stubs)) { + app.component(key, global.components[key]) + } + } + } + + if (global.directives) { + for (const key of Object.keys(global.directives)) + app.directive(key, global.directives[key]) + } + + // provide any values passed via provides mounting option + if (global.provide) { + for (const key of Reflect.ownKeys(global.provide)) { + // @ts-ignore: https://github.com/microsoft/TypeScript/issues/1863 + app.provide(key, global.provide[key]) + } + } + + // stubs + // even if we are using `mount`, we will still + // stub out Transition and Transition Group by default. + transformVNodeArgs( + createVNodeTransformer({ + transformers: [ + createStubComponentsTransformer({ + stubs: getComponentsFromStubs(global.stubs), + shallow: options?.shallow, + renderStubDefaultSlot: global.renderStubDefaultSlot + }), + createStubDirectivesTransformer({ + directives: getDirectivesFromStubs(global.stubs) + }) + ] + }) + ) + + // users expect stubs to work with globally registered + // components so we register stubs as global components to avoid + // warning about not being able to resolve component + // + // component implementation provided here will never be called + // but we need name to make sure that stubComponents will + // properly stub this later by matching stub name + // + // ref: https://github.com/vuejs/test-utils/issues/249 + // ref: https://github.com/vuejs/test-utils/issues/425 + if (global?.stubs) { + for (const name of Object.keys(getComponentsFromStubs(global.stubs))) { + if (!app.component(name)) { + app.component(name, { name }) + } + } + } + + return { + app, + el, + props, + componentRef + } +} diff --git a/src/index.ts b/src/index.ts index fa21e292a..7985faf29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { DOMWrapper } from './domWrapper' import { VueWrapper } from './vueWrapper' import BaseWrapper from './baseWrapper' import { mount, shallowMount } from './mount' +import { renderToString } from './renderToString' import { MountingOptions } from './types' import { RouterLinkStub } from './components/RouterLinkStub' import { createWrapperError } from './errorWrapper' @@ -12,6 +13,7 @@ import { enableAutoUnmount, disableAutoUnmount } from './utils/autoUnmount' export { mount, shallowMount, + renderToString, enableAutoUnmount, disableAutoUnmount, RouterLinkStub, diff --git a/src/mount.ts b/src/mount.ts index f8206294f..f74163eb6 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -1,17 +1,10 @@ import { - h, - createApp, - defineComponent, - reactive, - shallowReactive, - isRef, FunctionalComponent, ComponentPublicInstance, ComponentOptionsWithObjectProps, ComponentOptionsWithArrayProps, ComponentOptionsWithoutProps, ExtractPropTypes, - AppConfig, VNodeProps, ComponentOptionsMixin, DefineComponent, @@ -22,70 +15,15 @@ import { EmitsOptions, ComputedOptions, ComponentPropsOptions, - ComponentOptions, - ConcreteComponent, - Prop, - transformVNodeArgs, - ref + Prop } from 'vue' - -import { MountingOptions, Slot } from './types' -import { - getComponentsFromStubs, - getDirectivesFromStubs, - isScriptSetup, - isFunctionalComponent, - isObject, - isObjectComponent, - mergeGlobalProperties -} from './utils' -import { processSlot } from './utils/compileSlots' +import { MountingOptions } from './types' import { VueWrapper } from './vueWrapper' -import { attachEmitListener } from './emit' -import { createVNodeTransformer } from './vnodeTransformers/util' -import { - createStubComponentsTransformer, - addToDoNotStubComponents -} from './vnodeTransformers/stubComponentsTransformer' -import { createStubDirectivesTransformer } from './vnodeTransformers/stubDirectivesTransformer' -import { - isLegacyFunctionalComponent, - unwrapLegacyVueExtendComponent -} from './utils/vueCompatSupport' import { trackInstance } from './utils/autoUnmount' import { createVueWrapper } from './wrapperFactory' -import { registerStub } from './stubs' +import { createInstance } from './createInstance' // NOTE this should come from `vue` -const MOUNT_OPTIONS: Array> = [ - 'attachTo', - 'attrs', - 'data', - 'props', - 'slots', - 'global', - 'shallow' -] - -function getInstanceOptions( - options: MountingOptions & Record -): Record { - if (options.methods) { - console.warn( - "Passing a `methods` option to mount was deprecated on Vue Test Utils v1, and it won't have any effect on v2. For additional info: https://vue-test-utils.vuejs.org/upgrading-to-v1/#setmethods-and-mountingoptions-methods" - ) - delete options.methods - } - - const resultOptions = { ...options } - for (const key of Object.keys(options)) { - if (MOUNT_OPTIONS.includes(key as keyof MountingOptions)) { - delete resultOptions[key] - } - } - return resultOptions -} - type PublicProps = VNodeProps & AllowedComponentProps & ComponentCustomProps type ComponentMountingOptions = T extends DefineComponent< @@ -310,149 +248,10 @@ export function mount( inputComponent: any, options?: MountingOptions & Record ): VueWrapper { - // normalize the incoming component - const originalComponent = unwrapLegacyVueExtendComponent(inputComponent) - let component: ConcreteComponent - const instanceOptions = getInstanceOptions(options ?? {}) - - if ( - isFunctionalComponent(originalComponent) || - isLegacyFunctionalComponent(originalComponent) - ) { - component = defineComponent({ - compatConfig: { - MODE: 3, - INSTANCE_LISTENERS: false, - INSTANCE_ATTRS_CLASS_STYLE: false, - COMPONENT_FUNCTIONAL: isLegacyFunctionalComponent(originalComponent) - ? 'suppress-warning' - : false - }, - props: originalComponent.props || {}, - setup: - (props, { attrs, slots }) => - () => - h(originalComponent, { ...props, ...attrs }, slots), - ...instanceOptions - }) - addToDoNotStubComponents(originalComponent) - } else if (isObjectComponent(originalComponent)) { - component = { ...originalComponent, ...instanceOptions } - } else { - component = originalComponent - } - - addToDoNotStubComponents(component) - // We've just replaced our component with its copy - // Let's register it as a stub so user can find it - registerStub({ source: originalComponent, stub: component }) - - const el = document.createElement('div') - - if (options?.attachTo) { - let to: Element | null - if (typeof options.attachTo === 'string') { - to = document.querySelector(options.attachTo) - if (!to) { - throw new Error( - `Unable to find the element matching the selector ${options.attachTo} given as the \`attachTo\` option` - ) - } - } else { - to = options.attachTo - } - - to.appendChild(el) - } - - function slotToFunction(slot: Slot) { - switch (typeof slot) { - case 'function': - return slot - case 'object': - return () => h(slot) - case 'string': - return processSlot(slot) - default: - throw Error(`Invalid slot received.`) - } - } - - // handle any slots passed via mounting options - const slots = - options?.slots && - Object.entries(options.slots).reduce( - ( - acc: { [key: string]: Function }, - [name, slot]: [string, Slot] - ): { [key: string]: Function } => { - if (Array.isArray(slot)) { - const normalized = slot.map(slotToFunction) - acc[name] = (args: unknown) => normalized.map((f) => f(args)) - return acc - } - - acc[name] = slotToFunction(slot) - return acc - }, - {} - ) - - // override component data with mounting options data - if (options?.data) { - const providedData = options.data() - if (isObjectComponent(originalComponent)) { - // component is guaranteed to be the same type as originalComponent - const objectComponent = component as ComponentOptions - const originalDataFn = originalComponent.data || (() => ({})) - objectComponent.data = (vm) => ({ - ...originalDataFn.call(vm, vm), - ...providedData - }) - } else { - throw new Error( - 'data() option is not supported on functional and class components' - ) - } - } - - const MOUNT_COMPONENT_REF = 'VTU_COMPONENT' - // we define props as reactive so that way when we update them with `setProps` - // Vue's reactivity system will cause a rerender. - const refs = shallowReactive>({}) - const props = reactive>({}) - - Object.entries({ - ...options?.attrs, - ...options?.propsData, - ...options?.props, - ref: MOUNT_COMPONENT_REF - }).forEach(([k, v]) => { - if (isRef(v)) { - refs[k] = v - } else { - props[k] = v - } - }) - - const global = mergeGlobalProperties(options?.global) - if (isObjectComponent(component)) { - component.components = { ...component.components, ...global.components } - } - - const componentRef = ref(null) - // create the wrapper component - const Parent = defineComponent({ - name: 'VTU_ROOT', - setup() { - return { - [MOUNT_COMPONENT_REF]: componentRef - } - }, - render() { - return h(component as ComponentOptions, { ...props, ...refs }, slots) - } - }) + const { app, props, el, componentRef } = createInstance( + inputComponent, + options + ) const setProps = (newProps: Record) => { for (const [k, v] of Object.entries(newProps)) { @@ -462,149 +261,6 @@ export function mount( return vm.$nextTick() } - // create the app - const app = createApp(Parent) - // the Parent type must not be stubbed - // but we can't add it directly, as createApp creates a copy - // and store it in app._component (since v3.2.32) - // So we store this one instead - addToDoNotStubComponents(app._component) - - // add tracking for emitted events - // this must be done after `createApp`: https://github.com/vuejs/test-utils/issues/436 - attachEmitListener() - - // global mocks mixin - if (global?.mocks) { - const mixin = defineComponent({ - beforeCreate() { - // we need to differentiate components that are or not not `script setup` - // otherwise we run into a proxy set error - // due to https://github.com/vuejs/core/commit/f73925d76a76ee259749b8b48cb68895f539a00f#diff-ea4d1ddabb7e22e17e80ada458eef70679af4005df2a1a6b73418fec897603ceR404 - // introduced in Vue v3.2.45 - // Also ensures not to include option API components in this block - // since they can also have setup state but need to be patched using - // the regular method. - if (isScriptSetup(this)) { - // add the mocks to setupState - for (const [k, v] of Object.entries( - global.mocks as { [key: string]: any } - )) { - // we do this in a try/catch, as some properties might be read-only - try { - this.$.setupState[k] = v - // eslint-disable-next-line no-empty - } catch (e) {} - } - // also intercept the proxy calls to make the mocks available on the instance - // (useful when a template access a global function like $t and the developer wants to mock it) - ;(this.$ as any).proxy = new Proxy((this.$ as any).proxy, { - get(target, key) { - if (key in global.mocks) { - return global.mocks[key as string] - } - return target[key] - } - }) - } else { - for (const [k, v] of Object.entries( - global.mocks as { [key: string]: any } - )) { - ;(this as any)[k] = v - } - } - } - }) - - app.mixin(mixin) - } - - // AppConfig - if (global.config) { - for (const [k, v] of Object.entries(global.config) as [ - keyof Omit, - any - ][]) { - app.config[k] = isObject(app.config[k]) - ? Object.assign(app.config[k]!, v) - : v - } - } - - // use and plugins from mounting options - if (global.plugins) { - for (const plugin of global.plugins) { - if (Array.isArray(plugin)) { - app.use(plugin[0], ...plugin.slice(1)) - continue - } - app.use(plugin) - } - } - - // use any mixins from mounting options - if (global.mixins) { - for (const mixin of global.mixins) app.mixin(mixin) - } - - if (global.components) { - for (const key of Object.keys(global.components)) { - // avoid registering components that are stubbed twice - if (!(key in global.stubs)) { - app.component(key, global.components[key]) - } - } - } - - if (global.directives) { - for (const key of Object.keys(global.directives)) - app.directive(key, global.directives[key]) - } - - // provide any values passed via provides mounting option - if (global.provide) { - for (const key of Reflect.ownKeys(global.provide)) { - // @ts-ignore: https://github.com/microsoft/TypeScript/issues/1863 - app.provide(key, global.provide[key]) - } - } - - // stubs - // even if we are using `mount`, we will still - // stub out Transition and Transition Group by default. - transformVNodeArgs( - createVNodeTransformer({ - transformers: [ - createStubComponentsTransformer({ - stubs: getComponentsFromStubs(global.stubs), - shallow: options?.shallow, - renderStubDefaultSlot: global.renderStubDefaultSlot - }), - createStubDirectivesTransformer({ - directives: getDirectivesFromStubs(global.stubs) - }) - ] - }) - ) - - // users expect stubs to work with globally registered - // components so we register stubs as global components to avoid - // warning about not being able to resolve component - // - // component implementation provided here will never be called - // but we need name to make sure that stubComponents will - // properly stub this later by matching stub name - // - // ref: https://github.com/vuejs/test-utils/issues/249 - // ref: https://github.com/vuejs/test-utils/issues/425 - if (global?.stubs) { - for (const name of Object.keys(getComponentsFromStubs(global.stubs))) { - if (!app.component(name)) { - app.component(name, { name }) - } - } - } - // Workaround for https://github.com/vuejs/core/issues/7020 const originalErrorHandler = app.config.errorHandler diff --git a/src/renderToString.ts b/src/renderToString.ts new file mode 100644 index 000000000..c8aaa335f --- /dev/null +++ b/src/renderToString.ts @@ -0,0 +1,217 @@ +import { renderToString as baseRenderToString } from '@vue/server-renderer' +import { + FunctionalComponent, + ComponentOptionsWithObjectProps, + ComponentOptionsWithArrayProps, + ComponentOptionsWithoutProps, + ExtractPropTypes, + VNodeProps, + ComponentOptionsMixin, + DefineComponent, + MethodOptions, + AllowedComponentProps, + ComponentCustomProps, + ExtractDefaultPropTypes, + EmitsOptions, + ComputedOptions, + ComponentPropsOptions, + Prop +} from 'vue' + +import { MountingOptions } from './types' +import { createInstance } from './createInstance' + +// NOTE this should come from `vue` +type PublicProps = VNodeProps & AllowedComponentProps & ComponentCustomProps + +type ComponentMountingOptions = T extends DefineComponent< + infer PropsOrPropOptions, + any, + infer D, + any, + any +> + ? MountingOptions< + Partial> & + Omit< + Readonly> & PublicProps, + keyof ExtractDefaultPropTypes + >, + D + > & + Record + : MountingOptions + +// Class component (without vue-class-component) - no props +export function renderToString( + originalComponent: { + new (...args: any[]): V + __vccOpts: any + }, + options?: MountingOptions & Record +): Promise + +// Class component (without vue-class-component) - props +export function renderToString( + originalComponent: { + new (...args: any[]): V + __vccOpts: any + defaultProps?: Record> | string[] + }, + options?: MountingOptions

& Record +): Promise + +// Class component - no props +export function renderToString( + originalComponent: { + new (...args: any[]): V + registerHooks(keys: string[]): void + }, + options?: MountingOptions & Record +): Promise + +// Class component - props +export function renderToString( + originalComponent: { + new (...args: any[]): V + props(Props: P): any + registerHooks(keys: string[]): void + }, + options?: MountingOptions

& Record +): Promise + +// Functional component with emits +export function renderToString( + originalComponent: FunctionalComponent, + options?: MountingOptions & Record +): Promise + +// Component declared with defineComponent +export function renderToString< + PropsOrPropOptions = {}, + RawBindings = {}, + D = {}, + C extends ComputedOptions = ComputedOptions, + M extends MethodOptions = MethodOptions, + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, + E extends EmitsOptions = Record, + EE extends string = string, + PP = PublicProps, + Props = Readonly>, + Defaults extends {} = ExtractDefaultPropTypes +>( + component: DefineComponent< + PropsOrPropOptions, + RawBindings, + D, + C, + M, + Mixin, + Extends, + E, + EE, + PP, + Props, + Defaults + >, + options?: MountingOptions< + Partial & Omit, + D + > & + Record +): Promise + +// component declared by vue-tsc ScriptSetup +export function renderToString< + T extends DefineComponent +>(component: T, options?: ComponentMountingOptions): Promise + +// Component declared with no props +export function renderToString< + Props = {}, + RawBindings = {}, + D extends {} = {}, + C extends ComputedOptions = {}, + M extends Record = {}, + E extends EmitsOptions = Record, + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, + EE extends string = string +>( + componentOptions: ComponentOptionsWithoutProps< + Props, + RawBindings, + D, + C, + M, + E, + Mixin, + Extends, + EE + >, + options?: MountingOptions +): Promise + +// Component declared with { props: [] } +export function renderToString< + PropNames extends string, + RawBindings, + D extends {}, + C extends ComputedOptions = {}, + M extends Record = {}, + E extends EmitsOptions = Record, + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, + EE extends string = string, + Props extends Readonly<{ [key in PropNames]?: any }> = Readonly<{ + [key in PropNames]?: any + }> +>( + componentOptions: ComponentOptionsWithArrayProps< + PropNames, + RawBindings, + D, + C, + M, + E, + Mixin, + Extends, + EE, + Props + >, + options?: MountingOptions +): Promise + +// Component declared with { props: { ... } } +export function renderToString< + // the Readonly constraint allows TS to treat the type of { required: true } + // as constant instead of boolean. + PropsOptions extends Readonly, + RawBindings, + D extends {}, + C extends ComputedOptions = {}, + M extends Record = {}, + E extends EmitsOptions = Record, + Mixin extends ComponentOptionsMixin = ComponentOptionsMixin, + Extends extends ComponentOptionsMixin = ComponentOptionsMixin, + EE extends string = string +>( + componentOptions: ComponentOptionsWithObjectProps< + PropsOptions, + RawBindings, + D, + C, + M, + E, + Mixin, + Extends, + EE + >, + options?: MountingOptions & PublicProps, D> +): Promise + +export function renderToString(component: any, options?: any): Promise { + const { app } = createInstance(component, options) + return baseRenderToString(app) +} diff --git a/test-dts/renderToString.d-test.ts b/test-dts/renderToString.d-test.ts new file mode 100644 index 000000000..02c8a3361 --- /dev/null +++ b/test-dts/renderToString.d-test.ts @@ -0,0 +1,107 @@ +import { expectError, expectType } from './index' +import { defineComponent } from 'vue' +import { Options, Vue } from 'vue-class-component' +import { renderToString } from '../src' + +const AppWithDefine = defineComponent({ + props: { + a: { + type: String, + required: true + }, + b: Number + }, + template: '' +}) + +// accept props +let html = renderToString(AppWithDefine, { + props: { a: 'Hello', b: 2 } +}) +// html is properly typed +expectType>(html) + +// allow extra props, like using `h()` +renderToString(AppWithDefine, { + props: { a: 'Hello', c: 2 } +}) + +expectError( + // @ts-expect-error wrong prop type should not compile + renderToString(AppWithDefine, { + props: { a: 2 } + }) +) + +const AppWithProps = { + props: { + a: { + type: String, + required: true + } + }, + template: '' +} + +// accept props +expectType>( + renderToString(AppWithProps, { + props: { a: 'Hello' } + }) +) + +// allow extra props, like using `h()` +renderToString(AppWithProps, { + props: { a: 'Hello', b: 2 } +}) + +expectError( + renderToString(AppWithProps, { + // @ts-expect-error wrong prop type should not compile + props: { a: 2 } + }) +) + +const AppWithArrayProps = { + props: ['a'], + template: '' +} + +// accept props +html = renderToString(AppWithArrayProps, { + props: { a: 'Hello' } +}) +expectType>(html) + +// can receive extra props +// as they are declared as `string[]` +renderToString(AppWithArrayProps, { + props: { a: 'Hello', b: 2 } +}) + +const AppWithoutProps = { + template: '' +} + +// allow extra props, like using `h()` +html = renderToString(AppWithoutProps, { + props: { b: 'Hello' } +}) + +// class component +@Options({ + props: { + msg: String + } +}) +class ClassComponent extends Vue { + dataText = '' + get computedMsg(): string { + return `Message: ${(this.$props as any).msg}` + } + + changeMessage(text: string): void { + this.dataText = 'Updated' + } +} +expectType>(renderToString(ClassComponent)) diff --git a/tests/renderToString.spec.ts b/tests/renderToString.spec.ts new file mode 100644 index 000000000..eb1669fae --- /dev/null +++ b/tests/renderToString.spec.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest' +import { defineComponent, onMounted, onServerPrefetch, ref } from 'vue' +import { renderToString } from '../src' + +describe('renderToString', () => { + it('returns a promise', async () => { + const Component = defineComponent({ + template: '

{{ text }}
', + setup() { + return { text: 'Text content' } + } + }) + + const wrapper = await renderToString(Component) + + expect(wrapper).toMatchInlineSnapshot(`"
Text content
"`) + }) + + it('returns correct html on multi root nodes', async () => { + const Component = defineComponent({ + template: '
foo
bar
' + }) + + const wrapper = await renderToString(Component) + + expect(wrapper).toMatchInlineSnapshot( + `"
foo
bar
"` + ) + }) + + it('returns correct html with pre-fetched data on server', async () => { + function fakeFetch(text: string) { + return Promise.resolve(text) + } + + const Component = defineComponent({ + template: '
{{ text }}
', + setup() { + const text = ref(null) + + onServerPrefetch(async () => { + text.value = await fakeFetch('onServerPrefetch') + }) + + onMounted(async () => { + if (!text.value) { + text.value = await fakeFetch('onMounted') + } + }) + + return { text } + } + }) + + const contents = await renderToString(Component) + + expect(contents).toBe('
onServerPrefetch
') + }) +})