From 47a4028d4daebcddf946158a9cd335f6406ca8e5 Mon Sep 17 00:00:00 2001 From: rynx-cc Date: Mon, 2 Nov 2026 20:06:38 +0800 Subject: [PATCH 1/2] feat(devtools): add payload size warning and breakdown - Add payload size analysis with breakdown by category (state, data, functions) - Show visual warnings for large (>100KB) and critical (>500KB) payloads - Display size badges for individual state/data items - Show origin indicators (server/client/mixed) for data categories - Add visual breakdown bars showing relative sizes Closes #1007 --- .../client/components/StateEditor.vue | 42 ++++ .../client/composables/payload-analysis.ts | 124 ++++++++++ .../devtools/client/pages/modules/payload.vue | 222 ++++++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 packages/devtools/client/composables/payload-analysis.ts diff --git a/packages/devtools/client/components/StateEditor.vue b/packages/devtools/client/components/StateEditor.vue index bb5ed13cf0..c59281eef8 100644 --- a/packages/devtools/client/components/StateEditor.vue +++ b/packages/devtools/client/components/StateEditor.vue @@ -4,6 +4,7 @@ import JsonEditorVue from 'json-editor-vue' import { structuredClone } from 'structured-clone-es' import { computed, onMounted, shallowRef } from 'vue' import { getColorMode } from '~/composables/client' +import { formatBytes } from '~/composables/payload-analysis' const props = defineProps<{ name?: string @@ -25,6 +26,23 @@ const error = shallowRef() // A named editor is collapsible; only the visible ones need a fresh snapshot. const isVisible = computed(() => !props.name || !!isOpen.value) +// Calculate size of the state for display +const stateSize = computed(() => { + if (!props.state) + return 0 + try { + const str = JSON.stringify(props.state) + return str + ? new Blob([str]).size + : 0 + } + catch { + return 0 + } +}) + +const isLargeItem = computed(() => stateSize.value > 10 * 1024) // > 10KB + function isPrimitive(value: any): boolean { return [ 'number', @@ -95,6 +113,14 @@ function refresh() { :class="isOpen ? 'bg-[#8881] rounded-t' : 'rounded hover:bg-active'" >{{ name }} + + {{ formatBytes(stateSize) }} + + + diff --git a/packages/devtools/client/composables/payload-analysis.ts b/packages/devtools/client/composables/payload-analysis.ts new file mode 100644 index 0000000000..8b4704e8d0 --- /dev/null +++ b/packages/devtools/client/composables/payload-analysis.ts @@ -0,0 +1,124 @@ +import { computed } from 'vue' +import { useClient } from './client' + +export interface PayloadBreakdown { + key: string + size: number + percentage: number + origin?: 'server' | 'client' | 'mixed' +} + +export interface PayloadAnalysis { + totalSize: number + breakdown: PayloadBreakdown[] + isLarge: boolean + isCritical: boolean +} + +const LARGE_PAYLOAD_THRESHOLD = 100 * 1024 // 100 KB +const CRITICAL_PAYLOAD_THRESHOLD = 500 * 1024 // 500 KB + +function calculateSize(value: any): number { + if (value === null || value === undefined) + return 0 + try { + const str = JSON.stringify(value) + return str + ? new Blob([str]).size + : 0 + } + catch { + return estimateSize(value) + } +} + +function estimateSize(value: any): number { + if (typeof value === 'string') + return value.length * 2 + if (typeof value === 'number') + return 8 + if (typeof value === 'boolean') + return 4 + if (Array.isArray(value)) + return value.reduce((sum: number, item: any) => sum + estimateSize(item), 0) + if (typeof value === 'object') + return Object.values(value).reduce((sum: number, val: any) => sum + estimateSize(val), 0) + return 0 +} + +export function formatBytes(bytes: number): string { + if (bytes === 0) + return '0 B' + if (bytes < 1024) + return `${bytes} B` + if (bytes < 1024 * 1024) + return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +export function usePayloadAnalysis() { + const client = useClient() + + const analysis = computed(() => { + const payload = client.value?.nuxt.payload + if (!payload) + return null + + const breakdown: PayloadBreakdown[] = [] + let totalSize = 0 + + if (payload.state && typeof payload.state === 'object') { + const stateSize = calculateSize(payload.state) + breakdown.push({ + key: 'State (useState)', + size: stateSize, + percentage: 0, + origin: 'mixed', + }) + totalSize += stateSize + } + + if (payload.data && typeof payload.data === 'object') { + const dataSize = calculateSize(payload.data) + breakdown.push({ + key: 'Data (useAsyncData)', + size: dataSize, + percentage: 0, + origin: 'server', + }) + totalSize += dataSize + } + + if (payload.functions && typeof payload.functions === 'object') { + const functionsSize = calculateSize(payload.functions) + breakdown.push({ + key: 'Functions', + size: functionsSize, + percentage: 0, + origin: 'server', + }) + totalSize += functionsSize + } + + breakdown.forEach((item) => { + item.percentage = totalSize > 0 + ? (item.size / totalSize) * 100 + : 0 + }) + + breakdown.sort((a, b) => b.size - a.size) + + return { + totalSize, + breakdown, + isLarge: totalSize > LARGE_PAYLOAD_THRESHOLD, + isCritical: totalSize > CRITICAL_PAYLOAD_THRESHOLD, + } + }) + + return { + analysis, + LARGE_PAYLOAD_THRESHOLD, + CRITICAL_PAYLOAD_THRESHOLD, + } +} diff --git a/packages/devtools/client/pages/modules/payload.vue b/packages/devtools/client/pages/modules/payload.vue index b8664051f0..3aaaa828f9 100644 --- a/packages/devtools/client/pages/modules/payload.vue +++ b/packages/devtools/client/pages/modules/payload.vue @@ -2,6 +2,7 @@ import { computed } from 'vue' import { definePageMeta } from '#imports' import { useClient } from '~/composables/client' +import { formatBytes, usePayloadAnalysis } from '~/composables/payload-analysis' definePageMeta({ icon: 'carbon-data-set', @@ -17,6 +18,7 @@ definePageMeta({ const client = useClient() const payload = computed(() => client.value?.nuxt.payload) const revision = computed(() => client.value?.revision.value) +const { analysis } = usePayloadAnalysis() async function refreshData(keys?: string[]) { await client.value?.nuxt.hooks.callHookParallel('app:data:refresh', keys) @@ -25,6 +27,81 @@ async function refreshData(keys?: string[]) { + + From 17d71d4dc874e31407fdd0adcdaffee260020b4b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 4 Nov 2026 02:10:14 +0000 Subject: [PATCH 2/2] fix(devtools): address payload analysis review feedback - measure payload values with a cycle-safe estimator that supports Map/Set/Date/etc. - show size badges for falsy values (false, 0, '', null) - include all payload fields in total size via an 'Other' category - derive origin label from serverRendered instead of hard-coding 'server' --- .../client/components/StateEditor.vue | 18 +-- .../client/composables/payload-analysis.ts | 138 +++++++++++------- .../devtools/client/pages/modules/payload.vue | 1 + 3 files changed, 94 insertions(+), 63 deletions(-) diff --git a/packages/devtools/client/components/StateEditor.vue b/packages/devtools/client/components/StateEditor.vue index c59281eef8..d7b7990350 100644 --- a/packages/devtools/client/components/StateEditor.vue +++ b/packages/devtools/client/components/StateEditor.vue @@ -4,7 +4,7 @@ import JsonEditorVue from 'json-editor-vue' import { structuredClone } from 'structured-clone-es' import { computed, onMounted, shallowRef } from 'vue' import { getColorMode } from '~/composables/client' -import { formatBytes } from '~/composables/payload-analysis' +import { formatBytes, measureSize } from '~/composables/payload-analysis' const props = defineProps<{ name?: string @@ -27,19 +27,9 @@ const error = shallowRef() const isVisible = computed(() => !props.name || !!isOpen.value) // Calculate size of the state for display -const stateSize = computed(() => { - if (!props.state) - return 0 - try { - const str = JSON.stringify(props.state) - return str - ? new Blob([str]).size - : 0 - } - catch { - return 0 - } -}) +const stateSize = computed(() => props.state === undefined + ? 0 + : measureSize(props.state)) const isLargeItem = computed(() => stateSize.value > 10 * 1024) // > 10KB diff --git a/packages/devtools/client/composables/payload-analysis.ts b/packages/devtools/client/composables/payload-analysis.ts index 8b4704e8d0..73ba0b482f 100644 --- a/packages/devtools/client/composables/payload-analysis.ts +++ b/packages/devtools/client/composables/payload-analysis.ts @@ -18,32 +18,63 @@ export interface PayloadAnalysis { const LARGE_PAYLOAD_THRESHOLD = 100 * 1024 // 100 KB const CRITICAL_PAYLOAD_THRESHOLD = 500 * 1024 // 500 KB -function calculateSize(value: any): number { - if (value === null || value === undefined) - return 0 - try { - const str = JSON.stringify(value) - return str - ? new Blob([str]).size - : 0 - } - catch { - return estimateSize(value) - } +const encoder = new TextEncoder() + +function byteLength(str: string): number { + return encoder.encode(str).length } -function estimateSize(value: any): number { - if (typeof value === 'string') - return value.length * 2 - if (typeof value === 'number') - return 8 - if (typeof value === 'boolean') +/** + * Estimate the serialized size (in bytes) of a payload value. + * + * Unlike `JSON.stringify`, this accounts for the rich types Nuxt's payload + * serializer (devalue) supports (`Map`, `Set`, `Date`, `RegExp`, `BigInt`, ...) + * and never throws on circular references (repeated references are counted once, + * as devalue deduplicates them). + */ +export function measureSize(value: unknown, seen = new WeakSet()): number { + switch (typeof value) { + case 'undefined': + return 9 // `undefined` + case 'string': + return byteLength(JSON.stringify(value)) + case 'number': + case 'boolean': + return String(value).length + case 'bigint': + return value.toString().length + 1 + case 'symbol': + case 'function': + return 0 + } + + if (value === null) return 4 - if (Array.isArray(value)) - return value.reduce((sum: number, item: any) => sum + estimateSize(item), 0) - if (typeof value === 'object') - return Object.values(value).reduce((sum: number, val: any) => sum + estimateSize(val), 0) - return 0 + + const obj = value as object + if (seen.has(obj)) + return 1 // reference to an already serialized value + seen.add(obj) + + if (obj instanceof Date) + return 26 + if (obj instanceof RegExp) + return byteLength(JSON.stringify(obj.source)) + obj.flags.length + 2 + + let size = 2 + if (obj instanceof Map) { + for (const [k, v] of obj) + size += measureSize(k, seen) + measureSize(v, seen) + 2 + return size + } + if (obj instanceof Set || Array.isArray(obj)) { + for (const item of obj) + size += measureSize(item, seen) + 1 + return size + } + for (const [k, v] of Object.entries(obj)) + size += byteLength(JSON.stringify(k)) + measureSize(v, seen) + 2 + return size } export function formatBytes(bytes: number): string { @@ -65,40 +96,49 @@ export function usePayloadAnalysis() { return null const breakdown: PayloadBreakdown[] = [] - let totalSize = 0 - if (payload.state && typeof payload.state === 'object') { - const stateSize = calculateSize(payload.state) - breakdown.push({ - key: 'State (useState)', - size: stateSize, - percentage: 0, - origin: 'mixed', - }) - totalSize += stateSize + // Without SSR, everything in the payload was produced on the client. + // With SSR, entries may come from the server render or from later + // client-side fetches/updates (e.g. `useAsyncData({ server: false })`). + const origin: PayloadBreakdown['origin'] = payload.serverRendered === false + ? 'client' + : 'mixed' + + const categories: [field: string, label: string][] = [ + ['state', 'State (useState)'], + ['data', 'Data (useAsyncData)'], + ['functions', 'Functions'], + ] + + for (const [field, label] of categories) { + const value = (payload as Record)[field] + if (value && typeof value === 'object') { + breakdown.push({ + key: label, + size: measureSize(value), + percentage: 0, + origin, + }) + } } - if (payload.data && typeof payload.data === 'object') { - const dataSize = calculateSize(payload.data) + // Count all remaining payload fields so the total reflects the whole payload + const known = new Set(categories.map(([field]) => field)) + const others = Object.fromEntries( + Object.entries(payload).filter(([key]) => !known.has(key)), + ) + const othersSize = Object.keys(others).length + ? measureSize(others) + : 0 + if (othersSize > 0) { breakdown.push({ - key: 'Data (useAsyncData)', - size: dataSize, + key: 'Other', + size: othersSize, percentage: 0, - origin: 'server', }) - totalSize += dataSize } - if (payload.functions && typeof payload.functions === 'object') { - const functionsSize = calculateSize(payload.functions) - breakdown.push({ - key: 'Functions', - size: functionsSize, - percentage: 0, - origin: 'server', - }) - totalSize += functionsSize - } + const totalSize = breakdown.reduce((sum, item) => sum + item.size, 0) breakdown.forEach((item) => { item.percentage = totalSize > 0 diff --git a/packages/devtools/client/pages/modules/payload.vue b/packages/devtools/client/pages/modules/payload.vue index 3aaaa828f9..ab61eb1abf 100644 --- a/packages/devtools/client/pages/modules/payload.vue +++ b/packages/devtools/client/pages/modules/payload.vue @@ -73,6 +73,7 @@ async function refreshData(keys?: string[]) {