diff --git a/src/createInstance.ts b/src/createInstance.ts new file mode 100644 index 000000000..af9161429 --- /dev/null +++ b/src/createInstance.ts @@ -0,0 +1,301 @@ +import { + h, + createApp, + defineComponent, + reactive, + shallowReactive, + isRef, + AppConfig, + ComponentOptions, + ConcreteComponent, + DefineComponent +} from 'vue' + +import { MountingOptions, Slot } from './types' +import { + isFunctionalComponent, + isObjectComponent, + mergeGlobalProperties +} from './utils' +import { processSlot } from './utils/compileSlots' +import { attachEmitListener } from './emit' +import { stubComponents, addToDoNotStubComponents, registerStub } from './stubs' +import { + isLegacyFunctionalComponent, + unwrapLegacyVueExtendComponent +} from './utils/vueCompatSupport' + +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 +} + +// 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) + 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 } + } + + // create the wrapper component + const Parent = defineComponent({ + name: 'VTU_ROOT', + 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 = { + beforeCreate() { + 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] = 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. + stubComponents(global.stubs, options?.shallow, global?.renderStubDefaultSlot) + + // 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(global.stubs)) { + if (!app.component(name)) { + app.component(name, { name }) + } + } + } + + return { + app, + el, + props, + MOUNT_COMPONENT_REF + } +} 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 40068fd44..b584d8614 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,60 +15,18 @@ import { EmitsOptions, ComputedOptions, ComponentPropsOptions, - ComponentOptions, - ConcreteComponent, Prop } from 'vue' -import { MountingOptions, Slot } from './types' -import { - isFunctionalComponent, - isObjectComponent, - mergeGlobalProperties -} from './utils' -import { processSlot } from './utils/compileSlots' +import { MountingOptions } from './types' import { VueWrapper } from './vueWrapper' -import { attachEmitListener } from './emit' -import { stubComponents, addToDoNotStubComponents, registerStub } from './stubs' -import { - isLegacyFunctionalComponent, - unwrapLegacyVueExtendComponent -} from './utils/vueCompatSupport' import { trackInstance } from './utils/autoUnmount' import { createVueWrapper } from './wrapperFactory' +import { createInstance } from './createInstance' // NOTE this should come from `vue` type PublicProps = VNodeProps & AllowedComponentProps & ComponentCustomProps -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 -} - // Class component (without vue-class-component) - no props export function mount( originalComponent: { @@ -275,140 +226,13 @@ 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) - 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) - } + const { app, props, el, MOUNT_COMPONENT_REF } = createInstance( + inputComponent, + options + ) - 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 } - } - - // create the wrapper component - const Parent = defineComponent({ - name: 'VTU_ROOT', - render() { - return h(component as ComponentOptions, { ...props, ...refs }, slots) - } - }) + // mount the app! + const vm = app.mount(el) const setProps = (newProps: Record) => { for (const [k, v] of Object.entries(newProps)) { @@ -418,107 +242,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 = { - beforeCreate() { - 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] = 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. - stubComponents(global.stubs, options?.shallow, global?.renderStubDefaultSlot) - - // 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(global.stubs)) { - if (!app.component(name)) { - app.component(name, { name }) - } - } - } - - // mount the app! - const vm = app.mount(el) - // Ignore "Avoid app logic that relies on enumerating keys on a component instance..." warning const warnSave = console.warn console.warn = () => {} diff --git a/src/renderToString.ts b/src/renderToString.ts new file mode 100644 index 000000000..fcd98ec5f --- /dev/null +++ b/src/renderToString.ts @@ -0,0 +1,194 @@ +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 + +// 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 = ExtractDefaultPropTypes +>( + component: DefineComponent< + PropsOrPropOptions, + RawBindings, + D, + C, + M, + Mixin, + Extends, + E, + EE, + PP, + Props, + Defaults + >, + options?: MountingOptions< + Partial & Omit, + D + > & + Record +): Promise + +// Component declared with no props +export function renderToString< + Props = {}, + RawBindings = {}, + D = {}, + 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, + 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, + 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) { + const { app } = createInstance(component, options) + return baseRenderToString(app) +} diff --git a/tests/renderToString.spec.ts b/tests/renderToString.spec.ts new file mode 100644 index 000000000..55d12dcd0 --- /dev/null +++ b/tests/renderToString.spec.ts @@ -0,0 +1,62 @@ +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 new Promise((resolve) => { + setTimeout(() => { + resolve(text) + }, 100) + }) + } + + 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
') + }) +})