diff --git a/packages/devtools/client/components/StateEditor.vue b/packages/devtools/client/components/StateEditor.vue index bb5ed13cf0..d7b7990350 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, measureSize } from '~/composables/payload-analysis' const props = defineProps<{ name?: string @@ -25,6 +26,13 @@ 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(() => props.state === undefined + ? 0 + : measureSize(props.state)) + +const isLargeItem = computed(() => stateSize.value > 10 * 1024) // > 10KB + function isPrimitive(value: any): boolean { return [ 'number', @@ -95,6 +103,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..73ba0b482f --- /dev/null +++ b/packages/devtools/client/composables/payload-analysis.ts @@ -0,0 +1,164 @@ +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 + +const encoder = new TextEncoder() + +function byteLength(str: string): number { + return encoder.encode(str).length +} + +/** + * 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 + + 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 { + 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[] = [] + + // 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, + }) + } + } + + // 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: 'Other', + size: othersSize, + percentage: 0, + }) + } + + const totalSize = breakdown.reduce((sum, item) => sum + item.size, 0) + + 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..ab61eb1abf 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,82 @@ async function refreshData(keys?: string[]) { + +