fix(stack-update): refresh frontend state automatically after a stack update (#1113)

After applying a stack update the sidebar's "update available" dot stayed
visible and the stack's status indicator was stuck on the optimistic value
until the page was manually refreshed. Two root causes:

1. Image-updates state refresh was a fire-and-forget call in some paths and
   entirely missing from the bulk-update, auto-update, and state-invalidate
   WebSocket-handler paths.
2. stackActionsRef.current was resynced only at render time, so the post-
   update refreshStacks(true) running in the action's finally block read a
   stale "busy" map and preserved the optimistic mask via prev[file] ?? status.

Backend now broadcasts a state-invalidate event with scope='image-updates'
and action='stack-updated' after every successful update (single-stack route
and auto-update loop). The frontend useNotifications hook routes this to a
new onImageUpdatesChange callback wired to fetchImageUpdates in EditorLayout,
so every connected client refreshes the dot through the same code path.

Bulk update also calls fetchImageUpdates directly for fast local feedback,
and setStackAction/clearStackAction now keep stackActionsRef synchronously
in sync with state so the busy-stack check inside refreshStacks observes
the cleared map immediately.

Adds 3 unit tests covering the new WS branch (positive, scope-mismatch
negative, auto-update-settings-changed negative).
This commit is contained in:
Anso
2026-05-19 17:56:41 -04:00
committed by GitHub
parent 7f81f06bbd
commit 6722335a79
6 changed files with 113 additions and 16 deletions
@@ -37,7 +37,6 @@ export function useStackListState() {
const [isLoading, setIsLoading] = useState(false);
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
const stackActionsRef = useRef<Record<string, StackAction>>({});
stackActionsRef.current = stackActions;
const [isScanning, setIsScanning] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -65,15 +64,21 @@ export function useStackListState() {
if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).');
}, [evictedOldest]);
// Ref is updated synchronously alongside the state setter so any code that
// runs right after (e.g. `refreshStacks(true)` in an action's finally block)
// observes the cleared map before React commits the next render. Without
// this, the busy-stack check inside refreshStacks would still flag the
// stack as in-progress and preserve the optimistic status mask.
const setStackAction = (stackFile: string, action: StackAction) => {
setStackActions(prev => ({ ...prev, [stackFile]: action }));
const next = { ...stackActionsRef.current, [stackFile]: action };
stackActionsRef.current = next;
setStackActions(next);
};
const clearStackAction = (stackFile: string) => {
setStackActions(prev => {
const next = { ...prev };
delete next[stackFile];
return next;
});
const next = { ...stackActionsRef.current };
delete next[stackFile];
stackActionsRef.current = next;
setStackActions(next);
};
const isStackBusy = useCallback((stackFile: string) => stackFile in stackActionsRef.current, []);
@@ -271,9 +276,13 @@ export function useStackListState() {
const handleBulkAction = useCallback((action: BulkAction) => {
const filesToAction = Array.from(selectedFiles);
runBulk(action, filesToAction, {
onAfter: () => { refreshStacksRef.current(true); clearSelection(); },
onAfter: () => {
refreshStacksRef.current(true);
if (action === 'update') void fetchImageUpdates();
clearSelection();
},
});
}, [selectedFiles, runBulk, clearSelection]);
}, [selectedFiles, runBulk, clearSelection, fetchImageUpdates]);
const chipFilteredFilesRef = useRef(chipFilteredFiles);
useEffect(() => { chipFilteredFilesRef.current = chipFilteredFiles; }, [chipFilteredFiles]);