Skip to content

Commit

Permalink
fix(kit): avoid circular vue instance ref when encoding state (#429)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexzhang1030 authored Jun 14, 2024
1 parent c0df970 commit 7c32103
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 16 deletions.
12 changes: 10 additions & 2 deletions packages/devtools-kit/src/core/component/state/replacer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { getBigIntDetails, getComponentDefinitionDetails, getDateDetails, getFun
import { isVueInstance } from './is'
import { sanitize } from './util'

export function stringifyReplacer(key: string) {
export type Replacer = (this: any, key: string | number, value: any, depth?: number, seenInstance?: Map</* instance */any, /* depth */number>) => any

export function stringifyReplacer(key: string | number, _value: any, depth?: number, seenInstance?: Map<any, number>) {
// fix vue warn for compilerOptions passing-options-to-vuecompiler-sfc
// @TODO: need to check if it will cause any other issues
if (key === 'compilerOptions')
Expand Down Expand Up @@ -75,7 +77,13 @@ export function stringifyReplacer(key: string) {
return getRouterDetails(val)
}
else if (isVueInstance(val as Record<string, unknown>)) {
return getInstanceDetails(val)
const componentVal = getInstanceDetails(val)
const parentInstanceDepth = seenInstance?.get(val)
if (parentInstanceDepth && parentInstanceDepth < depth!) {
return `[[CircularRef]] <${componentVal._custom.displayText}>`
}
seenInstance?.set(val, depth!)
return componentVal
}
// @ts-expect-error skip type check
else if (typeof val.render === 'function') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`encode 1`] = `" [{"_":1,"__isVue":23,"a":24,"b":25},{"ctx":2,"vnode":4,"type":14,"appContext":16,"setupState":18,"attrs":19,"provides":20,"injects":21,"refs":22},{"props":3},{},[5],{"_custom":6},{"type":7,"id":8,"displayText":9,"tooltipText":10,"value":11,"fields":12},"component","__vue_devtool_undefined__","Anonymous Component","Component instance","__vue_devtool_undefined__",{"abstract":13},true,{"props":15},[],{"mixins":17},[],{},{},{},{},{},true,1,{"state":26},[27,31],{"type":28,"key":29,"value":30},"provided","$currentInstance","[[CircularRef]] <Anonymous Component>",{"type":32,"key":33,"value":34},"provided","$currentInstance2","[[CircularRef]] <Anonymous Component>"]"`;
45 changes: 45 additions & 0 deletions packages/devtools-kit/src/shared/__test__/transfer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { stringifyReplacer } from '../../core/component/state/replacer'
import { stringifyStrictCircularAutoChunks } from '../transfer'

it('encode', () => {
const vueInstanceLike = {
_: {
ctx: {
props: {},
},
vnode: [] as any[],
type: {
props: [],
},
appContext: {
mixins: [],
},
setupState: {},
attrs: {},
provides: {},
injects: {},
refs: {},
},
__isVue: true,
a: 1,
b: {
state: [] as any[],
},
}

vueInstanceLike.b.state.push({
type: 'provided',
key: '$currentInstance',
value: vueInstanceLike,
})

vueInstanceLike.b.state.push({
type: 'provided',
key: '$currentInstance2',
value: vueInstanceLike,
})

vueInstanceLike._.vnode.push(vueInstanceLike)

expect(stringifyStrictCircularAutoChunks(vueInstanceLike, stringifyReplacer)).toMatchSnapshot()
})
68 changes: 54 additions & 14 deletions packages/devtools-kit/src/shared/transfer.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,87 @@
import { isVueInstance } from '../core/component/state/is'
import { Replacer } from '../core/component/state/replacer'

const MAX_SERIALIZED_SIZE = 2 * 1024 * 1024 // 2MB

function encode(data: Record<string, unknown>, replacer: ((this: any, key: string, value: any) => any) | null, list: unknown[], seen: Map<unknown, number>) {
let stored, key, value, i, l
function isObject(_data: unknown, proto: string): _data is Record<string, unknown> {
return proto === '[object Object]'
}

function isArray(_data: unknown, proto: string): _data is unknown[] {
return proto === '[object Array]'
}

/**
* This function is used to serialize object with handling circular references.
*
* ```ts
* const obj = { a: 1, b: { c: 2 }, d: obj }
* const result = stringifyCircularAutoChunks(obj) // call `encode` inside
* console.log(result) // [{"a":1,"b":2,"d":0},1,{"c":4},2]
* ```
*
* Each object is stored in a list and the index is used to reference the object.
* With seen map, we can check if the object is already stored in the list to avoid circular references.
*
* Note: here we have a special case for Vue instance.
* We check if a vue instance includes itself in its properties and skip it
* by using `seenVueInstance` and `depth` to avoid infinite loop.
*/
function encode(data: unknown, replacer: Replacer | null, list: unknown[], seen: Map<unknown, number>, depth = 0, seenVueInstance = new Map<any, number>()): number {
let stored: Record<string, number> | number[]
let key: string
let value: unknown
let i: number
let l: number

const seenIndex = seen.get(data)
if (seenIndex != null)
return seenIndex

const index = list.length
const proto = Object.prototype.toString.call(data)
if (proto === '[object Object]') {
if (isObject(data, proto)) {
stored = {}
seen.set(data, index)
list.push(stored)
const keys = Object.keys(data)
for (i = 0, l = keys.length; i < l; i++) {
key = keys[i]
value = data[key]
const isVm = value != null && isObject(value, Object.prototype.toString.call(data)) && isVueInstance(value)
try {
// fix vue warn for compilerOptions passing-options-to-vuecompiler-sfc
// @TODO: need to check if it will cause any other issues
if (key === 'compilerOptions')
return
value = data[key]
if (replacer)
value = replacer.call(data, key, value)
return index
if (replacer) {
value = replacer.call(data, key, value, depth, seenVueInstance)
}
}
catch (e) {
value = e
}
stored[key] = encode(value, replacer, list, seen)
stored[key] = encode(value, replacer, list, seen, depth + 1, seenVueInstance)
// delete vue instance if its properties have been processed
if (isVm) {
seenVueInstance.delete(value)
}
}
}
else if (proto === '[object Array]') {
else if (isArray(data, proto)) {
stored = []
seen.set(data, index)
list.push(stored)
for (i = 0, l = data.length; i < l; i++) {
try {
value = data[i]
if (replacer)
value = replacer.call(data, i, value)
value = replacer.call(data, i, value, depth, seenVueInstance)
}
catch (e) {
value = e
}
stored[i] = encode(value, replacer, list, seen)
stored[i] = encode(value, replacer, list, seen, depth + 1, seenVueInstance)
}
}
else {
Expand Down Expand Up @@ -79,14 +117,16 @@ function decode(list: unknown[] | string, reviver: ((this: any, key: string, val
}
}

export function stringifyCircularAutoChunks(data: Record<string, unknown>, replacer: ((this: any, key: string, value: any) => any) | null = null, space: number | null = null) {
export function stringifyCircularAutoChunks(data: Record<string, unknown>, replacer: Replacer | null = null, space: number | null = null) {
let result: string
try {
// no circular references, JSON.stringify can handle this
result = arguments.length === 1
? JSON.stringify(data)
: JSON.stringify(data, replacer!, space!)
: JSON.stringify(data, (k, v) => replacer?.(k, v), space!)
}
catch (e) {
// handle circular references
result = stringifyStrictCircularAutoChunks(data, replacer!, space!)
}
if (result.length > MAX_SERIALIZED_SIZE) {
Expand All @@ -100,7 +140,7 @@ export function stringifyCircularAutoChunks(data: Record<string, unknown>, repla
return result
}

export function stringifyStrictCircularAutoChunks(data: Record<string, unknown>, replacer: ((this: any, key: string, value: any) => any) | null = null, space: number | null = null) {
export function stringifyStrictCircularAutoChunks(data: Record<string, unknown>, replacer: Replacer | null = null, space: number | null = null) {
const list = []
encode(data, replacer, list, new Map())
return space
Expand Down
5 changes: 5 additions & 0 deletions packages/playground/basic/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ const routes: RouteRecordRaw[] = [
component: VeeValidate,
name: 'vee-validate',
},
{
path: '/circular-state',
component: () => import('./pages/CircularState.vue'),
name: 'circular-state',
},
]

const router = createRouter({
Expand Down
29 changes: 29 additions & 0 deletions packages/playground/basic/src/pages/CircularState.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script lang="ts">
const getCircularState = () => {
const obj = {
a: 1,
b: 2,
}
// @ts-expect-error - Circular reference
obj.c = obj
return obj
}
export default {
provide() {
return {
$currentInstance: this,
$currentInstance2: this,
}
},
data() {
return {
circularState: getCircularState(),
}
},
}
</script>

<template>
<div />
</template>

0 comments on commit 7c32103

Please sign in to comment.