Repository: vuejs/test-utils (ts)

Issue:
Bug: Circular references in props cause "Maximum call stack size exceeded" since v2.4.5
<!-- Thanks for your interest in the project! We appreciate bugs filed and PRs submitted! -->


**Describe the bug**
When a property contains a circular reference, vue-test-utils gets stuck in a loop, resulting in a "Maximum call stack size exceeded" error.
<!-- A clear and concise description of what the bug is. -->

**To Reproduce**
Failing example (2.4.5): https://stackblitz.com/edit/github-mhdk9j-cpr5hr?file=src%2Fcomponents%2F__tests__%2FHelloWorld.spec.ts&view=editor&startScript=test:unit
Working example (2.4.4): https://stackblitz.com/edit/github-mhdk9j-gdhwv2?file=src%2Fcomponents%2F__tests__%2FHelloWorld.spec.ts&view=editor&startScript=test:unit
<!-- A link to a minimal reproduction (with the minimum code to reproduce the issue). 
It takes just a few minutes to build a repro online with https://stackblitz.com/github/vuejs/create-vue-templates/tree/main/typescript-vitest?file=src%2Fcomponents%2F__tests__%2FHelloWorld.spec.ts
-->

**Expected behavior**
The test should not crash on circular references, because Vue itself also doesn't crash on this.
<!-- A clear and concise description of what you expected to happen. -->

**Related information:**
  System:
    OS: Linux 5.0 undefined
    CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
    Memory: 0 Bytes / 0 Bytes
    Shell: 1.0 - /bin/jsh

  npmPackages:
    @vue/test-utils: ^2.4.4 => 2.4.5 
    vitest: ^1.3.1 => 1.3.1 
    vue: ^3.4.21 => 3.4.21 
<!-- Output of  `npx envinfo --system --npmPackages vue,@vue/test-utils,vitest,jest` -->
<!-- For test-utils v1 and Vue v2, please report at repository https://github.com/vuejs/vue-test-utils -->

**Additional information:**

Caused by the deep ref check in https://github.com/vuejs/test-utils/pull/2356/files


Pull request description:
Fix/circular references in props cause maximum call stack size exceeded
Fix #2370

Patch:
diff --git a/src/createInstance.ts b/src/createInstance.ts
index 34f3077..1afe8e9 100644
--- a/src/createInstance.ts
+++ b/src/createInstance.ts
@@ -16,7 +16,6 @@ import { MountingOptions, Slot } from './types'
 import {
   getComponentsFromStubs,
   getDirectivesFromStubs,
-  isDeepRef,
   isFunctionalComponent,
   isObject,
   isObjectComponent,
@@ -36,6 +35,7 @@ import {
   CreateStubComponentsTransformerConfig
 } from './vnodeTransformers/stubComponentsTransformer'
 import { createStubDirectivesTransformer } from './vnodeTransformers/stubDirectivesTransformer'
+import { isDeepRef } from './utils/isDeepRef'
 
 const MOUNT_OPTIONS: ReadonlyArray<keyof MountingOptions<any>> = [
   'attachTo',
diff --git a/src/utils.ts b/src/utils.ts
index 97b417e..f1119b4 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,12 +1,11 @@
-import { DeepRef, GlobalMountOptions, RefSelector, Stub, Stubs } from './types'
+import { GlobalMountOptions, RefSelector, Stub, Stubs } from './types'
 import {
   Component,
   ComponentOptions,
   ComponentPublicInstance,
   ConcreteComponent,
   Directive,
-  FunctionalComponent,
-  isRef
+  FunctionalComponent
 } from 'vue'
 import { config } from './config'
 
@@ -253,15 +252,3 @@ export const getGlobalThis = (): any => {
               : {})
   )
 }
-
-/**
- * Checks if the given value is a DeepRef.
- *
- * For both arrays and objects, it will recursively check
- * if any of their values is a Ref.
- *
- * @param {DeepRef<T> | unknown} r - The value to check.
- * @returns {boolean} Returns true if the value is a DeepRef, false otherwise.
- */
-export const isDeepRef = <T>(r: DeepRef<T> | unknown): r is DeepRef<T> =>
-  isRef(r) || (isObject(r) && Object.values(r).some(isDeepRef))
diff --git a/src/utils/isDeepRef.ts b/src/utils/isDeepRef.ts
new file mode 100644
index 0000000..00002c2
--- /dev/null
+++ b/src/utils/isDeepRef.ts
@@ -0,0 +1,36 @@
+import { isObject } from '../utils'
+import type { DeepRef } from '../types'
+import { isRef } from 'vue'
+
+/**
+ * Implementation details of isDeepRef to avoid circular dependencies.
+ * It keeps track of visited objects to avoid infinite recursion.
+ *
+ * @param r The value to check for a Ref.
+ * @param visitedObjects a weak map to keep track of visited objects and avoid infinite recursion
+ * @returns returns true if the value is a Ref, false otherwise
+ */
+const deeplyCheckForRef = <T>(
+  r: DeepRef<T> | unknown,
+  visitedObjects: WeakMap<object, boolean>
+): r is DeepRef<T> => {
+  if (isRef(r)) return true
+  if (!isObject(r)) return false
+  if (visitedObjects.has(r)) return false
+  visitedObjects.set(r, true)
+  return Object.values(r).some((val) => deeplyCheckForRef(val, visitedObjects))
+}
+
+/**
+ * Checks if the given value is a DeepRef.
+ *
+ * For both arrays and objects, it will recursively check
+ * if any of their values is a Ref.
+ *
+ * @param {DeepRef<T> | unknown} r - The value to check.
+ * @returns {boolean} Returns true if the value is a DeepRef, false otherwise.
+ */
+export const isDeepRef = <T>(r: DeepRef<T> | unknown): r is DeepRef<T> => {
+  const visitedObjects = new WeakMap()
+  return deeplyCheckForRef(r, visitedObjects)
+}


