diff --git a/packages/agent/tests/pagination.test.ts b/packages/agent/tests/pagination.test.ts index c0dbd96a8..f88708f36 100644 --- a/packages/agent/tests/pagination.test.ts +++ b/packages/agent/tests/pagination.test.ts @@ -86,11 +86,12 @@ it.each(['array', 'object', 'map', 'set'] as const)( 'pages through nested %s values in order', async (kind) => { const values = Array.from({ length: 121 }, (_, i) => i) + // Keep the fixture's numeric values aligned with alphabetical object keys. const payload = kind === 'array' ? values : kind === 'object' - ? Object.fromEntries(values.map((i) => [`key${i}`, i])) + ? Object.fromEntries(values.map((i) => [`key${String(i).padStart(3, '0')}`, i])) : kind === 'map' ? new Map(values.map((i) => [`key${i}`, i])) : new Set(values) @@ -295,11 +296,12 @@ it.each(['array', 'object', 'string'] as const)( 'paginates cached setup computed %s values', async (kind) => { const values = Array.from({ length: 121 }, (_, i) => i) + // Keep the fixture's numeric values aligned with alphabetical object keys. const payload = kind === 'array' ? values : kind === 'object' - ? Object.fromEntries(values.map((i) => [`key${i}`, i])) + ? Object.fromEntries(values.map((i) => [`key${String(i).padStart(3, '0')}`, i])) : 'x'.repeat(6000) const getter = vi.fn(() => payload) const cached = computed(getter) diff --git a/packages/kit/src/codec/encode.ts b/packages/kit/src/codec/encode.ts index a7ec19310..506ff6697 100644 --- a/packages/kit/src/codec/encode.ts +++ b/packages/kit/src/codec/encode.ts @@ -178,6 +178,7 @@ function encodeUnsafe(value: unknown, state: EncodeState): EncodedValue { function encodeObject(value: object, handle: ValueHandle, state: EncodeState): EncodedValue { const keys = state.entryKeys ?? safeOwnEnumerableStringKeys(value) + if (!state.entryKeys) keys.sort() const previewKeys = keys.slice(state.entryOffset, state.entryOffset + state.maxEntries) return { kind: 'object', diff --git a/packages/kit/src/runtime/state/index.ts b/packages/kit/src/runtime/state/index.ts index 254024218..90a5b3fab 100644 --- a/packages/kit/src/runtime/state/index.ts +++ b/packages/kit/src/runtime/state/index.ts @@ -254,7 +254,7 @@ export class ComponentStateCollector { const setItems = value instanceof Set ? Array.from(value) : undefined const objectKeys = value && typeof value === 'object' && !Array.isArray(value) && !mapEntries && !setItems - ? safeOwnEnumerableStringKeys(value) + ? safeOwnEnumerableStringKeys(value).sort() : undefined const encodeOptions = { handles: this.handles, diff --git a/tests/unit/codec/encode-value.test.ts b/tests/unit/codec/encode-value.test.ts index 27a0e6728..58f50343e 100644 --- a/tests/unit/codec/encode-value.test.ts +++ b/tests/unit/codec/encode-value.test.ts @@ -56,11 +56,44 @@ describe('encodeValue', () => { expect(encoded).toMatchObject({ entries: 2, kind: 'object' }) if (encoded.kind !== 'object') return expect(encoded.preview).toEqual([ - { key: 'ok', value: { kind: 'number', value: 1 } }, { key: 'broken', value: { kind: 'error', message: 'getter exploded', name: 'Error' } }, + { key: 'ok', value: { kind: 'number', value: 1 } }, ]) }) + it('preserves an explicit key snapshot order without mutating it', () => { + const entryKeys = ['zebra', 'alpha', 'middle'] + const encoded = encodeValue( + { alpha: 1, middle: 2, zebra: 3 }, + { entryKeys, entryOffset: 1, maxEntries: 1 }, + ) + + expect(encoded).toMatchObject({ + kind: 'object', + preview: [{ key: 'alpha', value: { kind: 'number', value: 1 } }], + }) + expect(entryKeys).toEqual(['zebra', 'alpha', 'middle']) + }) + + it('preserves array indices and Map/Set insertion order', () => { + const values = [ + { value: ['zebra', 'alpha'], keys: ['0', '1'] }, + { + value: new Map([ + ['zebra', 1], + ['alpha', 2], + ]), + keys: ['zebra', 'alpha'], + }, + { value: new Set(['zebra', 'alpha']), keys: ['0', '1'] }, + ] + + for (const { value, keys } of values) { + const encoded = encodeValue(value) + expect('preview' in encoded && encoded.preview.map((entry) => entry.key)).toEqual(keys) + } + }) + it('never executes prototype accessors of class instances', () => { let calls = 0 class Model { diff --git a/tests/unit/runtime/state-value-order.test.ts b/tests/unit/runtime/state-value-order.test.ts new file mode 100644 index 000000000..9031ede01 --- /dev/null +++ b/tests/unit/runtime/state-value-order.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { createDevtoolsRuntime } from '../../../packages/kit/src/runtime/runtime' + +describe('object property pagination', () => { + it('keeps preview, show-more expansion, and cursor pages in one alphabetical order', () => { + const keys = Array.from({ length: 65 }, (_, index) => `field${String(index).padStart(2, '0')}`) + const payload = Object.fromEntries([...keys].reverse().map((key) => [key, key])) + const instance = { + uid: 0, + type: { name: 'ObjectOrder' }, + setupState: { payload }, + subTree: {}, + } + const runtime = createDevtoolsRuntime() + try { + runtime.dispatch({ + type: 'app:init', + app: { _instance: instance }, + version: '3.5.0', + vueTypes: {}, + time: 1, + }) + const collector = runtime.componentState + const componentId = runtime.registry.getComponentId(instance)! + const snapshot = collector.snapshot(componentId, 30, 30)! + const value = snapshot.sections + .find((section) => section.id === 'setup')! + .entries.find((entry) => entry.key === 'payload')!.value + if (value.kind !== 'object' || !value.handle) throw new Error('Missing object preview') + expect(value.preview.map((entry) => entry.key)).toEqual(keys.slice(0, 30)) + + const expanded = collector.expand(value.handle, [], 60) + if (!expanded || !('value' in expanded) || expanded.value.kind !== 'object') + throw new Error('Missing expanded object') + expect(expanded.value.preview.map((entry) => entry.key)).toEqual(keys.slice(0, 60)) + + let page = 1 + let snapshotId: string | undefined + const allKeys: string[] = [] + while (true) { + const result = collector.expandPage( + 'ordered-object', + { page, pageSize: 30, snapshotId }, + value.handle, + ) + if (!result || !('value' in result) || result.value.kind !== 'object') + throw new Error('Missing object page') + allKeys.push(...result.value.preview.map((entry) => entry.key)) + if (!result.pagination?.next) break + page = result.pagination.next + snapshotId = result.snapshotId + } + expect(allKeys).toEqual(keys) + expect(Object.keys(payload)).toEqual([...keys].reverse()) + } finally { + runtime.dispose() + } + }) +})