Keep the online detail's download state on its own post

Two stale-state bugs came from react-router reusing the detail component
between posts (parent/child links hit the same /detail/<id> route):

- the previously viewed post's download panel kept rendering, so a freshly
  opened post could claim 'Downloaded to the library J-xx'. The task view is
  now gated on the task's post_id as well, and DetailPage keys the detail
  views per item — component state (download panel, delete confirmation,
  optimizer modal) cannot survive a post change any more.
- 'Your last download for this post finished' appeared on every revisit. It
  now only shows when this visit actually saw the download running (derived
  state, set during render), which still reports a re-attached download
  finishing while staying quiet on later visits; the 'In library' button
  remains the persistent indicator.

Library detail gets the same per-item key, so its delete confirmation and
optimizer modal reset between items too.

tsc, oxlint and the build are clean (the derived-state pattern was chosen
over a ref read in render / setState-in-effect, both flagged by the linter).
This commit is contained in:
2026-09-18 17:52:50 -05:00
parent a17dd5a4ef
commit d16a77907a
2 changed files with 28 additions and 7 deletions
+5 -2
View File
@@ -14,11 +14,14 @@ export default function DetailPage() {
if (J_ID_RE.test(itemId)) { if (J_ID_RE.test(itemId)) {
if (!hasBackend()) return <BackendNeeded />; if (!hasBackend()) return <BackendNeeded />;
const normalized = `J-${itemId.slice(2)}`; const normalized = `J-${itemId.slice(2)}`;
return <LibraryDetail jId={normalized} />; // Key per item: remount so local state (delete confirm, optimizer, task
// view) cannot leak from the previously viewed item.
return <LibraryDetail key={normalized} jId={normalized} />;
} }
if (/^\d+$/.test(itemId)) { if (/^\d+$/.test(itemId)) {
return <OnlineDetail postId={Number(itemId)} />; // Key per post: the download task view must not survive a post change.
return <OnlineDetail key={itemId} postId={Number(itemId)} />;
} }
return ( return (
+23 -5
View File
@@ -121,6 +121,10 @@ export function OnlineDetail({ postId }: { postId: number }) {
}); });
const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null); const [downloadTaskId, setDownloadTaskId] = useState<string | null>(null);
// True once this visit has seen the download running. Only then is "your
// last download finished" worth showing; a revisit stays quiet (the "In
// library" button already says where the file is).
const [watchedDownload, setWatchedDownload] = useState(false);
// Server-side tasks outlive this page; re-attach to the newest one for this // Server-side tasks outlive this page; re-attach to the newest one for this
// post so navigating away and back keeps the progress view. // post so navigating away and back keeps the progress view.
@@ -186,6 +190,19 @@ export function OnlineDetail({ postId }: { postId: number }) {
const downloadStatus = downloadTask?.status; const downloadStatus = downloadTask?.status;
const downloadActive = const downloadActive =
downloadStatus === "pending" || downloadStatus === "downloading"; downloadStatus === "pending" || downloadStatus === "downloading";
// Never render another post's task — this covers the single frame before the
// remount (DetailPage keys this component per post). A null post_id (older or
// hand-made tasks) stays visible rather than hiding a real download.
const downloadTaskMatchesPost =
!downloadTask ||
downloadTask.post_id === null ||
downloadTask.post_id === post?.id;
// Derived state (safe to set during render): remember that this visit saw
// the download running.
if (downloadActive && !watchedDownload) {
setWatchedDownload(true);
}
useEffect(() => { useEffect(() => {
if (!downloadStatus) return; if (!downloadStatus) return;
@@ -340,7 +357,8 @@ export function OnlineDetail({ postId }: { postId: number }) {
</a> </a>
</div> </div>
{downloadTask && {downloadTaskMatchesPost &&
downloadTask &&
(downloadTask.status === "pending" || (downloadTask.status === "pending" ||
downloadTask.status === "downloading") ? ( downloadTask.status === "downloading") ? (
<div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3"> <div className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3">
@@ -372,7 +390,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
cancel cancel
</button> </button>
</div> </div>
) : downloadTask?.status === "complete" ? ( ) : downloadTaskMatchesPost && downloadTask?.status === "complete" ? (
<p className="flex items-center gap-2 rounded-md border border-ctp-green/30 bg-ctp-green/10 px-3 py-2 text-sm text-ctp-green"> <p className="flex items-center gap-2 rounded-md border border-ctp-green/30 bg-ctp-green/10 px-3 py-2 text-sm text-ctp-green">
<CheckCircle2 className="h-4 w-4 shrink-0" /> <CheckCircle2 className="h-4 w-4 shrink-0" />
Downloaded to the library Downloaded to the library
@@ -385,13 +403,13 @@ export function OnlineDetail({ postId }: { postId: number }) {
</Link> </Link>
) : null} ) : null}
</p> </p>
) : downloadTask?.status === "cancelled" ? ( ) : downloadTaskMatchesPost && downloadTask?.status === "cancelled" ? (
<p className="text-xs text-ctp-overlay0">Download cancelled.</p> <p className="text-xs text-ctp-overlay0">Download cancelled.</p>
) : downloadTask?.status === "error" ? ( ) : downloadTaskMatchesPost && downloadTask?.status === "error" ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red"> <p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{downloadTask.error || "Download failed."} {downloadTask.error || "Download failed."}
</p> </p>
) : remoteFinishedTask && !downloadMutation.isPending ? ( ) : remoteFinishedTask && watchedDownload && !downloadMutation.isPending ? (
<p className="flex items-center gap-2 text-xs text-ctp-overlay0"> <p className="flex items-center gap-2 text-xs text-ctp-overlay0">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-ctp-green" /> <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-ctp-green" />
Your last download for this post finished Your last download for this post finished