From 12a4bb08dcd689f676092fe3bded19e74fc69bab Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Wed, 9 Sep 2026 02:10:25 +0000 Subject: [PATCH 1/2] fix: bind restores to previewed scope (#6670) --- client/src/components/BackupWidget.jsx | 63 +++++++++---- client/src/components/BackupWidget.test.jsx | 97 +++++++++++++++++++++ 2 files changed, 144 insertions(+), 16 deletions(-) diff --git a/client/src/components/BackupWidget.jsx b/client/src/components/BackupWidget.jsx index 095cea943c..6b00a6054a 100644 --- a/client/src/components/BackupWidget.jsx +++ b/client/src/components/BackupWidget.jsx @@ -1,4 +1,4 @@ -import { useState, memo, useCallback, useId } from 'react'; +import { useState, memo, useCallback, useId, useRef } from 'react'; import { Link } from 'react-router'; import {HardDrive, ChevronDown, @@ -62,31 +62,59 @@ const HEALTH_STYLES = { function RestorePanel({ snapshot, onClose }) { const filterId = useId(); const [filter, setFilter] = useState(''); - const [preview, setPreview] = useState(null); + const [acceptedPreview, setAcceptedPreview] = useState(null); const [previewing, setPreviewing] = useState(false); const [restoring, setRestoring] = useState(false); + const previewGenerationRef = useRef(0); + + const currentRequest = { + snapshotId: snapshot.id, + subdirFilter: filter.trim() || null, + }; + const previewMatchesCurrentRequest = acceptedPreview + && acceptedPreview.request.snapshotId === currentRequest.snapshotId + && acceptedPreview.request.subdirFilter === currentRequest.subdirFilter; + + const handleFilterChange = useCallback((event) => { + previewGenerationRef.current += 1; + setFilter(event.target.value); + setAcceptedPreview(null); + setPreviewing(false); + }, []); const handlePreview = useCallback(async () => { - setPreviewing(true); - setPreview(null); - const result = await api.restoreBackup({ + const generation = previewGenerationRef.current + 1; + previewGenerationRef.current = generation; + const request = { snapshotId: snapshot.id, + subdirFilter: filter.trim() || null, + }; + setPreviewing(true); + setAcceptedPreview(null); + const outcome = await api.restoreBackup({ + ...request, dryRun: true, - subdirFilter: filter.trim() || null - }, { silent: true }).catch(err => { - toast.error(`Preview failed: ${err.message}`); - return null; - }); + }, { silent: true }).then( + previewResult => ({ previewResult }), + previewError => ({ previewError }), + ); + + if (previewGenerationRef.current !== generation) return; setPreviewing(false); - if (result) setPreview(result); + if (outcome.previewError) { + toast.error(`Preview failed: ${outcome.previewError.message}`); + return; + } + if (outcome.previewResult) setAcceptedPreview({ request, result: outcome.previewResult }); }, [snapshot.id, filter]); const handleRestore = useCallback(async () => { + if (!previewMatchesCurrentRequest || restoring) return; + setRestoring(true); const result = await api.restoreBackup({ - snapshotId: snapshot.id, + ...acceptedPreview.request, dryRun: false, - subdirFilter: filter.trim() || null }, { silent: true }).catch(err => { toast.error(`Restore failed: ${err.message}`); return null; @@ -96,7 +124,9 @@ function RestorePanel({ snapshot, onClose }) { toast.success(`Restore complete — ${result.changedFiles?.length ?? 0} file(s) restored`); onClose(); } - }, [snapshot.id, filter, onClose]); + }, [acceptedPreview, onClose, previewMatchesCurrentRequest, restoring]); + + const preview = previewMatchesCurrentRequest ? acceptedPreview.result : null; return (
@@ -121,7 +151,8 @@ function RestorePanel({ snapshot, onClose }) { id={filterId} type="text" value={filter} - onChange={e => setFilter(e.target.value)} + onChange={handleFilterChange} + disabled={restoring} placeholder="e.g., brain" className="w-full bg-port-card border border-port-border rounded px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent" /> @@ -133,7 +164,7 @@ function RestorePanel({ snapshot, onClose }) { {/* Preview button */} @@ -218,7 +218,7 @@ function RestorePanel({ snapshot, onClose }) { // SnapshotList // --------------------------------------------------------------------------- -function SnapshotList() { +function SnapshotList({ restoringSnapshotId, onRestoreStateChange }) { // Let errors throw — `useAutoRefetch` preserves the last-good data on // transient failures. A `.catch(() => null)` here would wipe the snapshot // list on every blip per the hook's documented gotcha. @@ -285,7 +285,7 @@ function SnapshotList() {
@@ -327,6 +329,7 @@ const BackupWidget = memo(function BackupWidget() { ); const [handleBackupNow, triggering] = useBackupRun(); const [snapshotsOpen, setSnapshotsOpen] = useState(false); + const [restoringSnapshotId, setRestoringSnapshotId] = useState(null); // Tick every minute so the dedup-skipped widget still recomputes // `relativeTime(lastRun/nextRun)` labels and the `computeHealth` 25h/49h // thresholds when wall-clock time crosses a boundary even though the poll @@ -438,7 +441,8 @@ const BackupWidget = memo(function BackupWidget() { {/* Toggle snapshots */}