Skip to content

Commit 860c131

Browse files
rynx-ccantfubot
andauthored
feat(devtools): show payload size warnings and breakdown in Payload tab (#1159)
Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 660d8d0 commit 860c131

3 files changed

Lines changed: 419 additions & 0 deletions

File tree

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import JsonEditorVue from 'json-editor-vue'
44
import { structuredClone } from 'structured-clone-es'
55
import { computed, onMounted, shallowRef } from 'vue'
66
import { getColorMode } from '~/composables/client'
7+
import { formatBytes, measureSize } from '~/composables/payload-analysis'
78
89
const props = defineProps<{
910
name?: string
@@ -25,6 +26,13 @@ const error = shallowRef()
2526
// A named editor is collapsible; only the visible ones need a fresh snapshot.
2627
const isVisible = computed(() => !props.name || !!isOpen.value)
2728
29+
// Calculate size of the state for display
30+
const stateSize = computed(() => props.state === undefined
31+
? 0
32+
: measureSize(props.state))
33+
34+
const isLargeItem = computed(() => stateSize.value > 10 * 1024) // > 10KB
35+
2836
function isPrimitive(value: any): boolean {
2937
return [
3038
'number',
@@ -95,6 +103,14 @@ function refresh() {
95103
:class="isOpen ? 'bg-[#8881] rounded-t' : 'rounded hover:bg-active'"
96104
>{{ name }}</code>
97105
</button>
106+
<span
107+
v-if="name && stateSize > 0"
108+
class="state-size-badge"
109+
:class="{ 'state-size-large': isLargeItem }"
110+
:title="`Size: ${formatBytes(stateSize)}`"
111+
>
112+
{{ formatBytes(stateSize) }}
113+
</span>
98114
<slot name="actions" v-bind="{ isOpen, name, state }" />
99115
<template v-if="isOpen">
100116
<NButton v-tooltip.bottom="'Refresh View'" title="Refresh View" icon="carbon-renew" :border="false" @click="refresh" />
@@ -127,3 +143,19 @@ function refresh() {
127143
</template>
128144
</div>
129145
</template>
146+
147+
<style scoped>
148+
.state-size-badge {
149+
font-size: 0.75rem;
150+
padding: 0.125rem 0.5rem;
151+
border-radius: 0.25rem;
152+
background-color: rgba(59, 130, 246, 0.1);
153+
color: rgb(59, 130, 246);
154+
font-family: monospace;
155+
font-weight: 500;
156+
}
157+
.state-size-large {
158+
background-color: rgba(251, 191, 36, 0.1);
159+
color: rgb(251, 191, 36);
160+
}
161+
</style>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { computed } from 'vue'
2+
import { useClient } from './client'
3+
4+
export interface PayloadBreakdown {
5+
key: string
6+
size: number
7+
percentage: number
8+
origin?: 'server' | 'client' | 'mixed'
9+
}
10+
11+
export interface PayloadAnalysis {
12+
totalSize: number
13+
breakdown: PayloadBreakdown[]
14+
isLarge: boolean
15+
isCritical: boolean
16+
}
17+
18+
const LARGE_PAYLOAD_THRESHOLD = 100 * 1024 // 100 KB
19+
const CRITICAL_PAYLOAD_THRESHOLD = 500 * 1024 // 500 KB
20+
21+
const encoder = new TextEncoder()
22+
23+
function byteLength(str: string): number {
24+
return encoder.encode(str).length
25+
}
26+
27+
/**
28+
* Estimate the serialized size (in bytes) of a payload value.
29+
*
30+
* Unlike `JSON.stringify`, this accounts for the rich types Nuxt's payload
31+
* serializer (devalue) supports (`Map`, `Set`, `Date`, `RegExp`, `BigInt`, ...)
32+
* and never throws on circular references (repeated references are counted once,
33+
* as devalue deduplicates them).
34+
*/
35+
export function measureSize(value: unknown, seen = new WeakSet<object>()): number {
36+
switch (typeof value) {
37+
case 'undefined':
38+
return 9 // `undefined`
39+
case 'string':
40+
return byteLength(JSON.stringify(value))
41+
case 'number':
42+
case 'boolean':
43+
return String(value).length
44+
case 'bigint':
45+
return value.toString().length + 1
46+
case 'symbol':
47+
case 'function':
48+
return 0
49+
}
50+
51+
if (value === null)
52+
return 4
53+
54+
const obj = value as object
55+
if (seen.has(obj))
56+
return 1 // reference to an already serialized value
57+
seen.add(obj)
58+
59+
if (obj instanceof Date)
60+
return 26
61+
if (obj instanceof RegExp)
62+
return byteLength(JSON.stringify(obj.source)) + obj.flags.length + 2
63+
64+
let size = 2
65+
if (obj instanceof Map) {
66+
for (const [k, v] of obj)
67+
size += measureSize(k, seen) + measureSize(v, seen) + 2
68+
return size
69+
}
70+
if (obj instanceof Set || Array.isArray(obj)) {
71+
for (const item of obj)
72+
size += measureSize(item, seen) + 1
73+
return size
74+
}
75+
for (const [k, v] of Object.entries(obj))
76+
size += byteLength(JSON.stringify(k)) + measureSize(v, seen) + 2
77+
return size
78+
}
79+
80+
export function formatBytes(bytes: number): string {
81+
if (bytes === 0)
82+
return '0 B'
83+
if (bytes < 1024)
84+
return `${bytes} B`
85+
if (bytes < 1024 * 1024)
86+
return `${(bytes / 1024).toFixed(2)} KB`
87+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
88+
}
89+
90+
export function usePayloadAnalysis() {
91+
const client = useClient()
92+
93+
const analysis = computed<PayloadAnalysis | null>(() => {
94+
const payload = client.value?.nuxt.payload
95+
if (!payload)
96+
return null
97+
98+
const breakdown: PayloadBreakdown[] = []
99+
100+
// Without SSR, everything in the payload was produced on the client.
101+
// With SSR, entries may come from the server render or from later
102+
// client-side fetches/updates (e.g. `useAsyncData({ server: false })`).
103+
const origin: PayloadBreakdown['origin'] = payload.serverRendered === false
104+
? 'client'
105+
: 'mixed'
106+
107+
const categories: [field: string, label: string][] = [
108+
['state', 'State (useState)'],
109+
['data', 'Data (useAsyncData)'],
110+
['functions', 'Functions'],
111+
]
112+
113+
for (const [field, label] of categories) {
114+
const value = (payload as Record<string, unknown>)[field]
115+
if (value && typeof value === 'object') {
116+
breakdown.push({
117+
key: label,
118+
size: measureSize(value),
119+
percentage: 0,
120+
origin,
121+
})
122+
}
123+
}
124+
125+
// Count all remaining payload fields so the total reflects the whole payload
126+
const known = new Set(categories.map(([field]) => field))
127+
const others = Object.fromEntries(
128+
Object.entries(payload).filter(([key]) => !known.has(key)),
129+
)
130+
const othersSize = Object.keys(others).length
131+
? measureSize(others)
132+
: 0
133+
if (othersSize > 0) {
134+
breakdown.push({
135+
key: 'Other',
136+
size: othersSize,
137+
percentage: 0,
138+
})
139+
}
140+
141+
const totalSize = breakdown.reduce((sum, item) => sum + item.size, 0)
142+
143+
breakdown.forEach((item) => {
144+
item.percentage = totalSize > 0
145+
? (item.size / totalSize) * 100
146+
: 0
147+
})
148+
149+
breakdown.sort((a, b) => b.size - a.size)
150+
151+
return {
152+
totalSize,
153+
breakdown,
154+
isLarge: totalSize > LARGE_PAYLOAD_THRESHOLD,
155+
isCritical: totalSize > CRITICAL_PAYLOAD_THRESHOLD,
156+
}
157+
})
158+
159+
return {
160+
analysis,
161+
LARGE_PAYLOAD_THRESHOLD,
162+
CRITICAL_PAYLOAD_THRESHOLD,
163+
}
164+
}

0 commit comments

Comments
 (0)