fix: resolve workflow form step auto-open race condition (#21053)

## Summary
- Fix intermittent failure where the Quick Lead workflow form step did
not auto-open
- Root cause: race conditions between SSE events, Apollo cache writes,
and the `runWorkflowVersion` mutation timing
- Add generic monotonicity guard in the SSE handler that drops stale
updates for all records (not just WorkflowRun)

## Changes
- **`useTriggerOptimisticEffectFromSseUpdateEvents.ts`**: Compare
incoming `updatedAt` with cached record before writing — skip if stale.
Moved `upsertRecordsInStore` after the guard so neither Apollo cache nor
Jotai store receive stale data.
- **`useRunWorkflowVersion.tsx`**: Await mutation before opening side
panel; register SSE listener eagerly before mutation
- **`useWorkflowRun.ts`**: Simplified back to plain `useFindOneRecord` +
schema parse (no extra state needed)
- **`generateWorkflowRunDiagram.ts`**: `shouldOpenStep` matches both
PENDING and RUNNING for form steps (backend RUNNING means "waiting for
user input")
- **`WorkflowRunVisualizerEffect.tsx`**: Pass `runStatus` directly
without status mapping
- **`WorkflowRunStepNodeDetail.tsx`**: Form is interactive when step is
PENDING or RUNNING
- **Deleted `latestWorkflowRunFamilyState.ts`**: No longer needed — the
generic SSE guard replaces it

## Test plan
- [x] Hard refresh, run Quick Lead workflow 10+ times — form should
always auto-open
- [x] Complete the form and verify all subsequent steps execute without
getting stuck
- [x] Verify the workflow diagram is always visible (never disappears)
- [x] Verify other record types still update correctly via SSE (e.g.
edit a person in another tab)
This commit is contained in:
Thomas Trompette
2026-05-29 16:18:24 +02:00
committed by GitHub
parent 57118a868f
commit bc1b7f6fdf
9 changed files with 104 additions and 52 deletions
@@ -15,7 +15,9 @@ import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useU
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { useChangeQueryListenState } from '@/sse-db-event/hooks/useChangeQueryListenState';
import { RUN_WORKFLOW_VERSION } from '@/workflow/graphql/mutations/runWorkflowVersion';
import { getWorkflowRunSseQueryId } from '@/workflow/utils/getWorkflowRunSseQueryId';
import { type WorkflowRun } from '@/workflow/types/Workflow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useCallback } from 'react';
@@ -63,6 +65,7 @@ export const useRunWorkflowVersion = () => {
});
const { openRecordInSidePanel } = useOpenRecordInSidePanel();
const { changeQueryIdListenState } = useChangeQueryListenState();
const setRecordInStore = useCallback(
(workflowRun: WorkflowRun) => {
@@ -137,9 +140,24 @@ export const useRunWorkflowVersion = () => {
setRecordInStore(recordCreatedInCache);
await mutate({
variables: { input: { workflowVersionId, workflowRunId, payload } },
});
const sseQueryId = getWorkflowRunSseQueryId(workflowRunId);
const sseOperationSignature = {
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
variables: {
filter: { id: { eq: workflowRunId } },
},
};
changeQueryIdListenState(true, sseQueryId, sseOperationSignature);
try {
await mutate({
variables: { input: { workflowVersionId, workflowRunId, payload } },
});
} catch (error) {
changeQueryIdListenState(false, sseQueryId, sseOperationSignature);
throw error;
}
openRecordInSidePanel({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
@@ -0,0 +1,2 @@
export const getWorkflowRunSseQueryId = (workflowRunId: string) =>
`workflow-run-${workflowRunId}`;
@@ -1,12 +1,13 @@
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { getWorkflowRunSseQueryId } from '@/workflow/utils/getWorkflowRunSseQueryId';
export const WorkflowRunSSESubscribeEffect = ({
workflowRunId,
}: {
workflowRunId: string;
}) => {
const queryId = `workflow-run-${workflowRunId}`;
const queryId = getWorkflowRunSseQueryId(workflowRunId);
useListenToEventsForQuery({
queryId,
@@ -20,6 +20,7 @@ import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useSte
import { useStore } from 'jotai';
import { useCallback, useContext, useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const WorkflowRunVisualizerEffect = ({
@@ -25,10 +25,16 @@ const shouldOpenStep = ({
}) => {
const step = steps.find((step) => step.id === nodeId);
const stepInfo = stepInfos?.[nodeId];
const isStepPending = isDefined(stepInfo) && stepInfo.status === 'PENDING';
const isStepOpenable = isDefined(step) && ['FORM'].includes(step.type);
return isStepPending && isStepOpenable;
if (!isStepOpenable || !isDefined(stepInfo)) {
return false;
}
return (
stepInfo.status === StepStatus.PENDING ||
stepInfo.status === StepStatus.RUNNING
);
};
export const generateWorkflowRunDiagram = ({
@@ -205,7 +205,9 @@ export const WorkflowRunStepNodeDetail = ({
key={stepId}
action={stepDefinition.definition}
actionOptions={{
readonly: stepExecutionStatus !== 'PENDING',
readonly:
stepExecutionStatus !== 'PENDING' &&
stepExecutionStatus !== 'RUNNING',
}}
/>
);