Skip to content

Commit 362d04b

Browse files
authored
fix: circular references in props cause maximum call stack size exceeded
Fixes #2370
1 parent d5e5064 commit 362d04b

File tree

5 files changed

+142
-16
lines changed

5 files changed

+142
-16
lines changed

src/createInstance.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { MountingOptions, Slot } from './types'
1616
import {
1717
getComponentsFromStubs,
1818
getDirectivesFromStubs,
19-
isDeepRef,
2019
isFunctionalComponent,
2120
isObject,
2221
isObjectComponent,
@@ -36,6 +35,7 @@ import {
3635
CreateStubComponentsTransformerConfig
3736
} from './vnodeTransformers/stubComponentsTransformer'
3837
import { createStubDirectivesTransformer } from './vnodeTransformers/stubDirectivesTransformer'
38+
import { isDeepRef } from './utils/isDeepRef'
3939

4040
const MOUNT_OPTIONS: ReadonlyArray<keyof MountingOptions<any>> = [
4141
'attachTo',

src/utils.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { DeepRef, GlobalMountOptions, RefSelector, Stub, Stubs } from './types'
1+
import { GlobalMountOptions, RefSelector, Stub, Stubs } from './types'
22
import {
33
Component,
44
ComponentOptions,
55
ComponentPublicInstance,
66
ConcreteComponent,
77
Directive,
8-
FunctionalComponent,
9-
isRef
8+
FunctionalComponent
109
} from 'vue'
1110
import { config } from './config'
1211

@@ -253,15 +252,3 @@ export const getGlobalThis = (): any => {
253252
: {})
254253
)
255254
}
256-
257-
/**
258-
* Checks if the given value is a DeepRef.
259-
*
260-
* For both arrays and objects, it will recursively check
261-
* if any of their values is a Ref.
262-
*
263-
* @param {DeepRef<T> | unknown} r - The value to check.
264-
* @returns {boolean} Returns true if the value is a DeepRef, false otherwise.
265-
*/
266-
export const isDeepRef = <T>(r: DeepRef<T> | unknown): r is DeepRef<T> =>
267-
isRef(r) || (isObject(r) && Object.values(r).some(isDeepRef))

src/utils/isDeepRef.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { isObject } from '../utils'
2+
import type { DeepRef } from '../types'
3+
import { isRef } from 'vue'
4+
5+
/**
6+
* Implementation details of isDeepRef to avoid circular dependencies.
7+
* It keeps track of visited objects to avoid infinite recursion.
8+
*
9+
* @param r The value to check for a Ref.
10+
* @param visitedObjects a weak map to keep track of visited objects and avoid infinite recursion
11+
* @returns returns true if the value is a Ref, false otherwise
12+
*/
13+
const deeplyCheckForRef = <T>(
14+
r: DeepRef<T> | unknown,
15+
visitedObjects: WeakMap<object, boolean>
16+
): r is DeepRef<T> => {
17+
if (isRef(r)) return true
18+
if (!isObject(r)) return false
19+
if (visitedObjects.has(r)) return false
20+
visitedObjects.set(r, true)
21+
return Object.values(r).some((val) => deeplyCheckForRef(val, visitedObjects))
22+
}
23+
24+
/**
25+
* Checks if the given value is a DeepRef.
26+
*
27+
* For both arrays and objects, it will recursively check
28+
* if any of their values is a Ref.
29+
*
30+
* @param {DeepRef<T> | unknown} r - The value to check.
31+
* @returns {boolean} Returns true if the value is a DeepRef, false otherwise.
32+
*/
33+
export const isDeepRef = <T>(r: DeepRef<T> | unknown): r is DeepRef<T> => {
34+
const visitedObjects = new WeakMap()
35+
return deeplyCheckForRef(r, visitedObjects)
36+
}

