Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sort-json-viewer-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

fix: Sort JSON viewer keys alphabetically so wide Map columns are scannable
23 changes: 19 additions & 4 deletions packages/app/src/components/HyperJson.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
65 changes: 65 additions & 0 deletions packages/app/src/components/__tests__/HyperJson.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<HyperJson data={{ zebra: 1, alpha: 2, Mango: 3 }} />,
);

expect(renderedKeys(container)).toEqual(['alpha', 'Mango', 'zebra']);
});

it('sorts nested object keys alphabetically', () => {
const { container } = renderWithMantine(
<HyperJson
data={{ ProfileEvents: { Seek: 59, FileOpen: 2, Query: 1 } }}
normallyExpanded
/>,
);

expect(renderedKeys(container)).toEqual([
'ProfileEvents',
'FileOpen',
'Query',
'Seek',
]);
});

it('orders numeric suffixes naturally rather than lexicographically', () => {
const { container } = renderWithMantine(
<HyperJson data={{ key10: 1, key2: 2, key1: 3 }} />,
);

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(
<HyperJson data={{ list }} normallyExpanded />,
);

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);
});
});
Loading