fix(ui): router.refresh after server actions so status updates show without reload

Lab side reported that after accepting a job / advancing a step the
button kept its 'Yükleniyor' state and the page didn't reflect the new
status until they hit refresh. Two issues stacked on top of each other:

1. The button forms were passing the action through an extra
   startTransition wrap — 'action={(fd) => startTransition(() => action(fd))}'.
   With React 19 + useActionState this is unnecessary; useActionState
   already manages its own transition. The double transition can leave
   the dispatch's pending flag wedged in some race orderings, which
   matches what the user saw.

2. revalidatePath() on the server invalidates the RSC cache but does not
   trigger client navigation. So even after the action returned, the
   page kept rendering the stale Job snapshot — and since the buttons
   are conditional on job.status, the now-stale 'pending' status meant
   the button stayed visible.

Fix in JobActionsPanel and the four sibling components (connections
delete row, pending inbound, pending outbound, file row delete):
  - Removed the startTransition wrap; forms point at 'action' directly.
  - Added useRouter() and call router.refresh() in the same useEffect
    branch where the success toast fires. This forces the Server
    Component tree to re-fetch, picks up the new job.status, and the
    actions panel rerenders into whatever button is next in the flow.
  - Cleaned the now-unused useTransition imports.

Net effect: tap 'İşleme Al' → spinner appears, ~400ms later the toast
hits and the row updates in place to 'Sonraki Aşama' without any
manual refresh.
This commit is contained in:
kovakmedya
2026-05-22 01:15:32 +03:00
parent 6fec52b98d
commit cdb2a15643
5 changed files with 69 additions and 78 deletions
@@ -1,7 +1,8 @@
"use client"; "use client";
import * as React from "react"; import * as React from "react";
import { useActionState, useEffect, useState, useTransition } from "react"; import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2 } from "lucide-react"; import { Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -108,17 +109,18 @@ function ApprovedRow({
deleteConnectionAction, deleteConnectionAction,
initialConnectionActionState, initialConnectionActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success("Bağlantı silindi."); toast.success("Bağlantı silindi.");
setOpen(false); setOpen(false);
router.refresh();
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state]); }, [state, router]);
const kindLabel = const kindLabel =
row.counterpart?.kind === "lab" row.counterpart?.kind === "lab"
@@ -190,11 +192,7 @@ function ApprovedRow({
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="outline">Vazgeç</Button> <Button type="button" variant="outline">Vazgeç</Button>
</DialogClose> </DialogClose>
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" disabled={pending} variant="destructive"> <Button type="submit" disabled={pending} variant="destructive">
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useTransition } from "react"; import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2, X } from "lucide-react"; import { Check, Loader2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -64,17 +65,25 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
rejectConnectionAction, rejectConnectionAction,
initialConnectionActionState, initialConnectionActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (approveState.ok) toast.success("Bağlantı onaylandı."); if (approveState.ok) {
else if (approveState.error) toast.error(approveState.error); toast.success("Bağlantı onaylandı.");
}, [approveState]); router.refresh();
} else if (approveState.error) {
toast.error(approveState.error);
}
}, [approveState, router]);
useEffect(() => { useEffect(() => {
if (rejectState.ok) toast.success("Talep reddedildi."); if (rejectState.ok) {
else if (rejectState.error) toast.error(rejectState.error); toast.success("Talep reddedildi.");
}, [rejectState]); router.refresh();
} else if (rejectState.error) {
toast.error(rejectState.error);
}
}, [rejectState, router]);
const kindLabel = const kindLabel =
row.counterpart?.kind === "lab" row.counterpart?.kind === "lab"
@@ -94,22 +103,14 @@ function InboundRow({ row }: { row: ConnectionWithCounterpart }) {
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<form <form action={approveAction}>
action={(fd) => {
startTransition(() => approveAction(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" size="sm" disabled={approvePending || rejectPending}> <Button type="submit" size="sm" disabled={approvePending || rejectPending}>
{approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />} {approvePending ? <Loader2 className="size-4 animate-spin" /> : <Check className="size-4" />}
Onayla Onayla
</Button> </Button>
</form> </form>
<form <form action={rejectAction}>
action={(fd) => {
startTransition(() => rejectAction(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button <Button
type="submit" type="submit"
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useTransition } from "react"; import { useActionState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, X } from "lucide-react"; import { Loader2, X } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -57,12 +58,16 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
cancelConnectionAction, cancelConnectionAction,
initialConnectionActionState, initialConnectionActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
useEffect(() => { useEffect(() => {
if (state.ok) toast.success("Talep iptal edildi."); if (state.ok) {
else if (state.error) toast.error(state.error); toast.success("Talep iptal edildi.");
}, [state]); router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
const kindLabel = const kindLabel =
row.counterpart?.kind === "lab" row.counterpart?.kind === "lab"
@@ -81,11 +86,7 @@ function OutboundRow({ row }: { row: ConnectionWithCounterpart }) {
{dateFormatter.format(new Date(row.requestedAt))} {dateFormatter.format(new Date(row.requestedAt))}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="connectionId" value={row.$id} /> <input type="hidden" name="connectionId" value={row.$id} />
<Button type="submit" size="sm" variant="outline" disabled={pending}> <Button type="submit" size="sm" variant="outline" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <X className="size-4" />}
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useActionState, useEffect, useState, useTransition } from "react"; import { useActionState, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { import {
ArrowRight, ArrowRight,
Check, Check,
@@ -66,20 +67,20 @@ export function JobActionsPanel({
} }
function AcceptButton({ jobId }: { jobId: string }) { function AcceptButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState); const [state, action, pending] = useActionState(acceptJobAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
if (state.ok) toast.success("İş işleme alındı."); if (state.ok) {
else if (state.error) toast.error(state.error); toast.success("İş işleme alındı.");
}, [state]); router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
return ( return (
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} /> <input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}> <Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
@@ -90,18 +91,19 @@ function AcceptButton({ jobId }: { jobId: string }) {
} }
function AdvanceButton({ job }: { job: Job }) { function AdvanceButton({ job }: { job: Job }) {
const router = useRouter();
const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState); const [state, action, pending] = useActionState(advanceStepAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success("Aşama ilerletildi."); toast.success("Aşama ilerletildi.");
setOpen(false); setOpen(false);
router.refresh();
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state]); }, [state, router]);
const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1; const currentIdx = job.currentStep ? JOB_STEP_ORDER.indexOf(job.currentStep) : -1;
const isFinal = currentIdx === JOB_STEP_ORDER.length - 1; const isFinal = currentIdx === JOB_STEP_ORDER.length - 1;
@@ -127,12 +129,7 @@ function AdvanceButton({ job }: { job: Job }) {
: `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`} : `Sonraki aşama: ${nextLabel}. İsterseniz bir not ekleyebilirsiniz.`}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form action={action} className="grid gap-3">
action={(fd) => {
startTransition(() => action(fd));
}}
className="grid gap-3"
>
<input type="hidden" name="jobId" value={job.$id} /> <input type="hidden" name="jobId" value={job.$id} />
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="note">Not (opsiyonel)</Label> <Label htmlFor="note">Not (opsiyonel)</Label>
@@ -162,20 +159,20 @@ function AdvanceButton({ job }: { job: Job }) {
} }
function DeliverButton({ jobId }: { jobId: string }) { function DeliverButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState); const [state, action, pending] = useActionState(markDeliveredAction, initialJobActionState);
const [, startTransition] = useTransition();
useEffect(() => { useEffect(() => {
if (state.ok) toast.success("İş teslim alındı."); if (state.ok) {
else if (state.error) toast.error(state.error); toast.success("İş teslim alındı.");
}, [state]); router.refresh();
} else if (state.error) {
toast.error(state.error);
}
}, [state, router]);
return ( return (
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} /> <input type="hidden" name="jobId" value={jobId} />
<Button type="submit" disabled={pending}> <Button type="submit" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <PackageCheck className="size-4" />}
@@ -186,18 +183,19 @@ function DeliverButton({ jobId }: { jobId: string }) {
} }
function CancelButton({ jobId }: { jobId: string }) { function CancelButton({ jobId }: { jobId: string }) {
const router = useRouter();
const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState); const [state, action, pending] = useActionState(cancelJobAction, initialJobActionState);
const [, startTransition] = useTransition();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (state.ok) { if (state.ok) {
toast.success("İş iptal edildi."); toast.success("İş iptal edildi.");
setOpen(false); setOpen(false);
router.refresh();
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state]); }, [state, router]);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -215,11 +213,7 @@ function CancelButton({ jobId }: { jobId: string }) {
</span> </span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="jobId" value={jobId} /> <input type="hidden" name="jobId" value={jobId} />
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useActionState, useEffect, useRef, useState, useTransition } from "react"; import { useActionState, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react"; import { Download, FileText, ImageIcon, Layers, Loader2, Trash2, Upload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -240,7 +240,7 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
deleteJobFileAction, deleteJobFileAction,
initialJobFileActionState, initialJobFileActionState,
); );
const [, startTransition] = useTransition(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [downloadOpen, setDownloadOpen] = useState(false); const [downloadOpen, setDownloadOpen] = useState(false);
@@ -248,10 +248,11 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
if (state.ok) { if (state.ok) {
toast.success("Dosya silindi."); toast.success("Dosya silindi.");
setOpen(false); setOpen(false);
router.refresh();
} else if (state.error) { } else if (state.error) {
toast.error(state.error); toast.error(state.error);
} }
}, [state]); }, [state, router]);
function triggerDownload() { function triggerDownload() {
// Use a programmatic anchor click — the server route streams the file // Use a programmatic anchor click — the server route streams the file
@@ -319,11 +320,7 @@ function FileRow({ file }: { file: JobFileWithUrl }) {
Vazgeç Vazgeç
</Button> </Button>
</DialogClose> </DialogClose>
<form <form action={action}>
action={(fd) => {
startTransition(() => action(fd));
}}
>
<input type="hidden" name="rowId" value={file.$id} /> <input type="hidden" name="rowId" value={file.$id} />
<Button type="submit" variant="destructive" disabled={pending}> <Button type="submit" variant="destructive" disabled={pending}>
{pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />} {pending ? <Loader2 className="size-4 animate-spin" /> : <Trash2 className="size-4" />}