tests/isDeepRef.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { isDeepRef } from '../src/utils/isDeepRef'
2+
import { describe, expect, it } from 'vitest'
3+
import { ref } from 'vue'
4+
5+
describe('isDeepRef', () => {
6+
it('should return true for a Ref value', () => {
7+
const testRef = ref(1)
8+
9+
expect(isDeepRef(testRef)).toBe(true)
10+
})
11+
it('should return false for a non-object, non-Ref value', () => {
12+
const nonObject = 1
13+
14+
expect(isDeepRef(nonObject)).toBe(false)
15+
})
16+
it('should return true for an object with a Ref value', () => {
17+
const testObject = { ref: ref(1) }
18+
19+
expect(isDeepRef(testObject)).toBe(true)
20+
})
21+
it('should return false for an object without a Ref value', () => {
22+
const testObject = { nonRef: 1 }
23+
24+
expect(isDeepRef(testObject)).toBe(false)
25+
})
26+
it('should return true for an array with a Ref value', () => {
27+
const arrayWithRef = [ref(1)]
28+
29+
expect(isDeepRef(arrayWithRef)).toBe(true)
30+
})
31+
it('should return false for an array without a Ref value', () => {
32+
const arrayWithoutRef = [1]
33+
34+
expect(isDeepRef(arrayWithoutRef)).toBe(false)
35+
})
36+
it('should return true for a nested object with a Ref value', () => {
37+
const nestedObject = { nested: { ref: ref(1) } }
38+
39+
expect(isDeepRef(nestedObject)).toBe(true)
40+
})
41+
it('should return false for a nested object without a Ref value', () => {
42+
const nestedObject = { nested: { nonRef: 1 } }
43+
44+
expect(isDeepRef(nestedObject)).toBe(false)
45+
})
46+
it('should return true for a nested array with a Ref value', () => {
47+
const nestedArray = [[ref(1)]]
48+
49+
expect(isDeepRef(nestedArray)).toBe(true)
50+
})
51+
it('should return false for a nested array without a Ref value', () => {
52+
const nestedArray = [[1]]
53+
54+
expect(isDeepRef(nestedArray)).toBe(false)
55+
})
56+
it('should return false for an object that has already been visited and does not contain a Ref', () => {
57+
const item = { parent: null as any }
58+
const parentItem = { children: [item] }
59+
item.parent = parentItem
60+
61+
expect(isDeepRef(item)).toBe(false)
62+
})
63+
it('should return true for an object that has already been visited and contains a Ref', () => {
64+
const item = { parent: ref<any>(null) }
65+
const parentItem = { children: [ref(item)] }
66+
item.parent.value = parentItem
67+
68+
expect(isDeepRef(item)).toBe(true)
69+
})
70+
it('should return false for an object with a dynamic circular reference', () => {
71+
const testObject = {}
72+
Object.defineProperty(testObject, 'circularReference', {
73+
get: function () {
74+
delete this.circularReference
75+
this.circularReference = testObject
76+
return this.circularReference
77+
}
78+
})
79+
expect(() => isDeepRef(testObject)).not.toThrow()
80+
expect(isDeepRef(testObject)).toBe(false)
81+
})
82+
})

tests/mount.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mount } from '../src'
44
import DefinePropsAndDefineEmits from './components/DefinePropsAndDefineEmits.vue'
55
import WithDeepRef from './components/WithDeepRef.vue'
66
import HelloFromVitestPlayground from './components/HelloFromVitestPlayground.vue'
7+
import Hello from './components/Hello.vue'
78

89
describe('mount: general tests', () => {
910
it('correctly handles component, throwing on mount', () => {
@@ -70,4 +71,24 @@ describe('mount: general tests', () => {
7071
expect(wrapper.get('#oneLayerCountArrayObjectValue').text()).toBe('7')
7172
expect(wrapper.get('#oneLayerCountObjectMatrixValue').text()).toBe('8')
7273
})
74+
it('circular references in non-ref props do not cause a stack overflow', () => {
75+
const item = { id: 1, parent: null as any }
76+
const parentItem = { children: [item] }
77+
item.parent = parentItem
78+
79+
const wrapper = mount(Hello, {
80+
props: { msg: 'Hello world', item: item }
81+
})
82+
expect(wrapper.text()).toContain('Hello world')
83+
})
84+
it('circular references in ref props do not cause a stack overflow', () => {
85+
const item = { id: 1, parent: ref<any>(null) }
86+
const parentItem = { children: [ref(item)] }
87+
item.parent.value = parentItem
88+
89+
const wrapper = mount(Hello, {
90+
props: { msg: 'Hello world', item: item }
91+
})
92+
expect(wrapper.text()).toContain('Hello world')
93+
})
7394
})

0 commit comments

Comments
 (0)