Set viewport when nodes dimensions are ready (#12730)

Sometimes, we try to set the viewport, but the nodes' dimensions have
been reset. Trying to set the viewport when the nodes' dimensions are
incorrect leads to an incorrect viewport.

This PR ensures we only try to set the viewport if the nodes' dimensions
are valid. Otherwise, we wait for them to be computed to set the
viewport automatically.

The `handleNodesChanges` function is called every time the nodes change,
including when the dimensions have been computed.

Internally, Reactflow has a similar behavior to implement the `fitView`
feature:
f9971a8fad/packages/react/src/store/index.ts (L111).

## Example

This is more notable since I added optimistic rendering to workflow
runs.


https://github.com/user-attachments/assets/07232050-b808-4345-b82b-95acad72ab15
This commit is contained in:
Baptiste Devessier
2025-06-19 18:47:36 +02:00
committed by GitHub
parent 58e1e69280
commit 79e5ccfd37
2 changed files with 174 additions and 86 deletions

View File

@ -1,18 +1,23 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/components/WorkflowDiagramCustomMarkers';
import { useRightDrawerState } from '@/workflow/workflow-diagram/hooks/useRightDrawerState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowDiagramWaitingNodesDimensionsComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramEdgeType,
WorkflowDiagramNode,
WorkflowDiagramNodeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
@ -28,18 +33,11 @@ import {
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Tag, TagColor } from 'twenty-ui/components';
import { THEME_COMMON } from 'twenty-ui/theme';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
const StyledResetReactflowStyles = styled.div`
height: 100%;
@ -134,15 +132,25 @@ export const WorkflowDiagramCanvasBase = ({
const workflowDiagram = useRecoilComponentValueV2(
workflowDiagramComponentState,
);
const workflowDiagramState = useRecoilComponentCallbackStateV2(
workflowDiagramComponentState,
);
const setWorkflowDiagram = useSetRecoilComponentStateV2(
workflowDiagramComponentState,
);
const setWorkflowInsertStepIds = useSetRecoilComponentStateV2(
workflowInsertStepIdsComponentState,
);
const workflowDiagramWaitingNodesDimensionsState =
useRecoilComponentCallbackStateV2(
workflowDiagramWaitingNodesDimensionsComponentState,
);
const setWorkflowDiagramWaitingNodesDimensions = useSetRecoilComponentStateV2(
workflowDiagramWaitingNodesDimensionsComponentState,
);
const [
workflowDiagramFlowInitializationStatus,
setWorkflowDiagramFlowInitializationStatus,
] = useState<'not-initialized' | 'initialized'>('not-initialized');
const [workflowDiagramFlowInitialized, setWorkflowDiagramFlowInitialized] =
useState<boolean>(false);
const { nodes, edges } = useMemo(
() =>
@ -155,10 +163,6 @@ export const WorkflowDiagramCanvasBase = ({
const { rightDrawerState } = useRightDrawerState();
const { isInRightDrawer } = useContext(ActionMenuContext);
const setWorkflowDiagram = useSetRecoilComponentStateV2(
workflowDiagramComponentState,
);
const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
) => {
@ -188,97 +192,172 @@ export const WorkflowDiagramCanvasBase = ({
const containerRef = useRef<HTMLDivElement>(null);
const setFlowViewport = useCallback(
({
rightDrawerState,
noAnimation,
workflowDiagramFlowInitializationStatus,
isInRightDrawer,
}: {
rightDrawerState: CommandMenuAnimationVariant;
noAnimation?: boolean;
workflowDiagramFlowInitializationStatus:
| 'not-initialized'
| 'initialized';
isInRightDrawer: boolean;
}) => {
if (
!isDefined(containerRef.current) ||
workflowDiagramFlowInitializationStatus !== 'initialized'
) {
return;
}
const setFlowViewport = useRecoilCallback(
() =>
({
rightDrawerState,
noAnimation,
workflowDiagramFlowInitialized,
isInRightDrawer,
workflowDiagram,
}: {
rightDrawerState: CommandMenuAnimationVariant;
noAnimation?: boolean;
workflowDiagramFlowInitialized: boolean;
isInRightDrawer: boolean;
workflowDiagram: WorkflowDiagram | undefined;
}) => {
if (
!isDefined(containerRef.current) ||
!workflowDiagramFlowInitialized
) {
return;
}
const currentViewport = reactflow.getViewport();
const flowBounds = reactflow.getNodesBounds(reactflow.getNodes());
const currentViewport = reactflow.getViewport();
const nodes = workflowDiagram?.nodes ?? [];
let visibleRightDrawerWidth = 0;
if (rightDrawerState === 'normal' && !isInRightDrawer) {
const rightDrawerWidth = Number(
THEME_COMMON.rightDrawerWidth.replace('px', ''),
const canComputeNodesBounds = nodes.every((node) =>
isDefined(node.measured),
);
visibleRightDrawerWidth = rightDrawerWidth;
}
if (!canComputeNodesBounds) {
setWorkflowDiagramWaitingNodesDimensions(true);
return;
}
const viewportX =
(containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 -
flowBounds.width / 2;
setWorkflowDiagramWaitingNodesDimensions(false);
reactflow.setViewport(
{
...currentViewport,
x: viewportX - visibleRightDrawerWidth,
zoom: defaultFitViewOptions.maxZoom,
},
{ duration: noAnimation ? 0 : 300 },
);
},
[reactflow],
let visibleRightDrawerWidth = 0;
if (rightDrawerState === 'normal' && !isInRightDrawer) {
const rightDrawerWidth = Number(
THEME_COMMON.rightDrawerWidth.replace('px', ''),
);
visibleRightDrawerWidth = rightDrawerWidth;
}
const flowBounds = reactflow.getNodesBounds(nodes);
const viewportX =
(containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 -
flowBounds.width / 2;
reactflow.setViewport(
{
...currentViewport,
x: viewportX - visibleRightDrawerWidth,
zoom: defaultFitViewOptions.maxZoom,
},
{ duration: noAnimation ? 0 : 300 },
);
},
[reactflow, setWorkflowDiagramWaitingNodesDimensions],
);
const handleSetFlowViewportOnChange = useRecoilCallback(
({ snapshot }) =>
({
rightDrawerState,
workflowDiagramFlowInitialized,
isInRightDrawer,
}: {
rightDrawerState: CommandMenuAnimationVariant;
workflowDiagramFlowInitialized: boolean;
isInRightDrawer: boolean;
}) => {
setFlowViewport({
rightDrawerState,
isInRightDrawer,
workflowDiagramFlowInitialized,
workflowDiagram: getSnapshotValue(snapshot, workflowDiagramState),
});
},
[setFlowViewport, workflowDiagramState],
);
useEffect(() => {
setFlowViewport({
handleSetFlowViewportOnChange({
rightDrawerState,
workflowDiagramFlowInitialized,
isInRightDrawer,
workflowDiagramFlowInitializationStatus,
});
}, [
handleSetFlowViewportOnChange,
isInRightDrawer,
rightDrawerState,
setFlowViewport,
workflowDiagramFlowInitializationStatus,
workflowDiagramFlowInitialized,
]);
const handleNodesChanges = (changes: NodeChange<WorkflowDiagramNode>[]) => {
setWorkflowDiagram((diagram) => {
if (!isDefined(diagram)) {
return diagram;
}
const handleNodesChanges = useRecoilCallback(
({ snapshot, set }) =>
(changes: NodeChange<WorkflowDiagramNode>[]) => {
const workflowDiagram = getSnapshotValue(
snapshot,
workflowDiagramState,
);
let updatedWorkflowDiagram = workflowDiagram;
if (isDefined(workflowDiagram)) {
updatedWorkflowDiagram = {
...workflowDiagram,
nodes: applyNodeChanges(changes, workflowDiagram.nodes),
};
}
return {
...diagram,
nodes: applyNodeChanges(changes, diagram.nodes),
};
});
};
set(workflowDiagramState, updatedWorkflowDiagram);
const handleInit = () => {
if (!isDefined(containerRef.current)) {
return;
}
const workflowDiagramWaitingNodesDimensions = getSnapshotValue(
snapshot,
workflowDiagramWaitingNodesDimensionsState,
);
if (!workflowDiagramWaitingNodesDimensions) {
return;
}
setFlowViewport({
rightDrawerState,
noAnimation: true,
setFlowViewport({
rightDrawerState,
noAnimation: true,
isInRightDrawer,
workflowDiagramFlowInitialized,
workflowDiagram: updatedWorkflowDiagram,
});
},
[
isInRightDrawer,
workflowDiagramFlowInitializationStatus: 'initialized',
});
rightDrawerState,
setFlowViewport,
workflowDiagramFlowInitialized,
workflowDiagramState,
workflowDiagramWaitingNodesDimensionsState,
],
);
setWorkflowDiagramFlowInitializationStatus('initialized');
const handleInit = useRecoilCallback(
({ snapshot }) =>
() => {
if (!isDefined(containerRef.current)) {
return;
}
onInit?.();
};
setFlowViewport({
rightDrawerState,
noAnimation: true,
isInRightDrawer,
workflowDiagramFlowInitialized: true,
workflowDiagram: getSnapshotValue(snapshot, workflowDiagramState),
});
setWorkflowDiagramFlowInitialized(true);
onInit?.();
},
[
isInRightDrawer,
onInit,
rightDrawerState,
setFlowViewport,
workflowDiagramState,
],
);
return (
<StyledResetReactflowStyles ref={containerRef}>

View File

@ -0,0 +1,9 @@
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext';
export const workflowDiagramWaitingNodesDimensionsComponentState =
createComponentStateV2<boolean>({
key: 'workflowDiagramWaitingNodesDimensionsComponentState',
defaultValue: false,
componentInstanceContext: WorkflowVisualizerComponentInstanceContext,
});