Skip to content

Commit 2e8909d

Browse files
authored
feat(runtime): add scoped option to cleanup components (#1389)
1 parent 6b4076a commit 2e8909d

File tree

5 files changed

+170
-7
lines changed

5 files changed

+170
-7
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup lang="ts">
2+
const { isPositive } = useCounter()
3+
const { data } = useLazyAsyncData(async () => {
4+
return isPositive() ? 'positive' : 'zero-or-negative'
5+
})
6+
</script>
7+
8+
<template>
9+
<div v-if="data === 'positive'">
10+
Positive count
11+
</div>
12+
<div v-else>
13+
Zero or negative count
14+
</div>
15+
</template>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script setup lang="ts">
2+
const { title } = defineProps<{ title: string }>()
3+
4+
const testState = useState('testState')
5+
6+
watch(testState, (newTestState) => {
7+
console.log(`Test state has changed in component ${title}: `, newTestState)
8+
})
9+
</script>
10+
11+
<template>
12+
<div>
13+
{{ title }}
14+
</div>
15+
</template>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const useCounter = () => {
2+
const count = ref(0)
3+
4+
function isPositive() {
5+
return count.value > 0
6+
}
7+
8+
return {
9+
count,
10+
isPositive,
11+
}
12+
}

examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
22

3-
import { mountSuspended } from '@nuxt/test-utils/runtime'
3+
import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'
44
import { satisfies } from 'semver'
55
import { version as nuxtVersion } from 'nuxt/package.json'
66

@@ -25,11 +25,13 @@ import ComponentWithAttrs from '~/components/ComponentWithAttrs.vue'
2525
import ComponentWithReservedProp from '~/components/ComponentWithReservedProp.vue'
2626
import ComponentWithReservedState from '~/components/ComponentWithReservedState.vue'
2727
import ComponentWithImports from '~/components/ComponentWithImports.vue'
28+
import GenericStateComponent from '~/components/GenericStateComponent.vue'
2829

2930
import { BoundAttrs } from '#components'
3031
import DirectiveComponent from '~/components/DirectiveComponent.vue'
3132
import CustomComponent from '~/components/CustomComponent.vue'
3233
import WrapperElement from '~/components/WrapperElement.vue'
34+
import WatcherComponent from '~/components/WatcherComponent.vue'
3335

3436
const formats = {
3537
ExportDefaultComponent,
@@ -460,3 +462,79 @@ it('element should be changed', async () => {
460462

461463
expect(component.element.tagName).toBe('SPAN')
462464
})
465+
466+
describe('composable state isolation', () => {
467+
const { useCounterMock } = vi.hoisted(() => {
468+
return {
469+
useCounterMock: vi.fn(() => {
470+
return {
471+
isPositive: (): boolean => false,
472+
}
473+
}),
474+
}
475+
})
476+
477+
mockNuxtImport('useCounter', () => {
478+
return useCounterMock
479+
})
480+
481+
it('shows zero or negative state by default', async () => {
482+
const component = await mountSuspended(GenericStateComponent, { scoped: true })
483+
expect(component.text()).toMatchInlineSnapshot('"Zero or negative count"')
484+
})
485+
486+
it('shows positive state when counter is positive', async () => {
487+
useCounterMock.mockRestore()
488+
useCounterMock.mockImplementation(() => ({
489+
isPositive: () => true,
490+
}))
491+
const component = await mountSuspended(GenericStateComponent, { scoped: true })
492+
expect(component.text()).toMatchInlineSnapshot('"Positive count"')
493+
})
494+
})
495+
496+
describe('watcher cleanup validation', () => {
497+
let watcherCallCount = 0
498+
beforeEach(() => {
499+
watcherCallCount = 0
500+
// Mock console.log to count watcher calls
501+
vi.spyOn(console, 'log').mockImplementation((message) => {
502+
if (typeof message === 'string' && message.includes('Test state has changed')) {
503+
watcherCallCount++
504+
}
505+
})
506+
})
507+
508+
afterEach(() => {
509+
vi.restoreAllMocks()
510+
})
511+
512+
it('mounts component in test 1', async () => {
513+
await mountSuspended(WatcherComponent, {
514+
props: {
515+
title: 'Component 1',
516+
},
517+
scoped: true,
518+
})
519+
520+
expect(watcherCallCount).toBe(0)
521+
})
522+
523+
it('mounts component in test 2 and validates watcher cleanup', async () => {
524+
await mountSuspended(WatcherComponent, {
525+
props: {
526+
title: 'Component 2',
527+
},
528+
scoped: true,
529+
})
530+
531+
watcherCallCount = 0
532+
533+
const state = useState('testState')
534+
state.value = 'new state'
535+
536+
await nextTick()
537+
538+
expect(watcherCallCount).toBe(1)
539+
})
540+
})

src/runtime-utils/mount.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mount } from '@vue/test-utils'
22
import type { ComponentMountingOptions } from '@vue/test-utils'
3-
import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, isRef } from 'vue'
3+
import { Suspense, h, isReadonly, nextTick, reactive, unref, getCurrentInstance, effectScope, isRef } from 'vue'
44
import type { ComponentInternalInstance, DefineComponent, SetupContext } from 'vue'
55
import { defu } from 'defu'
66
import type { RouteLocationRaw } from 'vue-router'
@@ -12,6 +12,7 @@ import { tryUseNuxtApp, useRouter } from '#imports'
1212

