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

- Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584).
- 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).

## [v0.16.0] - 2026-05-19

Expand Down
30 changes: 15 additions & 15 deletions handler_api_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -943,20 +943,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 @@ -988,7 +988,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"
"testing"
"time"
Expand Down Expand Up @@ -438,11 +439,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 @@ -466,9 +481,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 @@ -480,7 +496,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 @@ -825,20 +825,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 @@ -849,7 +849,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
52 changes: 52 additions & 0 deletions src/components/JSONTextView.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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();
});
});
});
28 changes: 28 additions & 0 deletions src/components/JSONTextView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useMemo } from "react";

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

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

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

return (
<PlaintextPanel
className={className}
codeClassName="whitespace-pre-wrap break-words"
content={formattedText}
copyTitle={copyTitle}
rawText={formattedText}
/>
);
}
11 changes: 11 additions & 0 deletions src/components/JobDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,14 @@ test("cancels job delete confirmation", async () => {
).not.toBeInTheDocument();
});
});

test("renders raw job args without rounding large numbers", () => {
const argsRaw = '{"id":1970670598291982290}';
const job = jobFactory.completed().build({ argsRaw });

render(
<JobDetail cancel={vi.fn()} deleteFn={vi.fn()} job={job} retry={vi.fn()} />,
);

expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument();
});
3 changes: 2 additions & 1 deletion src/components/JobDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ButtonForGroup from "@components/ButtonForGroup";
import ConfirmationDialog from "@components/ConfirmationDialog";
import JobAttempts from "@components/JobAttempts";
import JobTimeline from "@components/JobTimeline";
import JSONTextView from "@components/JSONTextView";
import JSONView from "@components/JSONView";
import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
import TopNavTitleOnly from "@components/TopNavTitleOnly";
Expand Down Expand Up @@ -164,7 +165,7 @@ export default function JobDetail({
Args
</dt>
<dd className="mt-1 text-sm leading-6 text-slate-700 sm:mt-2 dark:text-slate-300">
<JSONView copyTitle="Args" data={job.args} />
<JSONTextView copyTitle="Args" text={job.argsRaw} />
</dd>
</div>
<div className="col-span-1 border-t border-slate-100 px-4 py-6 sm:px-0 dark:border-slate-800">
Expand Down
18 changes: 9 additions & 9 deletions src/components/JobList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ describe("JobList", () => {
});

it("shows job args by default", () => {
const job = jobMinimalFactory.build();
const job = jobMinimalFactory.build({
argsRaw: '{"z":2,"id":1970670598291982290,"a":1}',
});
const features = createFeatures({
jobListHideArgsByDefault: false,
});
Expand All @@ -87,7 +89,9 @@ describe("JobList", () => {
</FeaturesContext.Provider>,
);

expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument();
expect(
screen.getByText('{"a":1,"id":1970670598291982290,"z":2}'),
).toBeInTheDocument();
});

it("hides job args when jobListHideArgsByDefault is true", () => {
Expand Down Expand Up @@ -117,9 +121,7 @@ describe("JobList", () => {
</FeaturesContext.Provider>,
);

expect(
screen.queryByText(JSON.stringify(job.args)),
).not.toBeInTheDocument();
expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument();
});

it("shows job args when user overrides default hide setting", () => {
Expand Down Expand Up @@ -150,7 +152,7 @@ describe("JobList", () => {
);

// Even though server default is to hide, user setting should make them visible
expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument();
expect(screen.getByText(job.argsRaw)).toBeInTheDocument();
});

it("hides job args when user overrides default show setting", () => {
Expand Down Expand Up @@ -181,9 +183,7 @@ describe("JobList", () => {
);

// Even though server default is to show, user setting should hide them
expect(
screen.queryByText(JSON.stringify(job.args)),
).not.toBeInTheDocument();
expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument();
});

it("requires confirmation before deleting selected jobs", async () => {
Expand Down
7 changes: 6 additions & 1 deletion src/components/JobList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
JobStateFilterItem,
jobStateFilterItems,
} from "@utils/jobStateFilterItems";
import { compactJSONText } from "@utils/jsonText";
import { classNames } from "@utils/style";
import React, {
FormEvent,
Expand Down Expand Up @@ -89,6 +90,10 @@ const JobListItem = ({
onChangeSelect,
}: JobListItemProps) => {
const showArgs = !hideArgs;
const argsPreview = useMemo(
() => compactJSONText(job.argsRaw),
[job.argsRaw],
);

return (
<li className="relative flex items-stretch space-x-4 py-1.5">
Expand Down Expand Up @@ -134,7 +139,7 @@ const JobListItem = ({
</svg>
{showArgs && (
<p className="grow truncate font-mono whitespace-nowrap">
{JSON.stringify(job.args)}
{argsPreview}
</p>
)}
<Badge className="flex-none font-mono text-xs" color="zinc">
Expand Down
Loading
Loading