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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Job args: preserve large numeric JSON values exactly when displaying and copying args, while keeping object keys sorted. [Fixes #593](https://github.com/riverqueue/riverui/issues/593). [PR #594](https://github.com/riverqueue/riverui/pull/594).

## [v0.17.0] - 2026-07-31

### Added
Expand Down
30 changes: 15 additions & 15 deletions handler_api_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -977,20 +977,20 @@ type PartitionConfig struct {
}

type RiverJobMinimal struct {
ID int64 `json:"id"`
Args json.RawMessage `json:"args"`
Attempt int `json:"attempt"`
AttemptedAt *time.Time `json:"attempted_at"`
AttemptedBy []string `json:"attempted_by"`
CreatedAt time.Time `json:"created_at"`
FinalizedAt *time.Time `json:"finalized_at"`
Kind string `json:"kind"`
MaxAttempts int `json:"max_attempts"`
Priority int `json:"priority"`
Queue string `json:"queue"`
ScheduledAt time.Time `json:"scheduled_at"`
State string `json:"state"`
Tags []string `json:"tags"`
ID int64 `json:"id"`
Args string `json:"args"`
Attempt int `json:"attempt"`
AttemptedAt *time.Time `json:"attempted_at"`
AttemptedBy []string `json:"attempted_by"`
CreatedAt time.Time `json:"created_at"`
FinalizedAt *time.Time `json:"finalized_at"`
Kind string `json:"kind"`
MaxAttempts int `json:"max_attempts"`
Priority int `json:"priority"`
Queue string `json:"queue"`
ScheduledAt time.Time `json:"scheduled_at"`
State string `json:"state"`
Tags []string `json:"tags"`
}

type RiverJob struct {
Expand Down Expand Up @@ -1022,7 +1022,7 @@ func riverJobToSerializableJobMinimal(riverJob *rivertype.JobRow) *RiverJobMinim

return &RiverJobMinimal{
ID: riverJob.ID,
Args: riverJob.EncodedArgs,
Args: string(riverJob.EncodedArgs),
Attempt: riverJob.Attempt,
AttemptedAt: riverJob.AttemptedAt,
AttemptedBy: attemptedBy,
Expand Down
35 changes: 31 additions & 4 deletions handler_api_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package riverui

import (
"context"
"encoding/json"
"log/slog"
"net/http"
"testing"
Expand Down Expand Up @@ -514,11 +515,25 @@ func TestAPIHandlerJobGet(t *testing.T) {

endpoint, bundle := setupEndpoint(ctx, t, newJobGetEndpoint)

job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{})
encodedArgs := []byte(`{"id":1970670598291982290,"max":9223372036854775807}`)
job := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
EncodedArgs: encodedArgs,
})

resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobGetRequest{JobID: job.ID})
require.NoError(t, err)
require.Equal(t, job.ID, resp.ID)
expectedArgs := string(job.EncodedArgs)
require.Equal(t, expectedArgs, resp.Args)
require.Contains(t, resp.Args, "1970670598291982290")
require.Contains(t, resp.Args, "9223372036854775807")

var wireResp struct {
Args string `json:"args"`
}
require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp))
require.Equal(t, expectedArgs, wireResp.Args)
require.True(t, json.Valid([]byte(wireResp.Args)))
})

