Skip to content

Commit 0624919

Browse files
antfubotantfu
authored andcommitted
fix(devtools): prevent StateEditor from freezing the page on live state (#1058)
1 parent 0ceaa45 commit 0624919

1 file changed

Lines changed: 32 additions & 34 deletions

File tree

‎packages/devtools/client/components/StateEditor.vue‎

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<script setup lang="ts">
2-
import type { watchPausable } from '@vueuse/core'
3-
import { useVModel } from '@vueuse/core'
2+
import { useVModel, watchDebounced } from '@vueuse/core'
43
import JsonEditorVue from 'json-editor-vue'
5-
import { nextTick, onMounted, shallowRef, watch } from 'vue'
4+
import { structuredClone } from 'structured-clone-es'
5+
import { computed, onMounted, shallowRef } from 'vue'
66
import { getColorMode } from '~/composables/client'
77
88
const props = defineProps<{
@@ -22,56 +22,54 @@ const colorMode = getColorMode()
2222
const proxy = shallowRef()
2323
const error = shallowRef()
2424
25+
// A named editor is collapsible; only the visible ones need a fresh snapshot.
26+
const isVisible = computed(() => !props.name || !!isOpen.value)
27+
2528
function isPrimitive(value: any): boolean {
2629
return ['number', 'bigint', 'string', 'boolean'].includes(typeof value)
2730
}
2831
32+
// Produce a plain, non-reactive snapshot of the state for the JSON editor.
33+
//
34+
// This must NEVER mutate `props.state`: it is the live reactive Nuxt payload /
35+
// app config / `useState` data. The previous implementation deep-synced the
36+
// state back into itself under a `{ deep: true }` watcher, so any array or
37+
// nested object endlessly re-triggered the watcher and froze the whole page
38+
// (nuxt/devtools#972). Re-cloning into a detached `proxy` avoids that entirely.
39+
//
40+
// `structured-clone-es` is used instead of `JSON.parse(JSON.stringify())`: it
41+
// detaches from Vue reactivity, tolerates circular references (rather than
42+
// throwing), and preserves richer types (Map/Set/Date/…). `lossy: true` drops
43+
// functions/symbols instead of throwing on them.
2944
function clone() {
3045
error.value = undefined
46+
if (!isVisible.value)
47+
return
3148
try {
3249
proxy.value = isPrimitive(props.state)
3350
? props.state
34-
: JSON.parse(JSON.stringify(props.state || {}))
51+
: structuredClone(props.state ?? {}, { lossy: true })
3552
}
3653
catch (e) {
3754
console.error(e)
3855
error.value = e
3956
}
4057
}
4158
42-
let watcher: ReturnType<typeof watchPausable> | undefined
43-
44-
onMounted(() => {
45-
clone()
46-
47-
watch(
48-
() => [props.revision, props.state],
49-
([_, state]) => {
50-
if (!isPrimitive(state))
51-
deepSync(state, props.state)
52-
else
53-
proxy.value = props.state
54-
},
55-
{ deep: true },
56-
)
57-
})
59+
onMounted(clone)
5860
59-
function deepSync(from: any, to: any) {
60-
for (const key in from) {
61-
if (Array.isArray(from[key]))
62-
to[key] = from[key].slice()
63-
else if (typeof from[key] === 'object' && from[key] !== null)
64-
deepSync(from[key], to[key])
65-
else
66-
to[key] = from[key]
67-
}
68-
}
61+
// `revision` is bumped by DevTools whenever host reactivity updates, so it is a
62+
// sufficient change signal — we don't need (and must not use) a deep watcher on
63+
// the potentially huge state object. Debounced to avoid thrashing on rapid
64+
// updates.
65+
watchDebounced(
66+
() => [props.revision, props.state, isVisible.value],
67+
clone,
68+
{ debounce: 100, maxWait: 500 },
69+
)
6970
70-
async function refresh() {
71-
watcher?.pause()
71+
function refresh() {
7272
clone()
73-
await nextTick()
74-
watcher?.resume()
7573
}
7674
</script>
7775

0 commit comments

Comments
 (0)