Skip to content

Commit 7c32103

Browse files
authoredJun 14, 2024··
fix(kit): avoid circular vue instance ref when encoding state (#429)
1 parent c0df970 commit 7c32103

File tree

6 files changed

+146
-16
lines changed

6 files changed

+146
-16
lines changed
 

‎packages/devtools-kit/src/core/component/state/replacer.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { getBigIntDetails, getComponentDefinitionDetails, getDateDetails, getFun
33
import { isVueInstance } from './is'
44
import { sanitize } from './util'
55

6-
export function stringifyReplacer(key: string) {
6+
export type Replacer = (this: any, key: string | number, value: any, depth?: number, seenInstance?: Map</* instance */any, /* depth */number>) => any
7+
8+
export function stringifyReplacer(key: string | number, _value: any, depth?: number, seenInstance?: Map<any, number>) {
79
// fix vue warn for compilerOptions passing-options-to-vuecompiler-sfc
810
// @TODO: need to check if it will cause any other issues
911
if (key === 'compilerOptions')
@@ -75,7 +77,13 @@ export function stringifyReplacer(key: string) {
7577
return getRouterDetails(val)
7678
}
7779
else if (isVueInstance(val as Record<string, unknown>)) {
78-
return getInstanceDetails(val)
80+
const componentVal = getInstanceDetails(val)
81+
const parentInstanceDepth = seenInstance?.get(val)
82+
if (parentInstanceDepth && parentInstanceDepth < depth!) {
83+
return `[[CircularRef]] <${componentVal._custom.displayText}>`
84+
}
85+
seenInstance?.set(val, depth!)
86+
return componentVal
7987
}
8088
// @ts-expect-error skip type check
8189
else if (typeof val.render === 'function') {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
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>"]"`;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { stringifyReplacer } from '../../core/component/state/replacer'
2+
import { stringifyStrictCircularAutoChunks } from '../transfer'
3+
4+
it('encode', () => {
5+
const vueInstanceLike = {
6+
_: {
7+
ctx: {
8+
props: {},
9+
},
10+
vnode: [] as any[],
11+
type: {
12+
props: [],
13+
},
14+
appContext: {
15+
mixins: [],
16+
},
17+
setupState: {},
18+
attrs: {},
19+
provides: {},
20+
injects: {},
21+
refs: {},
22+
},
23+
__isVue: true,
24+
a: 1,
25+
b: {
26+
state: [] as any[],
27+
},
28+
}
29+
30+
vueInstanceLike.b.state.push({
31+
type: 'provided',
32+
key: '$currentInstance',
33+
value: vueInstanceLike,
34+
})
35+
36+
vueInstanceLike.b.state.push({
37+
type: 'provided',
38+
key: '$currentInstance2',
39+
value: vueInstanceLike,
40+
})
41+
42+
vueInstanceLike._.vnode.push(vueInstanceLike)
43+
44+
expect(stringifyStrictCircularAutoChunks(vueInstanceLike, stringifyReplacer)).toMatchSnapshot()
45+
})

‎packages/devtools-kit/src/shared/transfer.ts

+54-14
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,87 @@
1+
import { isVueInstance } from '../core/component/state/is'
2+
import { Replacer } from '../core/component/state/replacer'
3+
14
const MAX_SERIALIZED_SIZE = 2 * 1024 * 1024 // 2MB
25

3-
function encode(data: Record<string, unknown>, replacer: ((this: any, key: string, value: any) => any) | null, list: unknown[], seen: Map<unknown, number>) {
4-
let stored, key, value, i, l
6+
function isObject(_data: unknown, proto: string): _data is Record<string, unknown> {
7+
return proto === '[object Object]'
8+
}
9+
10+
function isArray(_data: unknown, proto: string): _data is unknown[] {
11+
return proto === '[object Array]'
12+
}
13+
14+
/**
15+
* This function is used to serialize object with handling circular references.
16+
*
17+
* ```ts
18+
* const obj = { a: 1, b: { c: 2 }, d: obj }
19+
* const result = stringifyCircularAutoChunks(obj) // call `encode` inside
20+
* console.log(result) // [{"a":1,"b":2,"d":0},1,{"c":4},2]
21+
* ```
22+
*
23+
* Each object is stored in a list and the index is used to reference the object.
24+
* With seen map, we can check if the object is already stored in the list to avoid circular references.
25+
*
26+
* Note: here we have a special case for Vue instance.
27+
* We check if a vue instance includes itself in its properties and skip it
28+
* by using `seenVueInstance` and `depth` to avoid infinite loop.
29+
*/
30+
function encode(data: unknown, replacer: Replacer | null, list: unknown[], seen: Map<unknown, number>, depth = 0, seenVueInstance = new Map<any, number>()): number {
31+
let stored: Record<string, number> | number[]
32+
let key: string
33+
let value: unknown
34+
let i: number
35+
let l: number
36+
537
const seenIndex = seen.get(data)
638
if (seenIndex != null)
739
return seenIndex
840

941
const index = list.length
1042
const proto = Object.prototype.toString.call(data)
11-
if (proto === '[object Object]') {
43+
if (isObject(data, proto)) {
1244
stored = {}
1345
seen.set(data, index)
1446
list.push(stored)
1547
const keys = Object.keys(data)
1648
for (i = 0, l = keys.length; i < l; i++) {
1749
key = keys[i]
50+
value = data[key]
51+
const isVm = value != null && isObject(value, Object.prototype.toString.call(data)) && isVueInstance(value)
1852
try {
1953
// fix vue warn for compilerOptions passing-options-to-vuecompiler-sfc
2054
// @TODO: need to check if it will cause any other issues
2155
if (key === 'compilerOptions')
22-
return
23-
value = data[key]
24-
if (replacer)
25-
value = replacer.call(data, key, value)
56+
return index
57+
if (replacer) {
58+
value = replacer.call(data, key, value, depth, seenVueInstance)
59+
}
2660
}
2761
catch (e) {
2862
value = e
2963
}
30-
stored[key] = encode(value, replacer, list, seen)
64+
stored[key] = encode(value, replacer, list, seen, depth + 1, seenVueInstance)
65+
// delete vue instance if its properties have been processed
66+
if (isVm) {
67+
seenVueInstance.delete(value)
68+
}
3169
}
3270
}
33-
else if (proto === '[object Array]') {
71+
else if (isArray(data, proto)) {
3472
stored = []
3573
seen.set(data, index)
3674
list.push(stored)
3775
for (i = 0, l = data.length; i < l; i++) {
3876
try {
3977
value = data[i]
4078
if (replacer)
41-
value = replacer.call(data, i, value)
79+
value = replacer.call(data, i, value, depth, seenVueInstance)
4280
}
4381
catch (e) {
4482
value = e
4583
}
46-
stored[i] = encode(value, replacer, list, seen)
84+
stored[i] = encode(value, replacer, list, seen, depth + 1, seenVueInstance)
4785
}
4886
}
4987
else {
@@ -79,14 +117,16 @@ function decode(list: unknown[] | string, reviver: ((this: any, key: string, val
79117
}
80118
}
81119

82-
export function stringifyCircularAutoChunks(data: Record<string, unknown>, replacer: ((this: any, key: string, value: any) => any) | null = null, space: number | null = null) {
120+
export function stringifyCircularAutoChunks(data: Record<string, unknown>, replacer: Replacer | null = null, space: number | null = null) {
83121
let result: string
84122
try {
123+
// no circular references, JSON.stringify can handle this
85124
result = arguments.length === 1
86125
? JSON.stringify(data)
87-
: JSON.stringify(data, replacer!, space!)
126+
: JSON.stringify(data, (k, v) => replacer?.(k, v), space!)
88127
}
89128
catch (e) {
129+
// handle circular references
90130
result = stringifyStrictCircularAutoChunks(data, replacer!, space!)
91131
}
92132
if (result.length > MAX_SERIALIZED_SIZE) {
@@ -100,7 +140,7 @@ export function stringifyCircularAutoChunks(data: Record<string, unknown>, repla
100140
return result
101141
}
102142

103-
export function stringifyStrictCircularAutoChunks(data: Record<string, unknown>, replacer: ((this: any, key: string, value: any) => any) | null = null, space: number | null = null) {
143+
export function stringifyStrictCircularAutoChunks(data: Record<string, unknown>, replacer: Replacer | null = null, space: number | null = null) {
104144
const list = []
105145
encode(data, replacer, list, new Map())
106146
return space

‎packages/playground/basic/src/main.ts

+5
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ const routes: RouteRecordRaw[] = [
5050
component: VeeValidate,
5151
name: 'vee-validate',
5252
},
53+
{
54+
path: '/circular-state',
55+
component: () => import('./pages/CircularState.vue'),
56+
name: 'circular-state',
57+
},
5358
]
5459

5560
const router = createRouter({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script lang="ts">
2+
const getCircularState = () => {
3+
const obj = {
4+
a: 1,
5+
b: 2,
6+
}
7+
// @ts-expect-error - Circular reference
8+
obj.c = obj
9+
return obj
10+
}
11+
12+
export default {
13+
provide() {
14+
return {
15+
$currentInstance: this,
16+
$currentInstance2: this,
17+
}
18+
},
19+
data() {
20+
return {
21+
circularState: getCircularState(),
22+
}
23+
},
24+
}
25+
</script>
26+
27+
<template>
28+
<div />
29+
</template>

0 commit comments

Comments
 (0)
Please sign in to comment.