diff --git a/examples/app-vitest-full/components/OptionsApiEmits.vue b/examples/app-vitest-full/components/OptionsApiEmits.vue
new file mode 100644
index 000000000..4c8ef6ece
--- /dev/null
+++ b/examples/app-vitest-full/components/OptionsApiEmits.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
diff --git a/examples/app-vitest-full/components/OptionsApiWatch.vue b/examples/app-vitest-full/components/OptionsApiWatch.vue
new file mode 100644
index 000000000..37f4e23c9
--- /dev/null
+++ b/examples/app-vitest-full/components/OptionsApiWatch.vue
@@ -0,0 +1,46 @@
+
+ {{ setByWatches }}
+
+
+
diff --git a/examples/app-vitest-full/components/ScriptSetupEmits.vue b/examples/app-vitest-full/components/ScriptSetupEmits.vue
new file mode 100644
index 000000000..d9c9012eb
--- /dev/null
+++ b/examples/app-vitest-full/components/ScriptSetupEmits.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
diff --git a/examples/app-vitest-full/components/ScriptSetupWatch.vue b/examples/app-vitest-full/components/ScriptSetupWatch.vue
new file mode 100644
index 000000000..19199e576
--- /dev/null
+++ b/examples/app-vitest-full/components/ScriptSetupWatch.vue
@@ -0,0 +1,85 @@
+
+ {{ setByWatches }}
+
+
+
diff --git a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts
index 2c206efaa..fa894ef3f 100644
--- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts
+++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts
@@ -15,8 +15,12 @@ import ExportDefaultComponent from '~/components/ExportDefaultComponent.vue'
import ExportDefineComponent from '~/components/ExportDefineComponent.vue'
import ExportDefaultWithRenderComponent from '~/components/ExportDefaultWithRenderComponent.vue'
import ExportDefaultReturnsRenderComponent from '~/components/ExportDefaultReturnsRenderComponent.vue'
+import ScriptSetupEmits from '~/components/ScriptSetupEmits.vue'
+import ScriptSetupWatch from '~/components/ScriptSetupWatch.vue'
import OptionsApiPage from '~/pages/other/options-api.vue'
import OptionsApiComputed from '~/components/OptionsApiComputed.vue'
+import OptionsApiEmits from '~/components/OptionsApiEmits.vue'
+import OptionsApiWatch from '~/components/OptionsApiWatch.vue'
import ComponentWithAttrs from '~/components/ComponentWithAttrs.vue'
import ComponentWithReservedProp from '~/components/ComponentWithReservedProp.vue'
import ComponentWithReservedState from '~/components/ComponentWithReservedState.vue'
@@ -227,6 +231,42 @@ describe('mountSuspended', () => {
expect(component.find('[foo="bar"]').exists()).toBe(true)
})
+ it('should capture emits from script setup and early hooks', async () => {
+ const component = await mountSuspended(ScriptSetupEmits)
+ await expect.poll(() => component.emitted()).toEqual({
+ 'event-from-setup': [[1], [2]],
+ 'event-from-before-mount': [[1], [2]],
+ 'event-from-mounted': [[1], [2]],
+ })
+ })
+
+ it('should handle data set from immediate watches', async () => {
+ const component = await mountSuspended(ScriptSetupWatch)
+ await expect.poll(
+ () =>
+ JSON.parse(component.find('[data-testid="set-by-watches"]').text()),
+ ).toEqual({
+ dataFromWatchEffectOnComputedFromReactiveObject: 'data-from-reactive-object',
+ dataFromWatchEffectOnReactiveObject: 'data-from-reactive-object',
+ dataFromWatchEffectOnReactiveString: 'data-from-reactive-string',
+ dataFromWatchOnComputedFromReactiveObject: 'data-from-reactive-object',
+ dataFromWatchOnReactiveObject: 'data-from-reactive-object',
+ dataFromWatchOnReactiveString: 'data-from-reactive-string',
+ })
+ })
+
+ it('should handle events emitted from immediate watches', async () => {
+ const component = await mountSuspended(ScriptSetupWatch)
+ await expect.poll(() => component.emitted()).toEqual({
+ 'event-from-watch-effect-on-computed-from-reactive-object': [[1]],
+ 'event-from-watch-effect-on-reactive-object': [[1]],
+ 'event-from-watch-effect-on-reactive-string': [[1]],
+ 'event-from-watch-on-computed-from-reactive-object': [[1]],
+ 'event-from-watch-on-reactive-object': [[1]],
+ 'event-from-watch-on-reactive-string': [[1]],
+ })
+ })
+
describe('Options API', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation((message) => {
@@ -270,6 +310,37 @@ describe('mountSuspended', () => {
expect(component.find('[data-testid="object-with-get-and-set"]').text()).toBe('object-with-get-and-set')
expect(console.error).not.toHaveBeenCalled()
})
+
+ it('should capture emits from setup and early hooks', async () => {
+ const component = await mountSuspended(OptionsApiEmits)
+ await expect.poll(() => component.emitted()).toEqual({
+ 'event-from-setup': [[1], [2]],
+ 'event-from-before-mount': [[1], [2]],
+ 'event-from-mounted': [[1], [2]],
+ })
+ expect(console.error).not.toHaveBeenCalled()
+ })
+
+ it('should handle data set from immediate watches', async () => {
+ const component = await mountSuspended(OptionsApiWatch)
+ await expect.poll(
+ () =>
+ JSON.parse(component.find('[data-testid="set-by-watches"]').text()),
+ ).toEqual({
+ dataFromInternalDataObject: 'data-from-internal-data-object',
+ dataMappedFromExternalReactiveStore: 'data-from-external-reactive-store',
+ })
+ expect(console.error).not.toHaveBeenCalled()
+ })
+
+ it('should handle events emitted from immediate watches', async () => {
+ const component = await mountSuspended(OptionsApiWatch)
+ await expect.poll(() => component.emitted()).toEqual({
+ 'event-from-internal-data-object': [[1]],
+ 'event-mapped-from-external-reactive-store': [[1]],
+ })
+ expect(console.error).not.toHaveBeenCalled()
+ })
})
})
diff --git a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts
index e0dd12224..fd94dfa83 100644
--- a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts
+++ b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts
@@ -16,7 +16,7 @@ import ExportDefaultWithRenderComponent from '~/components/ExportDefaultWithRend
import ExportDefaultReturnsRenderComponent from '~/components/ExportDefaultReturnsRenderComponent.vue'
import OptionsApiPage from '~/pages/other/options-api.vue'
-import { BoundAttrs } from '#components'
+import { BoundAttrs, OptionsApiEmits, OptionsApiWatch, ScriptSetupEmits, ScriptSetupWatch } from '#components'
const formats = {
ExportDefaultComponent,
@@ -134,6 +134,42 @@ describe('renderSuspended', () => {
`)
})
+ it('should capture emits from script setup and early hooks', async () => {
+ const { emitted } = await renderSuspended(ScriptSetupEmits)
+ await expect.poll(() => emitted()).toEqual({
+ 'event-from-setup': [[1], [2]],
+ 'event-from-before-mount': [[1], [2]],
+ 'event-from-mounted': [[1], [2]],
+ })
+ })
+
+ it('should handle data set from immediate watches', async () => {
+ const { getByTestId } = await renderSuspended(ScriptSetupWatch)
+ await expect.poll(
+ () =>
+ JSON.parse(getByTestId('set-by-watches').textContent || '{}'),
+ ).toEqual({
+ dataFromWatchEffectOnComputedFromReactiveObject: 'data-from-reactive-object',
+ dataFromWatchEffectOnReactiveObject: 'data-from-reactive-object',
+ dataFromWatchEffectOnReactiveString: 'data-from-reactive-string',
+ dataFromWatchOnComputedFromReactiveObject: 'data-from-reactive-object',
+ dataFromWatchOnReactiveObject: 'data-from-reactive-object',
+ dataFromWatchOnReactiveString: 'data-from-reactive-string',
+ })
+ })
+
+ it('should handle events emitted from immediate watches', async () => {
+ const { emitted } = await renderSuspended(ScriptSetupWatch)
+ await expect.poll(() => emitted()).toEqual({
+ 'event-from-watch-effect-on-computed-from-reactive-object': [[1]],
+ 'event-from-watch-effect-on-reactive-object': [[1]],
+ 'event-from-watch-effect-on-reactive-string': [[1]],
+ 'event-from-watch-on-computed-from-reactive-object': [[1]],
+ 'event-from-watch-on-reactive-object': [[1]],
+ 'event-from-watch-on-reactive-string': [[1]],
+ })
+ })
+
describe('Options API', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation((message) => {
@@ -169,6 +205,37 @@ describe('renderSuspended', () => {
await fireEvent.click(getByTestId('test-button'))
expect(console.error).not.toHaveBeenCalled()
})
+
+ it('should capture emits from setup and early hooks', async () => {
+ const { emitted } = await renderSuspended(OptionsApiEmits)
+ await expect.poll(() => emitted()).toEqual({
+ 'event-from-setup': [[1], [2]],
+ 'event-from-before-mount': [[1], [2]],
+ 'event-from-mounted': [[1], [2]],
+ })
+ expect(console.error).not.toHaveBeenCalled()
+ })
+
+ it('should handle data set from immediate watches', async () => {
+ const { getByTestId } = await renderSuspended(OptionsApiWatch)
+ await expect.poll(
+ () =>
+ JSON.parse(getByTestId('set-by-watches').textContent || '{}'),
+ ).toEqual({
+ dataFromInternalDataObject: 'data-from-internal-data-object',
+ dataMappedFromExternalReactiveStore: 'data-from-external-reactive-store',
+ })
+ expect(console.error).not.toHaveBeenCalled()
+ })
+
+ it('should handle events emitted from immediate watches', async () => {
+ const { emitted } = await renderSuspended(OptionsApiWatch)
+ await expect.poll(() => emitted()).toEqual({
+ 'event-from-internal-data-object': [[1]],
+ 'event-mapped-from-external-reactive-store': [[1]],
+ })
+ expect(console.error).not.toHaveBeenCalled()
+ })
})
})
diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts
index baba46d75..d6d746ba0 100644
--- a/src/runtime-utils/mount.ts
+++ b/src/runtime-utils/mount.ts
@@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance } from 'vue'
-import type { DefineComponent, SetupContext } from 'vue'
+import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue'
import { defu } from 'defu'
import type { RouteLocationRaw } from 'vue-router'
@@ -17,6 +17,7 @@ type MountSuspendedOptions = ComponentMountingOptions & {
// TODO: improve return types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SetupState = Record
+type Emit = ComponentInternalInstance['emit']
/**
* `mountSuspended` allows you to mount any vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins. For example:
@@ -65,9 +66,49 @@ export async function mountSuspended(
let setupState: Record
const setProps = reactive>({})
+ let interceptedEmit: Emit | null = null
+ /**
+ * Intercept the emit for testing purposes.
+ *
+ * @remarks
+ * Using this function ensures that the emit is not intercepted multiple times
+ * and doesn't duplicate events.
+ *
+ * @param emit - The original emit from the component's context.
+ *
+ * @returns An intercepted emit that will both emit from the component itself
+ * and from the top level wrapper for assertions via
+ * {@link import('@vue/test-utils').VueWrapper.emitted()}.
+ */
+ function getInterceptedEmitFunction(emit: Emit): Emit {
+ if (emit !== interceptedEmit) {
+ interceptedEmit = interceptedEmit ?? ((event, ...args) => {
+ emit(event, ...args)
+ setupContext.emit(event, ...args)
+ })
+ }
+
+ return interceptedEmit
+ }
+
+ /**
+ * Intercept emit for assertions in populate wrapper emitted.
+ */
+ function interceptEmitOnCurrentInstance(): void {
+ const currentInstance = getCurrentInstance()
+ if (!currentInstance) {
+ return
+ }
+
+ currentInstance.emit = getInterceptedEmitFunction(currentInstance.emit)
+ }
+
let passedProps: Record
- const wrappedSetup = async (props: Record, setupContext: SetupContext) => {
+ const wrappedSetup = async (props: Record, setupContext: SetupContext): Promise => {
+ interceptEmitOnCurrentInstance()
+
passedProps = props
+
if (setup) {
const result = await setup(props, setupContext)
setupState = result && typeof result === 'object' ? result : {}
@@ -107,28 +148,14 @@ export async function mountSuspended(
const router = useRouter()
await router.replace(route)
- let interceptedEmit: ((event: string, ...args: unknown[]) => void) | null = null
-
// Proxy top-level setup/render context so test wrapper resolves child component
const clonedComponent = {
name: 'MountSuspendedComponent',
...component,
render: render
? function (this: unknown, _ctx: Record, ...args: unknown[]) {
- // When using defineModel, getCurrentInstance().emit is executed internally. it needs to override.
- const currentInstance = getCurrentInstance()
- if (
- currentInstance
- // Intercept the emit only once. Otherwise the events would be duplicated for every rerender.
- && currentInstance.emit !== interceptedEmit
- ) {
- const oldEmit = currentInstance.emit
- interceptedEmit = (event, ...args) => {
- oldEmit(event, ...args)
- setupContext.emit(event, ...args)
- }
- currentInstance.emit = interceptedEmit
- }
+ interceptEmitOnCurrentInstance()
+
// Set before setupState set to allow asyncData to overwrite data
if (data && typeof data === 'function') {
// @ts-expect-error error TS2554: Expected 1 arguments, but got 0
@@ -178,7 +205,7 @@ export async function mountSuspended(
return render.call(this, renderContext, ...args)
}
: undefined,
- setup: setup ? (props: Record) => wrappedSetup(props, setupContext) : undefined,
+ setup: (props: Record) => wrappedSetup(props, setupContext),
}
return () => h(clonedComponent, { ...props, ...setProps, ...attrs }, slots)
diff --git a/src/runtime-utils/render.ts b/src/runtime-utils/render.ts
index 0bef20d4a..bd524a2fb 100644
--- a/src/runtime-utils/render.ts
+++ b/src/runtime-utils/render.ts
@@ -1,5 +1,5 @@
import { Suspense, effectScope, h, nextTick, isReadonly, reactive, unref, defineComponent } from 'vue'
-import type { DefineComponent, SetupContext } from 'vue'
+import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue'
import type { RenderOptions as TestingLibraryRenderOptions } from '@testing-library/vue'
import { defu } from 'defu'
import type { RouteLocationRaw } from 'vue-router'
@@ -17,6 +17,7 @@ const WRAPPER_EL_ID = 'test-wrapper'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type SetupState = Record
+type Emit = ComponentInternalInstance['emit']
/**
* `renderSuspended` allows you to mount any vue component within the Nuxt environment, allowing async setup and access to injections from your Nuxt plugins.
*
@@ -66,6 +67,43 @@ export async function renderSuspended(component: T, options?: RenderOptions {
+ emit(event, ...args)
+ setupContext.emit(event, ...args)
+ })
+ }
+
+ return interceptedEmit
+ }
+
+ /**
+ * Intercept emit for assertions in populate wrapper emitted.
+ */
+ function interceptEmitOnCurrentInstance(): void {
+ const currentInstance = getCurrentInstance()
+ if (!currentInstance) {
+ return
+ }
+
+ currentInstance.emit = getInterceptedEmitFunction(currentInstance.emit)
+ }
+
// cleanup previously mounted test wrappers
for (const fn of window.__cleanup || []) {
fn()
@@ -73,11 +111,11 @@ export async function renderSuspended(component: T, options?: RenderOptions
- const wrappedSetup = async (
- props: Record,
- setupContext: SetupContext,
- ) => {
+ const wrappedSetup = async (props: Record, setupContext: SetupContext): Promise => {
+ interceptEmitOnCurrentInstance()
+
passedProps = props
+
if (setup) {
const result = await setup(props, setupContext)
setupState = result && typeof result === 'object' ? result : {}
@@ -140,6 +178,8 @@ export async function renderSuspended(component: T, options?: RenderOptions, ...args: unknown[]) {
+ interceptEmitOnCurrentInstance()
+
// Set before setupState set to allow asyncData to overwrite data
if (data && typeof data === 'function') {
// @ts-expect-error error TS2554: Expected 1 arguments, but got 0
@@ -189,7 +229,7 @@ export async function renderSuspended(component: T, options?: RenderOptions) => wrappedSetup(props, setupContext) : undefined,
+ setup: (props: Record) => wrappedSetup(props, setupContext),
}
return () => h(clonedComponent, { ...(props && typeof props === 'object' ? props : {}), ...attrs }, slots)