diff --git a/CHANGELOG.md b/CHANGELOG.md
index b0f8ec1..d323e14 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 7cadaae..7b6e242 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 8b36238..c5400dc 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