From 6419df94960940bd1a65988cedf41ba958e78771 Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Mon, 2 Mar 2026 09:01:49 -0600 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + src/components/JSONView.test.tsx | 106 +++++++++++++++++++++++++++++++ src/components/JSONView.tsx | 67 ++++++++++++++++++- 3 files changed, 172 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f8ec18..d323e14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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). - 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). - 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). +- JSON viewer: sort keys alphabetically in rendered and copied output for object payloads. [PR #525](https://github.com/riverqueue/riverui/pull/525). ## [v0.15.0] - 2026-02-26 diff --git a/src/components/JSONView.test.tsx b/src/components/JSONView.test.tsx index 7cadaae5..7b6e242d 100644 --- a/src/components/JSONView.test.tsx +++ b/src/components/JSONView.test.tsx @@ -58,6 +58,44 @@ describe("JSONView Component", () => { expect(screen.getByText(/true/)).toBeInTheDocument(); }); + it("renders object keys alphabetically at root and nested levels", () => { + const unsortedData = Object.fromEntries([ + ["zebra", 3], + [ + "alpha", + Object.fromEntries([ + ["zulu", true], + ["bravo", true], + ]), + ], + ["middle", 2], + ]); + + render(); + + const alphaKey = screen.getByText(/"alpha"/); + const middleKey = screen.getByText(/"middle"/); + const zebraKey = screen.getByText(/"zebra"/); + + const alphaBeforeMiddle = + alphaKey.compareDocumentPosition(middleKey) & + Node.DOCUMENT_POSITION_FOLLOWING; + const middleBeforeZebra = + middleKey.compareDocumentPosition(zebraKey) & + Node.DOCUMENT_POSITION_FOLLOWING; + + expect(alphaBeforeMiddle).toBeTruthy(); + expect(middleBeforeZebra).toBeTruthy(); + + const bravoKey = screen.getByText(/"bravo"/); + const zuluKey = screen.getByText(/"zulu"/); + const bravoBeforeZulu = + bravoKey.compareDocumentPosition(zuluKey) & + Node.DOCUMENT_POSITION_FOLLOWING; + + expect(bravoBeforeZulu).toBeTruthy(); + }); + it("renders nested JSON data with collapsed nodes but visible keys by default", () => { render(); @@ -188,6 +226,74 @@ describe("JSONView Component", () => { }); }); + it("copies alphabetically sorted JSON to clipboard", async () => { + const unsortedData = Object.fromEntries([ + ["zebra", 3], + [ + "alpha", + Object.fromEntries([ + ["zulu", true], + ["bravo", true], + ]), + ], + ["middle", 2], + ]); + + render(); + + const copyButton = screen.getByTestId("text-copy-button"); + + await act(async () => { + fireEvent.click(copyButton); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + const clipboardCall = ( + navigator.clipboard.writeText as unknown as { + mock: { calls: string[][] }; + } + ).mock.calls[0][0]; + + const parsed = JSON.parse(clipboardCall) as Record; + expect(Object.keys(parsed)).toEqual(["alpha", "middle", "zebra"]); + expect(Object.keys(parsed.alpha as Record)).toEqual([ + "bravo", + "zulu", + ]); + }); + + it("preserves __proto__ as data when rendering and copying", async () => { + const dataWithProtoKey = JSON.parse( + '{"zebra":3,"__proto__":{"safe":"value"},"alpha":1}', + ) as Record; + + render(); + + expect(screen.getByText(/"__proto__"/)).toBeInTheDocument(); + + const copyButton = screen.getByTestId("text-copy-button"); + + await act(async () => { + fireEvent.click(copyButton); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + const clipboardCall = ( + navigator.clipboard.writeText as unknown as { + mock: { calls: string[][] }; + } + ).mock.calls[0][0]; + + expect(clipboardCall).toContain('"__proto__"'); + + const parsed = JSON.parse(clipboardCall) as Record; + expect(Object.prototype.hasOwnProperty.call(parsed, "__proto__")).toBe( + true, + ); + expect(parsed["__proto__"]).toEqual({ safe: "value" }); + expect(Object.getPrototypeOf(parsed)).toBe(Object.prototype); + }); + it("renders null and undefined values", () => { const data = { nullValue: null, diff --git a/src/components/JSONView.tsx b/src/components/JSONView.tsx index 8b362385..c5400dc7 100644 --- a/src/components/JSONView.tsx +++ b/src/components/JSONView.tsx @@ -44,10 +44,12 @@ export default function JSONView({ data, defaultExpandDepth = 1, }: JSONViewProps) { + const sortedData = React.useMemo(() => sortObjectKeys(data), [data]); + const jsonContent = ( <> ); } +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === null || prototype === Object.prototype; +} + function JSONNodeRenderer({ data, defaultExpandDepth, @@ -570,3 +581,55 @@ function renderValue( ); } + +function sortObjectKeys(value: unknown): unknown { + return sortObjectKeysInternal(value, new WeakMap()); +} + +function sortObjectKeysInternal( + value: unknown, + sortedValues: WeakMap, +): unknown { + if (Array.isArray(value)) { + const cachedArray = sortedValues.get(value); + if (cachedArray) { + return cachedArray; + } + + const sortedArray: unknown[] = []; + sortedValues.set(value, sortedArray); + for (const item of value) { + sortedArray.push(sortObjectKeysInternal(item, sortedValues)); + } + return sortedArray; + } + + if (!isPlainObject(value)) { + return value; + } + + const cachedObject = sortedValues.get(value); + if (cachedObject) { + return cachedObject; + } + + const sortedObject = Object.create(Object.getPrototypeOf(value)) as Record< + string, + unknown + >; + sortedValues.set(value, sortedObject); + + const sortedEntries = Object.entries(value).sort(([leftKey], [rightKey]) => + leftKey.localeCompare(rightKey), + ); + for (const [key, item] of sortedEntries) { + Object.defineProperty(sortedObject, key, { + configurable: true, + enumerable: true, + value: sortObjectKeysInternal(item, sortedValues), + writable: true, + }); + } + + return sortedObject; +}