Skip to content

Commit d8bf160

Browse files
authored
sort JSONView keys alphabetically (#525)
JSON viewer output could vary based on object key order from incoming payloads, which made payloads harder to scan and compare across views and copy/paste workflows. This change makes rendered and copied JSON output deterministic by sorting object keys consistently.
1 parent 55cb3e7 commit d8bf160

3 files changed

Lines changed: 172 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Workflow detail: add on-canvas zoom controls for click/touch navigation and improve controls styling for dark mode. [PR #524](https://github.com/riverqueue/riverui/pull/524).
1313
- Workflow detail: improve default workflow diagram framing for legibility while still allowing manual zoom-out to view the full graph. [PR #524](https://github.com/riverqueue/riverui/pull/524).
1414
- Workflow detail: truncate long workflow names in the header to prevent overflow and add a copy button for the full name. [PR #524](https://github.com/riverqueue/riverui/pull/524).
15+
- JSON viewer: sort keys alphabetically in rendered and copied output for object payloads. [PR #525](https://github.com/riverqueue/riverui/pull/525).
1516

1617
## [v0.15.0] - 2026-02-26
1718

src/components/JSONView.test.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,44 @@ describe("JSONView Component", () => {
5858
expect(screen.getByText(/true/)).toBeInTheDocument();
5959
});
6060

61+
it("renders object keys alphabetically at root and nested levels", () => {
62+
const unsortedData = Object.fromEntries([
63+
["zebra", 3],
64+
[
65+
"alpha",
66+
Object.fromEntries([
67+
["zulu", true],
68+
["bravo", true],
69+
]),
70+
],
71+
["middle", 2],
72+
]);
73+
74+
render(<JSONView data={unsortedData} defaultExpandDepth={2} />);
75+
76+
const alphaKey = screen.getByText(/"alpha"/);
77+
const middleKey = screen.getByText(/"middle"/);
78+
const zebraKey = screen.getByText(/"zebra"/);
79+
80+
const alphaBeforeMiddle =
81+
alphaKey.compareDocumentPosition(middleKey) &
82+
Node.DOCUMENT_POSITION_FOLLOWING;
83+
const middleBeforeZebra =
84+
middleKey.compareDocumentPosition(zebraKey) &
85+
Node.DOCUMENT_POSITION_FOLLOWING;
86+
87+
expect(alphaBeforeMiddle).toBeTruthy();
88+
expect(middleBeforeZebra).toBeTruthy();
89+
90+
const bravoKey = screen.getByText(/"bravo"/);
91+
const zuluKey = screen.getByText(/"zulu"/);
92+
const bravoBeforeZulu =
93+
bravoKey.compareDocumentPosition(zuluKey) &
94+
Node.DOCUMENT_POSITION_FOLLOWING;
95+
96+
expect(bravoBeforeZulu).toBeTruthy();
97+
});
98+
6199
it("renders nested JSON data with collapsed nodes but visible keys by default", () => {
62100
render(<JSONView data={nestedData} defaultExpandDepth={1} />);
63101

@@ -188,6 +226,74 @@ describe("JSONView Component", () => {
188226
});
189227
});
190228

229+
it("copies alphabetically sorted JSON to clipboard", async () => {
230+
const unsortedData = Object.fromEntries([
231+
["zebra", 3],
232+
[
233+
"alpha",
234+
Object.fromEntries([
235+
["zulu", true],
236+
["bravo", true],
237+
]),
238+
],
239+
["middle", 2],
240+
]);
241+
242+
render(<JSONView copyTitle="Test Data" data={unsortedData} />);
243+
244+
const copyButton = screen.getByTestId("text-copy-button");
245+
246+
await act(async () => {
247+
fireEvent.click(copyButton);
248+
});
249+
250+
expect(navigator.clipboard.writeText).toHaveBeenCalled();
251+
const clipboardCall = (
252+
navigator.clipboard.writeText as unknown as {
253+
mock: { calls: string[][] };
254+
}
255+
).mock.calls[0][0];
256+
257+
const parsed = JSON.parse(clipboardCall) as Record<string, unknown>;
258+
expect(Object.keys(parsed)).toEqual(["alpha", "middle", "zebra"]);
259+
expect(Object.keys(parsed.alpha as Record<string, unknown>)).toEqual([
260+
"bravo",
261+
"zulu",
262+
]);
263+
});
264+
265+
it("preserves __proto__ as data when rendering and copying", async () => {
266+
const dataWithProtoKey = JSON.parse(
267+
'{"zebra":3,"__proto__":{"safe":"value"},"alpha":1}',
268+
) as Record<string, unknown>;
269+
270+
render(<JSONView copyTitle="Test Data" data={dataWithProtoKey} />);
271+
272+
expect(screen.getByText(/"__proto__"/)).toBeInTheDocument();
273+
274+
const copyButton = screen.getByTestId("text-copy-button");
275+
276+
await act(async () => {
277+
fireEvent.click(copyButton);
278+
});
279+
280+
expect(navigator.clipboard.writeText).toHaveBeenCalled();
281+
const clipboardCall = (
282+
navigator.clipboard.writeText as unknown as {
283+
mock: { calls: string[][] };
284+
}
285+
).mock.calls[0][0];
286+
287+
expect(clipboardCall).toContain('"__proto__"');
288+
289+
const parsed = JSON.parse(clipboardCall) as Record<string, unknown>;
290+
expect(Object.prototype.hasOwnProperty.call(parsed, "__proto__")).toBe(
291+
true,
292+
);
293+
expect(parsed["__proto__"]).toEqual({ safe: "value" });
294+
expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype);
295+
});
296+
191297
it("renders null and undefined values", () => {
192298
const data = {
193299
nullValue: null,

src/components/JSONView.tsx

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ export default function JSONView({
4444
data,
4545
defaultExpandDepth = 1,
4646
}: JSONViewProps) {
47+
const sortedData = React.useMemo(() => sortObjectKeys(data), [data]);
48+
4749
const jsonContent = (
4850
<>
4951
<JSONNodeRenderer
50-
data={data}
52+
data={sortedData}
5153
defaultExpandDepth={defaultExpandDepth}
5254
depth={0}
5355
isLastItemInParent={true}
@@ -63,11 +65,20 @@ export default function JSONView({
6365
codeClassName="pl-6"
6466
content={jsonContent}
6567
copyTitle={copyTitle}
66-
rawText={JSON.stringify(data, null, 2)}
68+
rawText={JSON.stringify(sortedData, null, 2)}
6769
/>
6870
);
6971
}
7072

73+
function isPlainObject(value: unknown): value is Record<string, unknown> {
74+
if (value === null || typeof value !== "object") {
75+
return false;
76+
}
77+
78+
const prototype = Object.getPrototypeOf(value);
79+
return prototype === null || prototype === Object.prototype;
80+
}
81+
7182
function JSONNodeRenderer({
7283
data,
7384
defaultExpandDepth,
@@ -570,3 +581,55 @@ function renderValue(
570581
</Disclosure>
571582
);
572583
}
584+
585+
function sortObjectKeys(value: unknown): unknown {
586+
return sortObjectKeysInternal(value, new WeakMap<object, unknown>());
587+
}
588+
589+
function sortObjectKeysInternal(
590+
value: unknown,
591+
sortedValues: WeakMap<object, unknown>,
592+
): unknown {
593+
if (Array.isArray(value)) {
594+
const cachedArray = sortedValues.get(value);
595+
if (cachedArray) {
596+
return cachedArray;
597+
}
598+
599+
const sortedArray: unknown[] = [];
600+
sortedValues.set(value, sortedArray);
601+
for (const item of value) {
602+
sortedArray.push(sortObjectKeysInternal(item, sortedValues));
603+
}
604+
return sortedArray;
605+
}
606+
607+
if (!isPlainObject(value)) {
608+
return value;
609+
}
610+
611+
const cachedObject = sortedValues.get(value);
612+
if (cachedObject) {
613+
return cachedObject;
614+
}
615+
616+
const sortedObject = Object.create(Object.getPrototypeOf(value)) as Record<
617+
string,
618+
unknown
619+
>;
620+
sortedValues.set(value, sortedObject);
621+
622+
const sortedEntries = Object.entries(value).sort(([leftKey], [rightKey]) =>
623+
leftKey.localeCompare(rightKey),
624+
);
625+
for (const [key, item] of sortedEntries) {
626+
Object.defineProperty(sortedObject, key, {
627+
configurable: true,
628+
enumerable: true,
629+
value: sortObjectKeysInternal(item, sortedValues),
630+
writable: true,
631+
});
632+
}
633+
634+
return sortedObject;
635+
}

0 commit comments

Comments
 (0)