t.Run("NotFound", func(t *testing.T) {
Expand All @@ -542,9 +557,10 @@ func TestAPIHandlerJobList(t *testing.T) {
endpoint, bundle := setupEndpoint(ctx, t, newJobListEndpoint)

job1 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
Kind: ptrutil.Ptr("kind1"),
Queue: ptrutil.Ptr("queue1"),
State: ptrutil.Ptr(rivertype.JobStateRunning),
EncodedArgs: []byte(`{"id":1970670598291982290}`),
Kind: ptrutil.Ptr("kind1"),
Queue: ptrutil.Ptr("queue1"),
State: ptrutil.Ptr(rivertype.JobStateRunning),
})
job2 := testfactory.Job(ctx, t, bundle.exec, &testfactory.JobOpts{
Kind: ptrutil.Ptr("kind2"),
Expand All @@ -556,7 +572,18 @@ func TestAPIHandlerJobList(t *testing.T) {
require.NoError(t, err)
require.Len(t, resp.Data, 2)
require.Equal(t, job1.ID, resp.Data[0].ID)
expectedArgs := string(job1.EncodedArgs)
require.Equal(t, expectedArgs, resp.Data[0].Args)
require.Contains(t, resp.Data[0].Args, "1970670598291982290")
require.Equal(t, job2.ID, resp.Data[1].ID)

var wireResp struct {
Data []struct {
Args string `json:"args"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp))
require.Equal(t, expectedArgs, wireResp.Data[0].Args)
})

t.Run("FilterByIDs", func(t *testing.T) {
Expand Down
30 changes: 15 additions & 15 deletions riverproui/internal/prohandler/pro_handler_api_endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -828,20 +828,20 @@ func (a *workflowRetryEndpoint[TTx]) Execute(ctx context.Context, req *workflowR
}

type riverJobMinimal struct {
ID int64 `json:"id"`
Args json.RawMessage `json:"args"`
Attempt int `json:"attempt"`
AttemptedAt *time.Time `json:"attempted_at"`
AttemptedBy []string `json:"attempted_by"`
CreatedAt time.Time `json:"created_at"`
FinalizedAt *time.Time `json:"finalized_at"`
Kind string `json:"kind"`
MaxAttempts int `json:"max_attempts"`
Priority int `json:"priority"`
Queue string `json:"queue"`
ScheduledAt time.Time `json:"scheduled_at"`
State string `json:"state"`
Tags []string `json:"tags"`
ID int64 `json:"id"`
Args string `json:"args"`
Attempt int `json:"attempt"`
AttemptedAt *time.Time `json:"attempted_at"`
AttemptedBy []string `json:"attempted_by"`
CreatedAt time.Time `json:"created_at"`
FinalizedAt *time.Time `json:"finalized_at"`
Kind string `json:"kind"`
MaxAttempts int `json:"max_attempts"`
Priority int `json:"priority"`
Queue string `json:"queue"`
ScheduledAt time.Time `json:"scheduled_at"`
State string `json:"state"`
Tags []string `json:"tags"`
}

func internalJobToJobMinimal(internal *rivertype.JobRow) *riverJobMinimal {
Expand All @@ -852,7 +852,7 @@ func internalJobToJobMinimal(internal *rivertype.JobRow) *riverJobMinimal {

return &riverJobMinimal{
ID: internal.ID,
Args: internal.EncodedArgs,
Args: string(internal.EncodedArgs),
Attempt: internal.Attempt,
AttemptedAt: internal.AttemptedAt,
AttemptedBy: attemptedBy,
Expand Down
22 changes: 22 additions & 0 deletions riverproui/internal/prohandler/pro_handler_api_endpoints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func TestProAPIHandlerWorkflowGet(t *testing.T) {
}

dependencyJob := jobWithSchema(ctx, t, bundle.exec, bundle.schema, &testfactory.JobOpts{
EncodedArgs: []byte(`{"id":1970670598291982290,"max":9223372036854775807}`),
FinalizedAt: ptrutil.Ptr(now.Add(-2 * time.Minute)),
Metadata: workflowMetadata("wf_get", "collect_inputs", nil),
State: ptrutil.Ptr(rivertype.JobStateCompleted),
Expand Down Expand Up @@ -207,6 +208,27 @@ func TestProAPIHandlerWorkflowGet(t *testing.T) {

require.Equal(t, workflowTaskWaitReasonNone, taskByID[dependencyJob.ID].WaitReason)
require.Nil(t, taskByID[dependencyJob.ID].Wait)
expectedArgs := string(dependencyJob.EncodedArgs)
require.Equal(t, expectedArgs, taskByID[dependencyJob.ID].Args)
require.Contains(t, taskByID[dependencyJob.ID].Args, "1970670598291982290")
require.Contains(t, taskByID[dependencyJob.ID].Args, "9223372036854775807")

var wireResp struct {
Tasks []struct {
Args string `json:"args"`
ID int64 `json:"id"`
} `json:"tasks"`
}
require.NoError(t, json.Unmarshal(uicommontest.MustMarshalJSON(t, resp), &wireResp))
var dependencyArgs string
for _, task := range wireResp.Tasks {
if task.ID == dependencyJob.ID {
dependencyArgs = task.Args
break
}
}
require.Equal(t, expectedArgs, dependencyArgs)
require.True(t, json.Valid([]byte(dependencyArgs)))

waitingTask := taskByID[waitingJob.ID]
require.NotNil(t, waitingTask)
Expand Down
117 changes: 117 additions & 0 deletions src/components/JSONTextView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import toast from "react-hot-toast";
import { beforeEach, describe, expect, it, vi } from "vitest";

import JSONTextView from "./JSONTextView";

Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockImplementation(() => Promise.resolve()),
},
});

vi.mock("react-hot-toast", () => ({
default: {
custom: vi.fn(),
},
}));

describe("JSONTextView", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("renders and copies sorted JSON without rounding large numbers", async () => {
const rawJSON = '{"z":2,"id":1970670598291982290,"a":1}';
const formattedJSON = `{
"a": 1,
"id": 1970670598291982290,
"z": 2
}`;

render(<JSONTextView copyTitle="Args" text={rawJSON} />);

expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument();

await act(async () => {
fireEvent.click(screen.getByTestId("text-copy-button"));
});

expect(navigator.clipboard.writeText).toHaveBeenCalledWith(formattedJSON);

await waitFor(() => {
expect(toast.custom).toHaveBeenCalled();
});
});

it("keeps nested args collapsible while copying the complete value", async () => {
const rawJSON =
'{"z":2,"outer":{"nested":{"id":1970670598291982290}},"a":1}';

render(<JSONTextView copyTitle="Args" text={rawJSON} />);

expect(screen.queryByText("1970670598291982290")).not.toBeInTheDocument();

const outerButton = screen
.getAllByRole("button")
.find((button) => button.textContent?.includes('"outer"'));
expect(outerButton).toBeDefined();
fireEvent.click(outerButton!);

const nestedButton = screen
.getAllByRole("button")
.find((button) => button.textContent?.includes('"nested"'));
expect(nestedButton).toBeDefined();
fireEvent.click(nestedButton!);

expect(screen.getByText("1970670598291982290")).toBeInTheDocument();

await act(async () => {
fireEvent.click(screen.getByTestId("text-copy-button"));
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`{
"a": 1,
"outer": {
"nested": {
"id": 1970670598291982290
}
},
"z": 2
}`);
});

it("uses the same sorted order for displayed and copied integer keys", async () => {
render(<JSONTextView text='{"10":"ten","a":0,"2":"two"}' />);

const twoKey = screen.getByText('"2"');
const tenKey = screen.getByText('"10"');
expect(
twoKey.compareDocumentPosition(tenKey) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();

await act(async () => {
fireEvent.click(screen.getByTestId("text-copy-button"));
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`{
"2": "two",
"10": "ten",
"a": 0
}`);
});

it("renders malformed or excessively nested args as literal text", () => {
const deeplyNested = `${"[".repeat(5_000)}0${"]".repeat(5_000)}`;
const { rerender } = render(<JSONTextView text="{not valid" />);

expect(screen.getByText("{not valid")).toBeInTheDocument();

rerender(<JSONTextView text={deeplyNested} />);
expect(screen.getByText(deeplyNested)).toBeInTheDocument();
});
});
39 changes: 39 additions & 0 deletions src/components/JSONTextView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { useMemo } from "react";

import JSONView from "@/components/JSONView";
import PlaintextPanel from "@/components/PlaintextPanel";
import { prepareJSONText } from "@/utils/jsonText";

type JSONTextViewProps = {
className?: string;
copyTitle?: string;
text: string;
};

export default function JSONTextView({
className,
copyTitle = "JSON",
text,
}: JSONTextViewProps) {
const prepared = useMemo(() => prepareJSONText(text), [text]);

if (prepared) {
return (
<JSONView
className={className}
copyText={prepared.copyText}
copyTitle={copyTitle}
data={prepared.value}
/>
);
}

return (
<PlaintextPanel
className={className}
codeClassName="whitespace-pre-wrap break-words"
copyTitle={copyTitle}
text={text}
/>
);
}
Loading
Loading