Test patch:
diff --git a/tests/isDeepRef.spec.ts b/tests/isDeepRef.spec.ts
new file mode 100644
index 0000000..934c3c9
--- /dev/null
+++ b/tests/isDeepRef.spec.ts
@@ -0,0 +1,82 @@
+import { isDeepRef } from '../src/utils/isDeepRef'
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+describe('isDeepRef', () => {
+  it('should return true for a Ref value', () => {
+    const testRef = ref(1)
+
+    expect(isDeepRef(testRef)).toBe(true)
+  })
+  it('should return false for a non-object, non-Ref value', () => {
+    const nonObject = 1
+
+    expect(isDeepRef(nonObject)).toBe(false)
+  })
+  it('should return true for an object with a Ref value', () => {
+    const testObject = { ref: ref(1) }
+
+    expect(isDeepRef(testObject)).toBe(true)
+  })
+  it('should return false for an object without a Ref value', () => {
+    const testObject = { nonRef: 1 }
+
+    expect(isDeepRef(testObject)).toBe(false)
+  })
+  it('should return true for an array with a Ref value', () => {
+    const arrayWithRef = [ref(1)]
+
+    expect(isDeepRef(arrayWithRef)).toBe(true)
+  })
+  it('should return false for an array without a Ref value', () => {
+    const arrayWithoutRef = [1]
+
+    expect(isDeepRef(arrayWithoutRef)).toBe(false)
+  })
+  it('should return true for a nested object with a Ref value', () => {
+    const nestedObject = { nested: { ref: ref(1) } }
+
+    expect(isDeepRef(nestedObject)).toBe(true)
+  })
+  it('should return false for a nested object without a Ref value', () => {
+    const nestedObject = { nested: { nonRef: 1 } }
+
+    expect(isDeepRef(nestedObject)).toBe(false)
+  })
+  it('should return true for a nested array with a Ref value', () => {
+    const nestedArray = [[ref(1)]]
+
+    expect(isDeepRef(nestedArray)).toBe(true)
+  })
+  it('should return false for a nested array without a Ref value', () => {
+    const nestedArray = [[1]]
+
+    expect(isDeepRef(nestedArray)).toBe(false)
+  })
+  it('should return false for an object that has already been visited and does not contain a Ref', () => {
+    const item = { parent: null as any }
+    const parentItem = { children: [item] }
+    item.parent = parentItem
+
+    expect(isDeepRef(item)).toBe(false)
+  })
+  it('should return true for an object that has already been visited and contains a Ref', () => {
+    const item = { parent: ref<any>(null) }
+    const parentItem = { children: [ref(item)] }
+    item.parent.value = parentItem
+
+    expect(isDeepRef(item)).toBe(true)
+  })
+  it('should return false for an object with a dynamic circular reference', () => {
+    const testObject = {}
+    Object.defineProperty(testObject, 'circularReference', {
+      get: function () {
+        delete this.circularReference
+        this.circularReference = testObject
+        return this.circularReference
+      }
+    })
+    expect(() => isDeepRef(testObject)).not.toThrow()
+    expect(isDeepRef(testObject)).toBe(false)
+  })
+})
diff --git a/tests/mount.spec.ts b/tests/mount.spec.ts
index ad0b03f..dc6d715 100644
--- a/tests/mount.spec.ts
+++ b/tests/mount.spec.ts
@@ -4,6 +4,7 @@ import { mount } from '../src'
 import DefinePropsAndDefineEmits from './components/DefinePropsAndDefineEmits.vue'
 import WithDeepRef from './components/WithDeepRef.vue'
 import HelloFromVitestPlayground from './components/HelloFromVitestPlayground.vue'
+import Hello from './components/Hello.vue'
 
 describe('mount: general tests', () => {
   it('correctly handles component, throwing on mount', () => {
@@ -70,4 +71,24 @@ describe('mount: general tests', () => {
     expect(wrapper.get('#oneLayerCountArrayObjectValue').text()).toBe('7')
     expect(wrapper.get('#oneLayerCountObjectMatrixValue').text()).toBe('8')
   })
+  it('circular references in non-ref props do not cause a stack overflow', () => {
+    const item = { id: 1, parent: null as any }
+    const parentItem = { children: [item] }
+    item.parent = parentItem
+
+    const wrapper = mount(Hello, {
+      props: { msg: 'Hello world', item: item }
+    })
+    expect(wrapper.text()).toContain('Hello world')
+  })
+  it('circular references in ref props do not cause a stack overflow', () => {
+    const item = { id: 1, parent: ref<any>(null) }
+    const parentItem = { children: [ref(item)] }
+    item.parent.value = parentItem
+
+    const wrapper = mount(Hello, {
+      props: { msg: 'Hello world', item: item }
+    })
+    expect(wrapper.text()).toContain('Hello world')
+  })
 })