Skip to content

Commit

Permalink
fix: circular references in props cause maximum call stack size exceeded
Browse files Browse the repository at this point in the history
Fixes #2370
  • Loading branch information
Evobaso-J authored Mar 20, 2024
1 parent d5e5064 commit 362d04b
Show file tree
Hide file tree
Showing 5 changed files with 142 additions and 16 deletions.
2 changes: 1 addition & 1 deletion src/createInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { MountingOptions, Slot } from './types'
import {
getComponentsFromStubs,
getDirectivesFromStubs,
isDeepRef,
isFunctionalComponent,
isObject,
isObjectComponent,
Expand All @@ -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',
Expand Down
17 changes: 2 additions & 15 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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))
36 changes: 36 additions & 0 deletions src/utils/isDeepRef.ts
Original file line number Diff line number Diff line change
@@ -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)
}
82 changes: 82 additions & 0 deletions tests/isDeepRef.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
21 changes: 21 additions & 0 deletions tests/mount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})

0 comments on commit 362d04b

Please sign in to comment.