diff --git a/.changeset/sort-json-viewer-keys.md b/.changeset/sort-json-viewer-keys.md new file mode 100644 index 0000000000..97bc6b32d3 --- /dev/null +++ b/.changeset/sort-json-viewer-keys.md @@ -0,0 +1,5 @@ +--- +'@hyperdx/app': patch +--- + +fix: Sort JSON viewer keys alphabetically so wide Map columns are scannable diff --git a/packages/app/src/components/HyperJson.tsx b/packages/app/src/components/HyperJson.tsx index f7098eb939..e936f6f3a4 100644 --- a/packages/app/src/components/HyperJson.tsx +++ b/packages/app/src/components/HyperJson.tsx @@ -357,11 +357,26 @@ function TreeNode({ const keyPath = React.useMemo(() => _keyPath ?? [], [_keyPath]); const originalLength = React.useMemo(() => Object.keys(data).length, [data]); + + // ClickHouse hands back `Map(...)` keys in physical storage order, which reads + // as random for wide maps like `ProfileEvents`. Sorting here (rather than + // upstream) covers every nesting level for free, since TreeNode recurses, and + // it puts the sort ahead of the MAX_TREE_NODE_ITEMS slice below so the + // truncated view shows a predictable prefix instead of an arbitrary subset. + const entries = React.useMemo(() => { + const raw = Object.entries(data); + // Arrays are index-keyed — reordering them would change the data. + if (isArray(data)) { + return raw; + } + return raw.sort(([a], [b]) => + a.localeCompare(b, undefined, { numeric: true }), + ); + }, [data]); + const visibleLines = React.useMemo(() => { - return isExpanded - ? Object.entries(data) - : Object.entries(data).slice(0, MAX_TREE_NODE_ITEMS); - }, [data, isExpanded]); + return isExpanded ? entries : entries.slice(0, MAX_TREE_NODE_ITEMS); + }, [entries, isExpanded]); const nestedLevel = keyPath?.length || 0; return ( diff --git a/packages/app/src/components/__tests__/HyperJson.test.tsx b/packages/app/src/components/__tests__/HyperJson.test.tsx index 1f7dbf67b2..8710d9d83e 100644 --- a/packages/app/src/components/__tests__/HyperJson.test.tsx +++ b/packages/app/src/components/__tests__/HyperJson.test.tsx @@ -36,3 +36,68 @@ describe('HyperJson wrap markers', () => { expect(key).toHaveTextContent(longKey); }); }); + +// ClickHouse returns `Map(...)` keys in physical storage order, which reads as +// random for wide maps like `ProfileEvents`. Sort at render time so the tree is +// scannable — and so the MAX_TREE_NODE_ITEMS cap slices a predictable prefix +// rather than an arbitrary subset. +describe('HyperJson key ordering', () => { + const renderedKeys = (container: HTMLElement) => + Array.from(container.querySelectorAll('.keyContainer > .key')).map(el => + el.textContent?.trim(), + ); + + const renderedValues = (container: HTMLElement) => + Array.from(container.querySelectorAll('.valueContainer')).map(el => + el.textContent?.trim(), + ); + + it('sorts top-level keys alphabetically, case-insensitively', () => { + const { container } = renderWithMantine( + , + ); + + expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']); + }); + + it('sorts nested object keys alphabetically', () => { + const { container } = renderWithMantine( + , + ); + + expect(renderedKeys(container)).toEqual([ + 'ProfileEvents', + 'FileOpen', + 'Query', + 'Seek', + ]); + }); + + it('orders numeric suffixes naturally rather than lexicographically', () => { + const { container } = renderWithMantine( + , + ); + + expect(renderedKeys(container)).toEqual(['key1', 'key2', 'key10']); + }); + + // Arrays are index-keyed, so sorting their "keys" would reorder the data. + // Uses >10 elements so a lexicographic sort (0, 1, 10, 11, 2, ...) would show + // up, not just a hypothetical reversal. + it('preserves array element order', () => { + const list = Array.from({ length: 12 }, (_, i) => `item-${i}`); + const { container } = renderWithMantine( + , + ); + + expect(renderedKeys(container)).toEqual([ + 'list', + ...list.map((_, i) => String(i)), + ]); + // First value cell belongs to `list` itself ("[] 12 items"). + expect(renderedValues(container).slice(1)).toEqual(list); + }); +});