From 5a49d4c9416c0a38e04715226d13d5ada370bc6c Mon Sep 17 00:00:00 2001 From: Mike Bellika Date: Tue, 19 Aug 2025 15:14:21 +0200 Subject: [PATCH 1/4] fix: add effect scope cleanup to mountSuspended - Wrap component setup and Nuxt root setup in effect scopes - Add global cleanup mechanism to prevent state persistence between tests - Ensure watchers and reactive effects are properly disposed - Improves test isolation for mountSuspended components --- src/runtime-utils/mount.ts | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts index d6d746ba0..3ae5ea331 100644 --- a/src/runtime-utils/mount.ts +++ b/src/runtime-utils/mount.ts @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils' import type { ComponentMountingOptions } from '@vue/test-utils' -import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance } from 'vue' +import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, effectScope } from 'vue' import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue' import { defu } from 'defu' import type { RouteLocationRaw } from 'vue-router' @@ -57,6 +57,11 @@ export async function mountSuspended( ..._options } = options || {} + // cleanup previously mounted test wrappers + for (const fn of globalThis.__cleanup || []) { + fn() + } + const vueApp = tryUseNuxtApp()?.vueApp // @ts-expect-error untyped global __unctx__ || globalThis.__unctx__.get('nuxt-app').tryUse().vueApp @@ -104,13 +109,26 @@ export async function mountSuspended( } let passedProps: Record + let componentScope: ReturnType | null = null + const wrappedSetup = async (props: Record, setupContext: SetupContext): Promise => { interceptEmitOnCurrentInstance() passedProps = props if (setup) { - const result = await setup(props, setupContext) + // Create a new effect scope for the component's setup + componentScope = effectScope() + + // Add component scope cleanup to global cleanup + globalThis.__cleanup ||= [] + globalThis.__cleanup.push(() => { + componentScope?.stop() + }) + + const result = await componentScope.run(async () => { + return await setup(props, setupContext) + }) setupState = result && typeof result === 'object' ? result : {} return result } @@ -122,10 +140,18 @@ export async function mountSuspended( { setup: (props: Record, ctx: SetupContext) => { setupContext = ctx - return NuxtRoot.setup(props, { + + const scope = effectScope() + + globalThis.__cleanup ||= [] + globalThis.__cleanup.push(() => { + scope.stop() + }) + + return scope.run(() => NuxtRoot.setup(props, { ...ctx, expose: () => {}, - }) + })) }, render: (renderContext: Record) => h( @@ -276,3 +302,7 @@ function wrappedMountedWrapper(wrapper: ReturnType> & { setup return proxy } + +declare global { + var __cleanup: Array<() => void> | undefined +} From 779017d90fe285e15145c093ac204328dd158eb3 Mon Sep 17 00:00:00 2001 From: Mike Bellika Date: Tue, 19 Aug 2025 15:15:34 +0200 Subject: [PATCH 2/4] test: add tests to ensure isolation between tests --- .../components/GenericStateComponent.vue | 15 ++++ .../components/WatcherComponent.vue | 15 ++++ .../app-vitest-full/composables/useCounter.ts | 12 +++ .../tests/nuxt/mount-suspended.spec.ts | 82 ++++++++++++++++++- 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 examples/app-vitest-full/components/GenericStateComponent.vue create mode 100644 examples/app-vitest-full/components/WatcherComponent.vue create mode 100644 examples/app-vitest-full/composables/useCounter.ts diff --git a/examples/app-vitest-full/components/GenericStateComponent.vue b/examples/app-vitest-full/components/GenericStateComponent.vue new file mode 100644 index 000000000..6b0fd04bd --- /dev/null +++ b/examples/app-vitest-full/components/GenericStateComponent.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/app-vitest-full/components/WatcherComponent.vue b/examples/app-vitest-full/components/WatcherComponent.vue new file mode 100644 index 000000000..8e55bfc38 --- /dev/null +++ b/examples/app-vitest-full/components/WatcherComponent.vue @@ -0,0 +1,15 @@ + + + diff --git a/examples/app-vitest-full/composables/useCounter.ts b/examples/app-vitest-full/composables/useCounter.ts new file mode 100644 index 000000000..6d8154b90 --- /dev/null +++ b/examples/app-vitest-full/composables/useCounter.ts @@ -0,0 +1,12 @@ +export const useCounter = () => { + const count = ref(0) + + function isPositive() { + return count.value > 0 + } + + return { + count, + isPositive, + } +} 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 fa894ef3f..42ec269fa 100644 --- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' -import { mountSuspended } from '@nuxt/test-utils/runtime' +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime' import { satisfies } from 'semver' import { version as nuxtVersion } from 'nuxt/package.json' @@ -25,11 +25,13 @@ import ComponentWithAttrs from '~/components/ComponentWithAttrs.vue' import ComponentWithReservedProp from '~/components/ComponentWithReservedProp.vue' import ComponentWithReservedState from '~/components/ComponentWithReservedState.vue' import ComponentWithImports from '~/components/ComponentWithImports.vue' +import GenericStateComponent from '~/components/GenericStateComponent.vue' import { BoundAttrs } from '#components' import DirectiveComponent from '~/components/DirectiveComponent.vue' import CustomComponent from '~/components/CustomComponent.vue' import WrapperElement from '~/components/WrapperElement.vue' +import WatcherComponent from '~/components/WatcherComponent.vue' const formats = { ExportDefaultComponent, @@ -454,3 +456,81 @@ it('element should be changed', async () => { expect(component.element.tagName).toBe('SPAN') }) + +describe('composable state isolation', () => { + const { useCounterMock } = vi.hoisted(() => { + return { + useCounterMock: vi.fn(() => { + return { + isPositive: (): boolean => false, + } + }), + } + }) + + mockNuxtImport('useCounter', () => { + return useCounterMock + }) + + it('shows zero or negative state by default', async () => { + const component = await mountSuspended(GenericStateComponent) + expect(component.text()).toMatchInlineSnapshot('"Zero or negative count"') + }) + + it('shows positive state when counter is positive', async () => { + useCounterMock.mockRestore() + useCounterMock.mockImplementation(() => ({ + isPositive: () => true, + })) + const component = await mountSuspended(GenericStateComponent) + expect(component.text()).toMatchInlineSnapshot('"Positive count"') + }) +}) + +describe('watcher cleanup validation', () => { + let watcherCallCount = 0 + beforeEach(() => { + watcherCallCount = 0 + // Mock console.log to count watcher calls + vi.spyOn(console, 'log').mockImplementation((message) => { + if (typeof message === 'string' && message.includes('Test state has changed')) { + watcherCallCount++ + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('mounts component in test 1', async () => { + await mountSuspended(WatcherComponent, { + props: { + title: 'Component 1', + }, + }) + + expect(watcherCallCount).toBe(0) // No state changes yet + }) + + it('mounts component in test 2 and validates watcher cleanup', async () => { + await mountSuspended(WatcherComponent, { + props: { + title: 'Component 2', + }, + }) + + // Reset counter after mounting + watcherCallCount = 0 + + // Change the state - this should only trigger Component 2's watcher + const state = useState('testState') + state.value = 'new state' + + await nextTick() + + // Before the fix: would see 2 watcher calls (Component 1 and Component 2) + // After the fix: should only see 1 watcher call (Component 2 only) + expect(watcherCallCount).toBe(1) + }) +}) From 236c77b081e364ba1f9d7267a13a2af30789b0fb Mon Sep 17 00:00:00 2001 From: Mike Bellika Date: Wed, 3 Sep 2025 09:59:44 +0200 Subject: [PATCH 3/4] feat: add `scoped` option to `mountSuspended` --- .../tests/nuxt/mount-suspended.spec.ts | 12 ++-- src/runtime-utils/mount.ts | 57 ++++++++++++------- 2 files changed, 40 insertions(+), 29 deletions(-) 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 42ec269fa..da7f6795b 100644 --- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts @@ -473,7 +473,7 @@ describe('composable state isolation', () => { }) it('shows zero or negative state by default', async () => { - const component = await mountSuspended(GenericStateComponent) + const component = await mountSuspended(GenericStateComponent, { scoped: true }) expect(component.text()).toMatchInlineSnapshot('"Zero or negative count"') }) @@ -482,7 +482,7 @@ describe('composable state isolation', () => { useCounterMock.mockImplementation(() => ({ isPositive: () => true, })) - const component = await mountSuspended(GenericStateComponent) + const component = await mountSuspended(GenericStateComponent, { scoped: true }) expect(component.text()).toMatchInlineSnapshot('"Positive count"') }) }) @@ -508,9 +508,10 @@ describe('watcher cleanup validation', () => { props: { title: 'Component 1', }, + scoped: true, }) - expect(watcherCallCount).toBe(0) // No state changes yet + expect(watcherCallCount).toBe(0) }) it('mounts component in test 2 and validates watcher cleanup', async () => { @@ -518,19 +519,16 @@ describe('watcher cleanup validation', () => { props: { title: 'Component 2', }, + scoped: true, }) - // Reset counter after mounting watcherCallCount = 0 - // Change the state - this should only trigger Component 2's watcher const state = useState('testState') state.value = 'new state' await nextTick() - // Before the fix: would see 2 watcher calls (Component 1 and Component 2) - // After the fix: should only see 1 watcher call (Component 2 only) expect(watcherCallCount).toBe(1) }) }) diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts index 3ae5ea331..05706079c 100644 --- a/src/runtime-utils/mount.ts +++ b/src/runtime-utils/mount.ts @@ -12,6 +12,7 @@ import { tryUseNuxtApp, useRouter } from '#imports' type MountSuspendedOptions = ComponentMountingOptions & { route?: RouteLocationRaw + scoped?: boolean } // TODO: improve return types @@ -58,8 +59,8 @@ export async function mountSuspended( } = options || {} // cleanup previously mounted test wrappers - for (const fn of globalThis.__cleanup || []) { - fn() + for (const cleanupFunction of globalThis.__cleanup || []) { + cleanupFunction() } const vueApp = tryUseNuxtApp()?.vueApp @@ -117,18 +118,23 @@ export async function mountSuspended( passedProps = props if (setup) { - // Create a new effect scope for the component's setup - componentScope = effectScope() + let result + if (options?.scoped) { + componentScope = effectScope() - // Add component scope cleanup to global cleanup - globalThis.__cleanup ||= [] - globalThis.__cleanup.push(() => { - componentScope?.stop() - }) + // Add component scope cleanup to global cleanup + globalThis.__cleanup ||= [] + globalThis.__cleanup.push(() => { + componentScope?.stop() + }) + result = await componentScope?.run(async () => { + return await setup(props, setupContext) + }) + } + else { + result = await setup(props, setupContext) + } - const result = await componentScope.run(async () => { - return await setup(props, setupContext) - }) setupState = result && typeof result === 'object' ? result : {} return result } @@ -141,17 +147,24 @@ export async function mountSuspended( setup: (props: Record, ctx: SetupContext) => { setupContext = ctx - const scope = effectScope() - - globalThis.__cleanup ||= [] - globalThis.__cleanup.push(() => { - scope.stop() - }) + if (options?.scoped) { + const scope = effectScope() - return scope.run(() => NuxtRoot.setup(props, { - ...ctx, - expose: () => {}, - })) + globalThis.__cleanup ||= [] + globalThis.__cleanup.push(() => { + scope.stop() + }) + return scope.run(() => NuxtRoot.setup(props, { + ...ctx, + expose: () => {}, + })) + } + else { + return NuxtRoot.setup(props, { + ...ctx, + expose: () => {}, + }) + } }, render: (renderContext: Record) => h( From 705e9ef626c710f87d6dfd79b679ced416bf304e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Fri, 24 Oct 2025 00:36:54 +0900 Subject: [PATCH 4/4] chore: oops --- src/runtime-utils/mount.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime-utils/mount.ts b/src/runtime-utils/mount.ts index b8383642a..9dee9386f 100644 --- a/src/runtime-utils/mount.ts +++ b/src/runtime-utils/mount.ts @@ -1,7 +1,6 @@ import { mount } from '@vue/test-utils' import type { ComponentMountingOptions } from '@vue/test-utils' -import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, effectScope } from 'vue' -import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, isRef } from 'vue' +import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, effectScope, isRef } from 'vue' import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue' import { defu } from 'defu' import type { RouteLocationRaw } from 'vue-router'