Skip to content

Commit 6419df9

Browse files
committed
sort JSONView keys alphabetically
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 6419df9

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)