mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 12:23:07 +00:00
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
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
// @ts-nocheck - feature update logic with partial updates and image/file handling
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Feature,
|
||||
FeatureImage,
|
||||
@@ -18,7 +17,10 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
|
||||
import { truncateDescription } from '@/lib/utils';
|
||||
import { getBlockingDependencies } from '@automaker/dependency-resolver';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import {
|
||||
markFeatureTransitioning,
|
||||
unmarkFeatureTransitioning,
|
||||
} from '@/lib/feature-transition-state';
|
||||
|
||||
const logger = createLogger('BoardActions');
|
||||
|
||||
@@ -116,8 +118,6 @@ export function useBoardActions({
|
||||
currentWorktreeBranch,
|
||||
stopFeature,
|
||||
}: UseBoardActionsProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
|
||||
// subscribing to the entire store. Bare useAppStore() causes the host component
|
||||
// (BoardView) to re-render on EVERY store change, which cascades through effects
|
||||
@@ -125,7 +125,6 @@ export function useBoardActions({
|
||||
const addFeature = useAppStore((s) => s.addFeature);
|
||||
const updateFeature = useAppStore((s) => s.updateFeature);
|
||||
const removeFeature = useAppStore((s) => s.removeFeature);
|
||||
const moveFeature = useAppStore((s) => s.moveFeature);
|
||||
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
|
||||
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
|
||||
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
|
||||
@@ -707,8 +706,7 @@ export function useBoardActions({
|
||||
try {
|
||||
const result = await verifyFeatureMutation.mutateAsync(feature.id);
|
||||
if (result.passes) {
|
||||
// Immediately move card to verified column (optimistic update)
|
||||
moveFeature(feature.id, 'verified');
|
||||
// persistFeatureUpdate handles the optimistic RQ cache update internally
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: 'verified',
|
||||
justFinishedAt: undefined,
|
||||
@@ -725,7 +723,7 @@ export function useBoardActions({
|
||||
// Error toast is already shown by the mutation's onError handler
|
||||
}
|
||||
},
|
||||
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
|
||||
[currentProject, verifyFeatureMutation, persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleResumeFeature = useCallback(
|
||||
@@ -742,7 +740,6 @@ export function useBoardActions({
|
||||
|
||||
const handleManualVerify = useCallback(
|
||||
(feature: Feature) => {
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, {
|
||||
status: 'verified',
|
||||
justFinishedAt: undefined,
|
||||
@@ -751,7 +748,7 @@ export function useBoardActions({
|
||||
description: `Marked as verified: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[moveFeature, persistFeatureUpdate]
|
||||
[persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleMoveBackToInProgress = useCallback(
|
||||
@@ -760,13 +757,12 @@ export function useBoardActions({
|
||||
status: 'in_progress' as const,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
toast.info('Feature moved back', {
|
||||
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
[persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleOpenFollowUp = useCallback(
|
||||
@@ -885,7 +881,6 @@ export function useBoardActions({
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
moveFeature(feature.id, 'verified');
|
||||
persistFeatureUpdate(feature.id, { status: 'verified' });
|
||||
toast.success('Feature committed', {
|
||||
description: `Committed and verified: ${truncateDescription(feature.description)}`,
|
||||
@@ -907,7 +902,7 @@ export function useBoardActions({
|
||||
await loadFeatures();
|
||||
}
|
||||
},
|
||||
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
|
||||
[currentProject, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
|
||||
);
|
||||
|
||||
const handleMergeFeature = useCallback(
|
||||
@@ -951,17 +946,12 @@ export function useBoardActions({
|
||||
|
||||
const handleCompleteFeature = useCallback(
|
||||
(feature: Feature) => {
|
||||
const updates = {
|
||||
status: 'completed' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
|
||||
persistFeatureUpdate(feature.id, { status: 'completed' as const });
|
||||
toast.success('Feature completed', {
|
||||
description: `Archived: ${truncateDescription(feature.description)}`,
|
||||
});
|
||||
},
|
||||
[updateFeature, persistFeatureUpdate]
|
||||
[persistFeatureUpdate]
|
||||
);
|
||||
|
||||
const handleUnarchiveFeature = useCallback(
|
||||
@@ -978,11 +968,7 @@ export function useBoardActions({
|
||||
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
|
||||
: featureBranch === currentWorktreeBranch;
|
||||
|
||||
const updates: Partial<Feature> = {
|
||||
status: 'verified' as const,
|
||||
};
|
||||
updateFeature(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, updates);
|
||||
persistFeatureUpdate(feature.id, { status: 'verified' as const });
|
||||
|
||||
if (willBeVisibleOnCurrentView) {
|
||||
toast.success('Feature restored', {
|
||||
@@ -994,13 +980,7 @@ export function useBoardActions({
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
currentWorktreeBranch,
|
||||
projectPath,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
[persistFeatureUpdate, currentWorktreeBranch, projectPath, isPrimaryWorktreeBranch]
|
||||
);
|
||||
|
||||
const handleViewOutput = useCallback(
|
||||
@@ -1031,6 +1011,13 @@ export function useBoardActions({
|
||||
|
||||
const handleForceStopFeature = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Mark this feature as transitioning so WebSocket-driven query invalidation
|
||||
// (useAutoModeQueryInvalidation) skips redundant cache invalidations while
|
||||
// persistFeatureUpdate is handling the optimistic update. Without this guard,
|
||||
// auto_mode_error / auto_mode_stopped WS events race with the optimistic
|
||||
// update and cause cache flip-flops that cascade through useBoardColumnFeatures,
|
||||
// triggering React error #185 on mobile.
|
||||
markFeatureTransitioning(feature.id);
|
||||
try {
|
||||
await stopFeature(feature.id);
|
||||
|
||||
@@ -1048,25 +1035,11 @@ export function useBoardActions({
|
||||
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
|
||||
}
|
||||
|
||||
// Optimistically update the React Query features cache so the board
|
||||
// moves the card immediately. Without this, the card stays in
|
||||
// "in_progress" until the next poll cycle (30s) because the async
|
||||
// refetch races with the persistFeatureUpdate write.
|
||||
if (currentProject) {
|
||||
queryClient.setQueryData(
|
||||
queryKeys.features.all(currentProject.path),
|
||||
(oldFeatures: Feature[] | undefined) => {
|
||||
if (!oldFeatures) return oldFeatures;
|
||||
return oldFeatures.map((f) =>
|
||||
f.id === feature.id ? { ...f, status: targetStatus } : f
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (targetStatus !== feature.status) {
|
||||
moveFeature(feature.id, targetStatus);
|
||||
// Must await to ensure file is written before user can restart
|
||||
// persistFeatureUpdate handles the optimistic RQ cache update, the
|
||||
// Zustand store update (on server response), and the final cache
|
||||
// invalidation internally — no need for separate queryClient.setQueryData
|
||||
// or moveFeature calls which would cause redundant re-renders.
|
||||
await persistFeatureUpdate(feature.id, { status: targetStatus });
|
||||
}
|
||||
|
||||
@@ -1083,9 +1056,15 @@ export function useBoardActions({
|
||||
toast.error('Failed to stop agent', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
} finally {
|
||||
// Delay unmarking so the refetch triggered by persistFeatureUpdate's
|
||||
// invalidateQueries() has time to settle before WS-driven invalidations
|
||||
// are allowed through again. Without this, a WS event arriving during
|
||||
// the refetch window would trigger a conflicting invalidation.
|
||||
setTimeout(() => unmarkFeatureTransitioning(feature.id), 500);
|
||||
}
|
||||
},
|
||||
[stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient]
|
||||
[stopFeature, persistFeatureUpdate, currentProject]
|
||||
);
|
||||
|
||||
const handleStartNextFeatures = useCallback(async () => {
|
||||
|
||||
Reference in New Issue
Block a user