Skip to content

Commit dd7108a

Browse files
authored
Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash * fix: Prevent cascading re-renders and crashes from high-frequency WS events
1 parent ae48065 commit dd7108a

9 files changed

Lines changed: 211 additions & 152 deletions

File tree

apps/ui/src/components/views/board-view/hooks/use-board-actions.ts

Lines changed: 31 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// @ts-nocheck - feature update logic with partial updates and image/file handling
22
import { useCallback } from 'react';
3-
import { useQueryClient } from '@tanstack/react-query';
43
import {
54
Feature,
65
FeatureImage,
@@ -18,7 +17,10 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
1817
import { truncateDescription } from '@/lib/utils';
1918
import { getBlockingDependencies } from '@automaker/dependency-resolver';
2019
import { createLogger } from '@automaker/utils/logger';
21-
import { queryKeys } from '@/lib/query-keys';
20+
import {
21+
markFeatureTransitioning,
22+
unmarkFeatureTransitioning,
23+
} from '@/lib/feature-transition-state';
2224

2325
const logger = createLogger('BoardActions');
2426

@@ -116,16 +118,13 @@ export function useBoardActions({
116118
currentWorktreeBranch,
117119
stopFeature,
118120
}: UseBoardActionsProps) {
119-
const queryClient = useQueryClient();
120-
121121
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
122122
// subscribing to the entire store. Bare useAppStore() causes the host component
123123
// (BoardView) to re-render on EVERY store change, which cascades through effects
124124
// and triggers React error #185 (maximum update depth exceeded).
125125
const addFeature = useAppStore((s) => s.addFeature);
126126
const updateFeature = useAppStore((s) => s.updateFeature);
127127
const removeFeature = useAppStore((s) => s.removeFeature);
128-
const moveFeature = useAppStore((s) => s.moveFeature);
129128
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
130129
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
131130
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
@@ -707,8 +706,7 @@ export function useBoardActions({
707706
try {
708707
const result = await verifyFeatureMutation.mutateAsync(feature.id);
709708
if (result.passes) {
710-
// Immediately move card to verified column (optimistic update)
711-
moveFeature(feature.id, 'verified');
709+
// persistFeatureUpdate handles the optimistic RQ cache update internally
712710
persistFeatureUpdate(feature.id, {
713711
status: 'verified',
714712
justFinishedAt: undefined,
@@ -725,7 +723,7 @@ export function useBoardActions({
725723
// Error toast is already shown by the mutation's onError handler
726724
}
727725
},
728-
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
726+
[currentProject, verifyFeatureMutation, persistFeatureUpdate]
729727
);
730728

731729
const handleResumeFeature = useCallback(
@@ -742,7 +740,6 @@ export function useBoardActions({
742740

743741
const handleManualVerify = useCallback(
744742
(feature: Feature) => {
745-
moveFeature(feature.id, 'verified');
746743
persistFeatureUpdate(feature.id, {
747744
status: 'verified',
748745
justFinishedAt: undefined,
@@ -751,7 +748,7 @@ export function useBoardActions({
751748
description: `Marked as verified: ${truncateDescription(feature.description)}`,
752749
});
753750
},
754-
[moveFeature, persistFeatureUpdate]
751+
[persistFeatureUpdate]
755752
);
756753

757754
const handleMoveBackToInProgress = useCallback(
@@ -760,13 +757,12 @@ export function useBoardActions({
760757
status: 'in_progress' as const,
761758
startedAt: new Date().toISOString(),
762759
};
763-
updateFeature(feature.id, updates);
764760
persistFeatureUpdate(feature.id, updates);
765761
toast.info('Feature moved back', {
766762
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
767763
});
768764
},
769-
[updateFeature, persistFeatureUpdate]
765+
[persistFeatureUpdate]
770766
);
771767

772768
const handleOpenFollowUp = useCallback(
@@ -885,7 +881,6 @@ export function useBoardActions({
885881
);
886882

887883
if (result.success) {
888-
moveFeature(feature.id, 'verified');
889884
persistFeatureUpdate(feature.id, { status: 'verified' });
890885
toast.success('Feature committed', {
891886
description: `Committed and verified: ${truncateDescription(feature.description)}`,
@@ -907,7 +902,7 @@ export function useBoardActions({
907902
await loadFeatures();
908903
}
909904
},
910-
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
905+
[currentProject, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
911906
);
912907

913908
const handleMergeFeature = useCallback(
@@ -951,17 +946,12 @@ export function useBoardActions({
951946

952947
const handleCompleteFeature = useCallback(
953948
(feature: Feature) => {
954-
const updates = {
955-
status: 'completed' as const,
956-
};
957-
updateFeature(feature.id, updates);
958-
persistFeatureUpdate(feature.id, updates);
959-
949+
persistFeatureUpdate(feature.id, { status: 'completed' as const });
960950
toast.success('Feature completed', {
961951
description: `Archived: ${truncateDescription(feature.description)}`,
962952
});
963953
},
964-
[updateFeature, persistFeatureUpdate]
954+
[persistFeatureUpdate]
965955
);
966956

967957
const handleUnarchiveFeature = useCallback(
@@ -978,11 +968,7 @@ export function useBoardActions({
978968
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
979969
: featureBranch === currentWorktreeBranch;
980970

981-
const updates: Partial<Feature> = {
982-
status: 'verified' as const,
983-
};
984-
updateFeature(feature.id, updates);
985-
persistFeatureUpdate(feature.id, updates);
971+
persistFeatureUpdate(feature.id, { status: 'verified' as const });
986972

987973
if (willBeVisibleOnCurrentView) {
988974
toast.success('Feature restored', {
@@ -994,13 +980,7 @@ export function useBoardActions({
994980
});
995981
}
996982
},
997-
[
998-
updateFeature,
999-
persistFeatureUpdate,
1000-
currentWorktreeBranch,
1001-
projectPath,
1002-
isPrimaryWorktreeBranch,
1003-
]
983+
[persistFeatureUpdate, currentWorktreeBranch, projectPath, isPrimaryWorktreeBranch]
1004984
);
1005985

1006986
const handleViewOutput = useCallback(
@@ -1031,6 +1011,13 @@ export function useBoardActions({
10311011

10321012
const handleForceStopFeature = useCallback(
10331013
async (feature: Feature) => {
1014+
// Mark this feature as transitioning so WebSocket-driven query invalidation
1015+
// (useAutoModeQueryInvalidation) skips redundant cache invalidations while
1016+
// persistFeatureUpdate is handling the optimistic update. Without this guard,
1017+
// auto_mode_error / auto_mode_stopped WS events race with the optimistic
1018+
// update and cause cache flip-flops that cascade through useBoardColumnFeatures,
1019+
// triggering React error #185 on mobile.
1020+
markFeatureTransitioning(feature.id);
10341021
try {
10351022
await stopFeature(feature.id);
10361023

@@ -1048,25 +1035,11 @@ export function useBoardActions({
10481035
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
10491036
}
10501037

1051-
// Optimistically update the React Query features cache so the board
1052-
// moves the card immediately. Without this, the card stays in
1053-
// "in_progress" until the next poll cycle (30s) because the async
1054-
// refetch races with the persistFeatureUpdate write.
1055-
if (currentProject) {
1056-
queryClient.setQueryData(
1057-
queryKeys.features.all(currentProject.path),
1058-
(oldFeatures: Feature[] | undefined) => {
1059-
if (!oldFeatures) return oldFeatures;
1060-
return oldFeatures.map((f) =>
1061-
f.id === feature.id ? { ...f, status: targetStatus } : f
1062-
);
1063-
}
1064-
);
1065-
}
1066-
10671038
if (targetStatus !== feature.status) {
1068-
moveFeature(feature.id, targetStatus);
1069-
// Must await to ensure file is written before user can restart
1039+
// persistFeatureUpdate handles the optimistic RQ cache update, the
1040+
// Zustand store update (on server response), and the final cache
1041+
// invalidation internally — no need for separate queryClient.setQueryData
1042+
// or moveFeature calls which would cause redundant re-renders.
10701043
await persistFeatureUpdate(feature.id, { status: targetStatus });
10711044
}
10721045

@@ -1083,9 +1056,15 @@ export function useBoardActions({
10831056
toast.error('Failed to stop agent', {
10841057
description: error instanceof Error ? error.message : 'An error occurred',
10851058
});
1059+
} finally {
1060+
// Delay unmarking so the refetch triggered by persistFeatureUpdate's
1061+
// invalidateQueries() has time to settle before WS-driven invalidations
1062+
// are allowed through again. Without this, a WS event arriving during
1063+
// the refetch window would trigger a conflicting invalidation.
1064+
setTimeout(() => unmarkFeatureTransitioning(feature.id), 500);
10861065
}
10871066
},
1088-
[stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient]
1067+
[stopFeature, persistFeatureUpdate, currentProject]
10891068
);
10901069

10911070
const handleStartNextFeatures = useCallback(async () => {

apps/ui/src/components/views/board-view/hooks/use-board-column-features.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @ts-nocheck - column filtering logic with dependency resolution and status mapping
2-
import { useMemo, useCallback, useEffect, useRef } from 'react';
2+
import { useMemo, useCallback, useEffect } from 'react';
33
import { Feature, useAppStore } from '@/store/app-store';
44
import {
55
createFeatureMap,
@@ -177,9 +177,6 @@ export function useBoardColumnFeatures({
177177
(state) => state.clearRecentlyCompletedFeatures
178178
);
179179

180-
// Track previous feature IDs to detect when features list has been refreshed
181-
const prevFeatureIdsRef = useRef<Set<string>>(new Set());
182-
183180
// Clear recently completed features when the cache refreshes with updated statuses.
184181
//
185182
// RACE CONDITION SCENARIO THIS PREVENTS:
@@ -193,22 +190,24 @@ export function useBoardColumnFeatures({
193190
//
194191
// When the refetch completes with fresh data (status='verified'/'completed'),
195192
// this effect clears the recentlyCompletedFeatures set since it's no longer needed.
193+
// Clear recently completed features when the cache refreshes with updated statuses.
194+
// IMPORTANT: Only depend on `features` (not `recentlyCompletedFeatures`) to avoid a
195+
// re-trigger loop where clearing the set creates a new reference that re-fires this effect.
196+
// Read recentlyCompletedFeatures from the store directly to get the latest value without
197+
// subscribing to it as a dependency.
196198
useEffect(() => {
197-
const currentIds = new Set(features.map((f) => f.id));
199+
const currentRecentlyCompleted = useAppStore.getState().recentlyCompletedFeatures;
200+
if (currentRecentlyCompleted.size === 0) return;
198201

199-
// Check if any recently completed features now have terminal statuses in the new data
200-
// If so, we can clear the tracking since the cache is now fresh
201-
const hasUpdatedStatus = Array.from(recentlyCompletedFeatures).some((featureId) => {
202+
const hasUpdatedStatus = Array.from(currentRecentlyCompleted).some((featureId) => {
202203
const feature = features.find((f) => f.id === featureId);
203204
return feature && (feature.status === 'verified' || feature.status === 'completed');
204205
});
205206

206207
if (hasUpdatedStatus) {
207208
clearRecentlyCompletedFeatures();
208209
}
209-
210-
prevFeatureIdsRef.current = currentIds;
211-
}, [features, recentlyCompletedFeatures, clearRecentlyCompletedFeatures]);
210+
}, [features, clearRecentlyCompletedFeatures]);
212211

213212
// Memoize column features to prevent unnecessary re-renders
214213
const columnFeaturesMap = useMemo(() => {

apps/ui/src/components/views/board-view/hooks/use-board-drag-drop.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ export function useBoardDragDrop({
3838
// subscribing to the entire store. Bare useAppStore() causes the host component
3939
// (BoardView) to re-render on EVERY store change, which cascades through effects
4040
// and triggers React error #185 (maximum update depth exceeded).
41-
const moveFeature = useAppStore((s) => s.moveFeature);
4241
const updateFeature = useAppStore((s) => s.updateFeature);
4342

4443
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
@@ -207,23 +206,22 @@ export function useBoardDragDrop({
207206
if (targetStatus === draggedFeature.status) return;
208207

209208
// Handle different drag scenarios
210-
// Note: Worktrees are created server-side at execution time based on feature.branchName
209+
// Note: persistFeatureUpdate handles optimistic RQ cache update internally,
210+
// so no separate moveFeature() call is needed.
211211
if (draggedFeature.status === 'backlog' || draggedFeature.status === 'merge_conflict') {
212212
// From backlog
213213
if (targetStatus === 'in_progress') {
214214
// Use helper function to handle concurrency check and start implementation
215215
// Server will derive workDir from feature.branchName
216216
await handleStartImplementation(draggedFeature);
217217
} else {
218-
moveFeature(featureId, targetStatus);
219218
persistFeatureUpdate(featureId, { status: targetStatus });
220219
}
221220
} else if (draggedFeature.status === 'waiting_approval') {
222221
// waiting_approval features can be dragged to verified for manual verification
223222
// NOTE: This check must come BEFORE skipTests check because waiting_approval
224223
// features often have skipTests=true, and we want status-based handling first
225224
if (targetStatus === 'verified') {
226-
moveFeature(featureId, 'verified');
227225
// Clear justFinishedAt timestamp when manually verifying via drag
228226
persistFeatureUpdate(featureId, {
229227
status: 'verified',
@@ -237,7 +235,6 @@ export function useBoardDragDrop({
237235
});
238236
} else if (targetStatus === 'backlog') {
239237
// Allow moving waiting_approval cards back to backlog
240-
moveFeature(featureId, 'backlog');
241238
// Clear justFinishedAt timestamp when moving back to backlog
242239
persistFeatureUpdate(featureId, {
243240
status: 'backlog',
@@ -269,7 +266,6 @@ export function useBoardDragDrop({
269266
});
270267
}
271268
}
272-
moveFeature(featureId, 'backlog');
273269
persistFeatureUpdate(featureId, { status: 'backlog' });
274270
toast.info(
275271
isRunningTask
@@ -291,7 +287,6 @@ export function useBoardDragDrop({
291287
return;
292288
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
293289
// Manual verify via drag (only for skipTests features)
294-
moveFeature(featureId, 'verified');
295290
persistFeatureUpdate(featureId, { status: 'verified' });
296291
toast.success('Feature verified', {
297292
description: `Marked as verified: ${draggedFeature.description.slice(
@@ -304,7 +299,6 @@ export function useBoardDragDrop({
304299
// skipTests feature being moved between verified and waiting_approval
305300
if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') {
306301
// Move verified feature back to waiting_approval
307-
moveFeature(featureId, 'waiting_approval');
308302
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
309303
toast.info('Feature moved back', {
310304
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
@@ -314,7 +308,6 @@ export function useBoardDragDrop({
314308
});
315309
} else if (targetStatus === 'backlog') {
316310
// Allow moving skipTests cards back to backlog (from verified)
317-
moveFeature(featureId, 'backlog');
318311
persistFeatureUpdate(featureId, { status: 'backlog' });
319312
toast.info('Feature moved to backlog', {
320313
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -327,7 +320,6 @@ export function useBoardDragDrop({
327320
// Handle verified TDD (non-skipTests) features being moved back
328321
if (targetStatus === 'waiting_approval') {
329322
// Move verified feature back to waiting_approval
330-
moveFeature(featureId, 'waiting_approval');
331323
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
332324
toast.info('Feature moved back', {
333325
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
@@ -337,7 +329,6 @@ export function useBoardDragDrop({
337329
});
338330
} else if (targetStatus === 'backlog') {
339331
// Allow moving verified cards back to backlog
340-
moveFeature(featureId, 'backlog');
341332
persistFeatureUpdate(featureId, { status: 'backlog' });
342333
toast.info('Feature moved to backlog', {
343334
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -351,7 +342,6 @@ export function useBoardDragDrop({
351342
[
352343
features,
353344
runningAutoTasks,
354-
moveFeature,
355345
updateFeature,
356346
persistFeatureUpdate,
357347
handleStartImplementation,

0 commit comments

Comments
 (0)