1313
type MountSuspendedOptions<T> = ComponentMountingOptions<T> & {
1414
route?: RouteLocationRaw
15+
scoped?: boolean
1516
}
1617

1718
// TODO: improve return types
@@ -57,6 +58,11 @@ export async function mountSuspended<T>(
5758
..._options
5859
} = options || {}
5960

61+
// cleanup previously mounted test wrappers
62+
for (const cleanupFunction of globalThis.__cleanup || []) {
63+
cleanupFunction()
64+
}
65+
6066
const vueApp = tryUseNuxtApp()?.vueApp
6167
// @ts-expect-error untyped global __unctx__
6268
|| globalThis.__unctx__.get('nuxt-app').tryUse().vueApp
@@ -104,13 +110,31 @@ export async function mountSuspended<T>(
104110
}
105111

106112
let passedProps: Record<string, unknown>
113+
let componentScope: ReturnType<typeof effectScope> | null = null
114+
107115
const wrappedSetup = async (props: Record<string, unknown>, setupContext: SetupContext): Promise<unknown> => {
108116
interceptEmitOnCurrentInstance()
109117

110118
passedProps = props
111119

112120
if (setup) {
113-
const result = await setup(props, setupContext)
121+
let result
122+
if (options?.scoped) {
123+
componentScope = effectScope()
124+
125+
// Add component scope cleanup to global cleanup
126+
globalThis.__cleanup ||= []
127+
globalThis.__cleanup.push(() => {
128+
componentScope?.stop()
129+
})
130+
result = await componentScope?.run(async () => {
131+
return await setup(props, setupContext)
132+
})
133+
}
134+
else {
135+
result = await setup(props, setupContext)
136+
}
137+
114138
setupState = result && typeof result === 'object' ? result : {}
115139
return result
116140
}
@@ -122,10 +146,25 @@ export async function mountSuspended<T>(
122146
{
123147
setup: (props: Record<string, unknown>, ctx: SetupContext) => {
124148
setupContext = ctx
125-
return NuxtRoot.setup(props, {
126-
...ctx,
127-
expose: () => {},
128-
})
149+
150+
if (options?.scoped) {
151+
const scope = effectScope()
152+
153+
globalThis.__cleanup ||= []
154+
globalThis.__cleanup.push(() => {
155+
scope.stop()
156+
})
157+
return scope.run(() => NuxtRoot.setup(props, {
158+
...ctx,
159+
expose: () => {},
160+
}))
161+
}
162+
else {
163+
return NuxtRoot.setup(props, {
164+
...ctx,
165+
expose: () => {},
166+
})
167+
}
129168
},
130169
render: (renderContext: Record<string, unknown>) =>
131170
h(
@@ -307,3 +346,7 @@ function createVMProxy<T extends ComponentPublicInstance>(vm: T, setupState: Rec
307346
},
308347
})
309348
}
349+
350+
declare global {
351+
var __cleanup: Array<() => void> | undefined
352+
}

0 commit comments

Comments
 (0)