From 8d6358c72ed15a1b1f95e97e37dabde0e515590b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 12:40:36 -0700 Subject: [PATCH 01/10] fix(workflow): stop subflows resizing themselves after every load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container sized itself from its children, and when a child had not yet reported a height it used `estimateBlockDimensions` in its place — a guess of `ceil(subBlockCount / 2)` rows, which read a 39-field Gmail card as 276px tall against the 112px it draws. The container painted that number, the real height arrived a frame later, and it visibly resized between the two. Nothing is persisted, so it happened on every refresh. A card's height depends on what it actually renders — which rows survive its conditions, whether it draws a summary sentence, and for a reactive field even a credential it has to fetch — so the card is the only thing that can know it. Size only from heights the children have themselves reported, and hold the container at its current size until they have. `getBlockDimensions` keeps the estimate for the callers that only need a rough box (clamping a drag, placing a paste) and is now that same lookup plus the fallback. Also stop `calculateContainerDimensions` counting the container's chrome twice. Child coordinates are relative to the container's own origin and are already held clear of the header by `clampPositionToContainer`, so a child's far edge is the distance to cover and only the trailing padding is owed on top. Adding the header and leading padding again left every container 66px taller and 16px wider than its contents. --- .../[workflowId]/hooks/use-node-utilities.ts | 108 ++++++++++++------ .../utils/node-position-utils.test.ts | 60 ++++++++++ .../[workflowId]/utils/node-position-utils.ts | 16 ++- 3 files changed, 146 insertions(+), 38 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 85a4a7ab128..084e29e2315 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -25,40 +25,64 @@ export function useNodeUtilities(blocks: Record) { }, []) /** - * Get the dimensions of a block. - * For regular blocks, uses stored height or estimates based on block config. + * A block's dimensions as the block itself reported them, or null if it has + * not reported yet. + * + * {@link getBlockDimensions} without the estimate fallback — a rough box is + * fine for clamping a drag or placing a paste; see + * {@link calculateLoopDimensions} for why a container cannot size from one. + * + * A container's own size is already derived from its children, so it counts + * as reported once it has one; an empty container reports its default. */ - const getBlockDimensions = useCallback( - (blockId: string): { width: number; height: number } => { + const getReportedBlockDimensions = useCallback( + (blockId: string): { width: number; height: number } | null => { const block = blocks[blockId] - if (!block) { - return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } - } + if (!block) return null if (isContainerType(block.type)) { return { - width: block.data?.width - ? Math.max(block.data.width, CONTAINER_DIMENSIONS.MIN_WIDTH) - : CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - height: block.data?.height - ? Math.max(block.data.height, CONTAINER_DIMENSIONS.MIN_HEIGHT) - : CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + width: Math.max( + block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + CONTAINER_DIMENSIONS.MIN_WIDTH + ), + height: Math.max( + block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + CONTAINER_DIMENSIONS.MIN_HEIGHT + ), } } - if (block.height) { - return { - width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, - height: - block.type === 'note' - ? block.height - : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), - } + if (!block.height) return null + + return { + width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, + height: + block.type === 'note' + ? block.height + : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } + }, + [blocks, isContainerType] + ) + + /** + * Get the dimensions of a block, estimating from its type when it has not + * reported a height yet. + */ + const getBlockDimensions = useCallback( + (blockId: string): { width: number; height: number } => { + const reported = getReportedBlockDimensions(blockId) + if (reported) return reported + + const block = blocks[blockId] + if (!block) { + return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } } return estimateBlockDimensions(block.type) }, - [blocks, isContainerType] + [blocks, getReportedBlockDimensions] ) /** @@ -270,28 +294,44 @@ export function useNodeUtilities(blocks: Record) { /** * Calculates appropriate dimensions for a loop or parallel node based on its children + * + * Sizes only from heights the children have themselves reported. A card's + * height depends on what it actually renders — which rows survive its + * conditions, whether it draws a summary sentence, and for a reactive field + * even a credential it has to fetch — so the card is the only thing that can + * know it, and it publishes it once it does. + * + * Guessing in the meantime is what made a container resize on every load: + * `estimateBlockDimensions` assumes `ceil(subBlockCount / 2)` rows, so it read + * a 39-field Gmail card as 276px tall against the 112px it draws. The + * container painted that, then the real height arrived a frame later and it + * visibly resized. Returning null holds the container at the size it already + * has, so it moves once, to the right answer. + * * @param nodeId ID of the container node - * @returns Calculated width and height for the container + * @returns Calculated dimensions, or null while any child is still unmeasured */ const calculateLoopDimensions = useCallback( - (nodeId: string): { width: number; height: number } => { + (nodeId: string): { width: number; height: number } | null => { const currentBlocks = useWorkflowStore.getState().blocks const childBlockIds = Object.keys(currentBlocks).filter( (id) => currentBlocks[id]?.data?.parentId === nodeId ) - const childPositions = childBlockIds - .map((childId) => { - const child = currentBlocks[childId] - if (!child?.position) return null - const { width, height } = getBlockDimensions(childId) - return { x: child.position.x, y: child.position.y, width, height } - }) - .filter((p): p is NonNullable => p !== null) + const childPositions: Array<{ x: number; y: number; width: number; height: number }> = [] + for (const childId of childBlockIds) { + const child = currentBlocks[childId] + if (!child?.position) continue + + const reported = getReportedBlockDimensions(childId) + if (!reported) return null + + childPositions.push({ x: child.position.x, y: child.position.y, ...reported }) + } return calculateContainerDimensions(childPositions) }, - [getBlockDimensions] + [getReportedBlockDimensions] ) /** @@ -312,6 +352,8 @@ export function useNodeUtilities(blocks: Record) { for (const { id, block } of containerBlocks) { const dimensions = calculateLoopDimensions(id) + if (!dimensions) continue + const currentWidth = block?.data?.width const currentHeight = block?.data?.height diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.test.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.test.ts new file mode 100644 index 00000000000..5cf044a9299 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { describe, expect, it } from 'vitest' +import { + calculateContainerDimensions, + clampPositionToContainer, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' + +describe('calculateContainerDimensions', () => { + it('covers the child it holds plus one trailing padding', () => { + /* Child coordinates are relative to the container's own origin, so their + far edge is already the distance to cover. */ + const child = { x: 273, y: 207.5, width: 250, height: 112 } + + expect(calculateContainerDimensions([child])).toEqual({ + width: child.x + child.width + CONTAINER_DIMENSIONS.RIGHT_PADDING, + height: child.y + child.height + CONTAINER_DIMENSIONS.BOTTOM_PADDING, + }) + }) + + it('leaves the same gap under a child wherever the child sits', () => { + const gapUnder = (y: number) => + calculateContainerDimensions([{ x: 600, y, width: 250, height: 112 }]).height - (y + 112) + + expect(gapUnder(400)).toBe(CONTAINER_DIMENSIONS.BOTTOM_PADDING) + expect(gapUnder(700)).toBe(CONTAINER_DIMENSIONS.BOTTOM_PADDING) + }) + + it('holds a child pinned to the top-left clear of the chrome', () => { + /* The floor `clampPositionToContainer` applies is what encodes the header + and leading padding into the child's own coordinates — the sizing math + reads them from there rather than adding them again. */ + const pinned = clampPositionToContainer( + { x: -999, y: -999 }, + { width: 900, height: 900 }, + { width: 250, height: 112 } + ) + + expect(pinned).toEqual({ + x: CONTAINER_DIMENSIONS.LEFT_PADDING, + y: CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING, + }) + }) + + it('falls back to the default box when it holds nothing', () => { + expect(calculateContainerDimensions([])).toEqual({ + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + }) + }) + + it('never sizes below the default box', () => { + expect(calculateContainerDimensions([{ x: 16, y: 66, width: 40, height: 20 }])).toEqual({ + width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + }) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts index 8b93e302e06..7430229df48 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils.ts @@ -70,6 +70,15 @@ export function clampPositionToContainer( * Single source of truth for container sizing - ensures consistency between * live drag updates and final dimension calculations. * + * Child coordinates are relative to the container's own origin — React Flow + * places a child at the parent's origin plus its position, and + * {@link clampPositionToContainer} keeps them clear of the chrome by flooring + * them at `LEFT_PADDING` and `HEADER_HEIGHT + TOP_PADDING`. A child's far edge + * is therefore already the distance the container has to cover, and only the + * trailing padding is owed on top. Adding the header and leading padding here + * as well counted them twice, leaving every container 66px taller and 16px + * wider than its contents. + * * @param childPositions - Array of child positions with their dimensions * @returns Calculated width and height for the container */ @@ -93,14 +102,11 @@ export function calculateContainerDimensions( const width = Math.max( CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.LEFT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING + maxRight + CONTAINER_DIMENSIONS.RIGHT_PADDING ) const height = Math.max( CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.HEADER_HEIGHT + - CONTAINER_DIMENSIONS.TOP_PADDING + - maxBottom + - CONTAINER_DIMENSIONS.BOTTOM_PADDING + maxBottom + CONTAINER_DIMENSIONS.BOTTOM_PADDING ) return { width, height } From 3cce724832608b3cca9645c15b46402dde3d2dfb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 12:47:34 -0700 Subject: [PATCH 02/10] fix(workflow): gate container sizing on this session's reported layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two holes in the measurement gate, both from reading the wrong field. `height` is a persisted column and `data.width` / `data.height` persist a container's last size, so a block that has not reported yet can still carry last session's numbers — reachable through paste, import, and checkpoint restore. The gate treated those as reported and sized from them. Nested containers had it worse: an inner container with an unreported descendant handed back its 500x300 default as though it were measured, so the outer container sized to that and resized again once the descendant filled in — the same two-step this change exists to remove. `layout` is in-memory only and written by exactly the two places that know: a card through `updateBlockLayoutMetrics`, a container through `updateNodeDimensions`. Reading it means "reported during this session" and nothing else, and an inner container that is still waiting reports null, so the outer one waits with it. `getBlockDimensions` keeps the persisted height and the estimate as fallbacks — its callers only need a rough box, where a stale height still beats a guess. --- .../[workflowId]/hooks/use-node-utilities.ts | 70 +++++++++++++------ 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 084e29e2315..c96c8560311 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -25,50 +25,57 @@ export function useNodeUtilities(blocks: Record) { }, []) /** - * A block's dimensions as the block itself reported them, or null if it has - * not reported yet. + * A block's dimensions as reported during this session, or null if nothing + * has reported them yet. * - * {@link getBlockDimensions} without the estimate fallback — a rough box is - * fine for clamping a drag or placing a paste; see - * {@link calculateLoopDimensions} for why a container cannot size from one. + * Reads `layout`, not `height`. `layout` is in-memory only — a card writes it + * through `updateBlockLayoutMetrics` once it knows what it renders, and a + * container through `updateNodeDimensions` once it has been sized from its + * children. `height` is a persisted column, so it can still hold last + * session's value for a block that has not reported yet, and `data.width` / + * `data.height` likewise persist a container's last size. Sizing a container + * from those is the same guess-then-correct this gate exists to prevent, just + * closer to the truth and harder to reproduce. * - * A container's own size is already derived from its children, so it counts - * as reported once it has one; an empty container reports its default. + * Reading `layout` also settles nesting: an inner container that is still + * waiting on its own children has nothing in `layout`, so it reports null and + * the outer container waits with it, rather than sizing to the inner one's + * default and resizing again once it fills out. */ const getReportedBlockDimensions = useCallback( (blockId: string): { width: number; height: number } | null => { const block = blocks[blockId] - if (!block) return null + const reportedHeight = block?.layout?.measuredHeight + if (!block || !reportedHeight) return null if (isContainerType(block.type)) { return { width: Math.max( - block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + block.layout?.measuredWidth || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, CONTAINER_DIMENSIONS.MIN_WIDTH ), - height: Math.max( - block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.MIN_HEIGHT - ), + height: Math.max(reportedHeight, CONTAINER_DIMENSIONS.MIN_HEIGHT), } } - if (!block.height) return null - return { width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, height: block.type === 'note' - ? block.height - : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + ? reportedHeight + : Math.max(reportedHeight, BLOCK_DIMENSIONS.MIN_HEIGHT), } }, [blocks, isContainerType] ) /** - * Get the dimensions of a block, estimating from its type when it has not - * reported a height yet. + * Get the dimensions of a block, falling back to its persisted height and + * then to an estimate from its type. + * + * The callers here only need a rough box — clamping a drag, placing a paste — + * so a stale height beats a guess and a guess beats nothing. A container + * sizing itself cannot use either; see {@link calculateLoopDimensions}. */ const getBlockDimensions = useCallback( (blockId: string): { width: number; height: number } => { @@ -80,9 +87,32 @@ export function useNodeUtilities(blocks: Record) { return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } } + if (isContainerType(block.type)) { + return { + width: Math.max( + block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + CONTAINER_DIMENSIONS.MIN_WIDTH + ), + height: Math.max( + block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + CONTAINER_DIMENSIONS.MIN_HEIGHT + ), + } + } + + if (block.height) { + return { + width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, + height: + block.type === 'note' + ? block.height + : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + } + } + return estimateBlockDimensions(block.type) }, - [blocks, getReportedBlockDimensions] + [blocks, isContainerType, getReportedBlockDimensions] ) /** From 00ae25370a5b9cd7b9b974969197c374c399913c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 12:52:57 -0700 Subject: [PATCH 03/10] Revert "fix(workflow): gate container sizing on this session's reported layout" This reverts commit 3cce724832608b3cca9645c15b46402dde3d2dfb. --- .../[workflowId]/hooks/use-node-utilities.ts | 70 ++++++------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index c96c8560311..084e29e2315 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -25,57 +25,50 @@ export function useNodeUtilities(blocks: Record) { }, []) /** - * A block's dimensions as reported during this session, or null if nothing - * has reported them yet. + * A block's dimensions as the block itself reported them, or null if it has + * not reported yet. * - * Reads `layout`, not `height`. `layout` is in-memory only — a card writes it - * through `updateBlockLayoutMetrics` once it knows what it renders, and a - * container through `updateNodeDimensions` once it has been sized from its - * children. `height` is a persisted column, so it can still hold last - * session's value for a block that has not reported yet, and `data.width` / - * `data.height` likewise persist a container's last size. Sizing a container - * from those is the same guess-then-correct this gate exists to prevent, just - * closer to the truth and harder to reproduce. + * {@link getBlockDimensions} without the estimate fallback — a rough box is + * fine for clamping a drag or placing a paste; see + * {@link calculateLoopDimensions} for why a container cannot size from one. * - * Reading `layout` also settles nesting: an inner container that is still - * waiting on its own children has nothing in `layout`, so it reports null and - * the outer container waits with it, rather than sizing to the inner one's - * default and resizing again once it fills out. + * A container's own size is already derived from its children, so it counts + * as reported once it has one; an empty container reports its default. */ const getReportedBlockDimensions = useCallback( (blockId: string): { width: number; height: number } | null => { const block = blocks[blockId] - const reportedHeight = block?.layout?.measuredHeight - if (!block || !reportedHeight) return null + if (!block) return null if (isContainerType(block.type)) { return { width: Math.max( - block.layout?.measuredWidth || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, CONTAINER_DIMENSIONS.MIN_WIDTH ), - height: Math.max(reportedHeight, CONTAINER_DIMENSIONS.MIN_HEIGHT), + height: Math.max( + block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + CONTAINER_DIMENSIONS.MIN_HEIGHT + ), } } + if (!block.height) return null + return { width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, height: block.type === 'note' - ? reportedHeight - : Math.max(reportedHeight, BLOCK_DIMENSIONS.MIN_HEIGHT), + ? block.height + : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), } }, [blocks, isContainerType] ) /** - * Get the dimensions of a block, falling back to its persisted height and - * then to an estimate from its type. - * - * The callers here only need a rough box — clamping a drag, placing a paste — - * so a stale height beats a guess and a guess beats nothing. A container - * sizing itself cannot use either; see {@link calculateLoopDimensions}. + * Get the dimensions of a block, estimating from its type when it has not + * reported a height yet. */ const getBlockDimensions = useCallback( (blockId: string): { width: number; height: number } => { @@ -87,32 +80,9 @@ export function useNodeUtilities(blocks: Record) { return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } } - if (isContainerType(block.type)) { - return { - width: Math.max( - block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, - CONTAINER_DIMENSIONS.MIN_WIDTH - ), - height: Math.max( - block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, - CONTAINER_DIMENSIONS.MIN_HEIGHT - ), - } - } - - if (block.height) { - return { - width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, - height: - block.type === 'note' - ? block.height - : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), - } - } - return estimateBlockDimensions(block.type) }, - [blocks, isContainerType, getReportedBlockDimensions] + [blocks, getReportedBlockDimensions] ) /** From a483d7a9810a1f51e823359786da5a95e0e3e7d5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 12:54:36 -0700 Subject: [PATCH 04/10] fix(workflow): size containers from a state-aware child estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate in the reverted commit held a container at its current size until its children reported. That is worse than it sounds: the size it holds is the persisted default of 300, the child needs 335, and so the child hung outside the container until something forced a resize. Estimate accurately instead of waiting. `getBlockMetrics` derives a card's height from the block's own state — the sub-blocks its values leave visible, the summary sentence, the error row — through the same `calculateWorkflowBlockDimensions` the card calls, and lands on the height the card goes on to render: 112px for the Gmail card the type-only estimate put at 276px. The pass before the cards report and the pass after now produce the same container, so there is nothing to gate and nothing to correct. This also fixes the guess everywhere else it was painted rather than only in the container path — `estimateBlockDimensions` fed React Flow's node height for unmeasured blocks, so selection bounds were 276px around a 112px card. --- .../[workflowId]/hooks/use-node-utilities.ts | 100 +++++++----------- 1 file changed, 36 insertions(+), 64 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 084e29e2315..db6f98f05f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -2,10 +2,10 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' import { useReactFlow } from 'reactflow' +import { getBlockMetrics } from '@/lib/workflows/autolayout/utils' import { calculateContainerDimensions, clampPositionToContainer, - estimateBlockDimensions, } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/node-position-utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -25,20 +25,27 @@ export function useNodeUtilities(blocks: Record) { }, []) /** - * A block's dimensions as the block itself reported them, or null if it has - * not reported yet. + * Get the dimensions of a block. * - * {@link getBlockDimensions} without the estimate fallback — a rough box is - * fine for clamping a drag or placing a paste; see - * {@link calculateLoopDimensions} for why a container cannot size from one. + * Before a card mounts there is no measurement, so the height comes from + * {@link getBlockMetrics}, which estimates it from the block's own state — + * the sub-blocks its values leave visible, the summary sentence it will draw, + * the error row — through the same `calculateWorkflowBlockDimensions` the + * card itself calls. It lands on the height the card goes on to render. * - * A container's own size is already derived from its children, so it counts - * as reported once it has one; an empty container reports its default. + * The old estimate read the block's *type* alone and assumed + * `ceil(subBlockCount / 2)` rows, which put a 39-field Gmail card at 276px + * against the 112px it draws. A container sized from that, painted it, then + * got the real height a frame later and visibly resized — on every load, + * because measurements are not persisted. Estimating from state removes the + * gap rather than waiting it out: both passes now produce the same number. */ - const getReportedBlockDimensions = useCallback( - (blockId: string): { width: number; height: number } | null => { + const getBlockDimensions = useCallback( + (blockId: string): { width: number; height: number } => { const block = blocks[blockId] - if (!block) return null + if (!block) { + return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } + } if (isContainerType(block.type)) { return { @@ -53,38 +60,15 @@ export function useNodeUtilities(blocks: Record) { } } - if (!block.height) return null - + const metrics = getBlockMetrics(block) return { - width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : BLOCK_DIMENSIONS.FIXED_WIDTH, - height: - block.type === 'note' - ? block.height - : Math.max(block.height, BLOCK_DIMENSIONS.MIN_HEIGHT), + width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : metrics.width, + height: block.type === 'note' && block.height ? block.height : metrics.height, } }, [blocks, isContainerType] ) - /** - * Get the dimensions of a block, estimating from its type when it has not - * reported a height yet. - */ - const getBlockDimensions = useCallback( - (blockId: string): { width: number; height: number } => { - const reported = getReportedBlockDimensions(blockId) - if (reported) return reported - - const block = blocks[blockId] - if (!block) { - return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } - } - - return estimateBlockDimensions(block.type) - }, - [blocks, getReportedBlockDimensions] - ) - /** * Calculates the depth of a node in the hierarchy tree * @param nodeId ID of the node to check @@ -295,43 +279,33 @@ export function useNodeUtilities(blocks: Record) { /** * Calculates appropriate dimensions for a loop or parallel node based on its children * - * Sizes only from heights the children have themselves reported. A card's - * height depends on what it actually renders — which rows survive its - * conditions, whether it draws a summary sentence, and for a reactive field - * even a credential it has to fetch — so the card is the only thing that can - * know it, and it publishes it once it does. - * - * Guessing in the meantime is what made a container resize on every load: - * `estimateBlockDimensions` assumes `ceil(subBlockCount / 2)` rows, so it read - * a 39-field Gmail card as 276px tall against the 112px it draws. The - * container painted that, then the real height arrived a frame later and it - * visibly resized. Returning null holds the container at the size it already - * has, so it moves once, to the right answer. + * Child heights come from {@link getBlockDimensions}, which estimates from + * block state when a card has not mounted yet and lands on the height it will + * render — so the size computed before the cards report matches the one after, + * and the container does not resize behind the user. * * @param nodeId ID of the container node - * @returns Calculated dimensions, or null while any child is still unmeasured + * @returns Calculated width and height for the container */ const calculateLoopDimensions = useCallback( - (nodeId: string): { width: number; height: number } | null => { + (nodeId: string): { width: number; height: number } => { const currentBlocks = useWorkflowStore.getState().blocks const childBlockIds = Object.keys(currentBlocks).filter( (id) => currentBlocks[id]?.data?.parentId === nodeId ) - const childPositions: Array<{ x: number; y: number; width: number; height: number }> = [] - for (const childId of childBlockIds) { - const child = currentBlocks[childId] - if (!child?.position) continue - - const reported = getReportedBlockDimensions(childId) - if (!reported) return null - - childPositions.push({ x: child.position.x, y: child.position.y, ...reported }) - } + const childPositions = childBlockIds + .map((childId) => { + const child = currentBlocks[childId] + if (!child?.position) return null + const { width, height } = getBlockDimensions(childId) + return { x: child.position.x, y: child.position.y, width, height } + }) + .filter((position): position is NonNullable => position !== null) return calculateContainerDimensions(childPositions) }, - [getReportedBlockDimensions] + [getBlockDimensions] ) /** @@ -352,8 +326,6 @@ export function useNodeUtilities(blocks: Record) { for (const { id, block } of containerBlocks) { const dimensions = calculateLoopDimensions(id) - if (!dimensions) continue - const currentWidth = block?.data?.width const currentHeight = block?.data?.height From 07a59eb2c5ab777a06c04307d78520216e9d88a0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 12:57:25 -0700 Subject: [PATCH 05/10] improvement(workflow): even out the gutter inside a container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Left, top and bottom were 16 and the bottom read tighter than either, because the 50px header sits above the top gap and gives that edge visual weight the other two do not have. Taking them to 24 leaves the three gutter-only edges matching and the bottom no longer pinched. Right stays 80. The container's output handle sits on that edge, so a child needs clearance there it does not need anywhere else — chrome rather than gutter, now said so in the type. Only reachable as a single constant each because the paddings mean what they say: each is the gap between a child's edge and the container's, counted once. While the sizing math added the header and leading padding a second time, the effective bottom gap was spread across three constants and tuning it meant reasoning about all of them. --- packages/workflow-renderer/src/dimensions.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/workflow-renderer/src/dimensions.ts b/packages/workflow-renderer/src/dimensions.ts index 8efc5098548..f8e77c03cfc 100644 --- a/packages/workflow-renderer/src/dimensions.ts +++ b/packages/workflow-renderer/src/dimensions.ts @@ -80,16 +80,30 @@ export const estimateNoteBlockHeight = (content: string) => { ) } +/** + * A container's box, and the gutter it keeps around the blocks inside it. + * + * Each padding is the gap between a child's edge and the container's, and + * nothing else. The header is counted once, by the child's own position, which + * `clampPositionToContainer` floors at `HEADER_HEIGHT + TOP_PADDING` — so these + * are the numbers you see, and three of them match because those three edges + * are only gutter. + * + * `RIGHT_PADDING` is the deliberate exception. The container's own output + * handle sits on that edge, so a child needs clearance there it does not need + * anywhere else. That makes it chrome rather than gutter, which is why it is + * not tied to the other three. + */ export const CONTAINER_DIMENSIONS = { DEFAULT_WIDTH: 500, DEFAULT_HEIGHT: 300, MIN_WIDTH: 400, MIN_HEIGHT: 200, HEADER_HEIGHT: 50, - LEFT_PADDING: 16, + LEFT_PADDING: 24, RIGHT_PADDING: 80, - TOP_PADDING: 16, - BOTTOM_PADDING: 16, + TOP_PADDING: 24, + BOTTOM_PADDING: 24, } as const /** From 1ed9525e4f4c84eeef4e497e2d97efd0949e5a1f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 13:05:24 -0700 Subject: [PATCH 06/10] improvement(workflow): give a container one source for its own gutter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four paddings and the header height existed twice: as `CONTAINER_DIMENSIONS`, which sizes a container and clamps its children, and again as Tailwind literals in `subflow-node-view`, which draws the header and the content box. Nothing kept them in step and they had already drifted — the view rendering a 40px header against a constant claiming 50, so children were clamped 10px below where the header actually ends. The view now renders from the constants, and the constant follows the DOM at 40. Match the bottom gutter to the right at 80. The two edges that carry chrome are now the two that are wider: the container's output handle sits on the right, and the resize grip in the bottom-right corner spans 40px in from both, so a child at the 24px gutter width could sit underneath it. Left and top are only gutter and stay at 24. --- packages/workflow-renderer/src/dimensions.ts | 28 +++++++++++-------- .../src/subflow/subflow-node-view.tsx | 17 +++++++---- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/workflow-renderer/src/dimensions.ts b/packages/workflow-renderer/src/dimensions.ts index f8e77c03cfc..e35fb5cb08f 100644 --- a/packages/workflow-renderer/src/dimensions.ts +++ b/packages/workflow-renderer/src/dimensions.ts @@ -83,27 +83,33 @@ export const estimateNoteBlockHeight = (content: string) => { /** * A container's box, and the gutter it keeps around the blocks inside it. * - * Each padding is the gap between a child's edge and the container's, and - * nothing else. The header is counted once, by the child's own position, which - * `clampPositionToContainer` floors at `HEADER_HEIGHT + TOP_PADDING` — so these - * are the numbers you see, and three of them match because those three edges - * are only gutter. + * The single source for both halves of that: the layout math that sizes a + * container and clamps its children, and the card's own DOM. `subflow-node-view` + * renders its header and content box straight from these, so the gap the + * geometry reserves is the gap the container actually paints. They used to be + * separate — the same four numbers as Tailwind literals in the view — and had + * already drifted, the view drawing a 40px header against a constant that + * claimed 50. * - * `RIGHT_PADDING` is the deliberate exception. The container's own output - * handle sits on that edge, so a child needs clearance there it does not need - * anywhere else. That makes it chrome rather than gutter, which is why it is - * not tied to the other three. + * Each padding is the gap between a child's edge and the container's, counted + * once: the header is accounted for by the child's own position, which + * `clampPositionToContainer` floors at `HEADER_HEIGHT + TOP_PADDING`. + * + * The two edges that carry chrome are wider than the two that are only gutter. + * The container's output handle sits on the right, and the resize grip in the + * bottom-right corner spans 40px in from both — a child at the gutter width + * would sit underneath it. */ export const CONTAINER_DIMENSIONS = { DEFAULT_WIDTH: 500, DEFAULT_HEIGHT: 300, MIN_WIDTH: 400, MIN_HEIGHT: 200, - HEADER_HEIGHT: 50, + HEADER_HEIGHT: 40, LEFT_PADDING: 24, RIGHT_PADDING: 80, TOP_PADDING: 24, - BOTTOM_PADDING: 24, + BOTTOM_PADDING: 80, } as const /** diff --git a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx index b7d25588ae0..40fbafd140a 100644 --- a/packages/workflow-renderer/src/subflow/subflow-node-view.tsx +++ b/packages/workflow-renderer/src/subflow/subflow-node-view.tsx @@ -8,7 +8,7 @@ import { useStoreApi as useReactFlowStoreApi, useUpdateNodeInternals, } from 'reactflow' -import { BLOCK_DIMENSIONS, HANDLE_POSITIONS } from '../dimensions' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, HANDLE_POSITIONS } from '../dimensions' import { OverflowSpan } from '../lib/overflow-span' import type { DiffStatus } from '../types' import { @@ -588,8 +588,8 @@ export function SubflowNodeView({ aria-label={`Select ${blockName}`} onClick={onSelect} onKeyDown={(event) => handleKeyboardActivation(event, onSelect)} - className='workflow-drag-handle relative z-20 flex h-[40px] cursor-grab items-center justify-between px-2 [&:active]:cursor-grabbing' - style={{ pointerEvents: 'auto' }} + className='workflow-drag-handle relative z-20 flex cursor-grab items-center justify-between px-2 [&:active]:cursor-grabbing' + style={{ pointerEvents: 'auto', height: CONTAINER_DIMENSIONS.HEADER_HEIGHT }} data-subflow-header='' >
Date: Wed, 12 Aug 2026 13:09:29 -0700 Subject: [PATCH 07/10] test(workflow-renderer): assert the subflow header's height, not its class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header renders from `CONTAINER_DIMENSIONS.HEADER_HEIGHT` now, so the class it used to carry is gone. Assert the rendered height against the same constant the layout math measures against — the two drifting apart is what this whole change is about, and a utility-class assertion cannot catch that. Also set `IS_REACT_ACT_ENVIRONMENT`, which these tests have always needed. React only treats `act` as supported when it can see the flag, so every render logged "The current testing environment is not configured to support act(...)" — around forty lines of it per run, burying the actual failure output. --- .../workflow-block/workflow-block-border-mount.test.tsx | 6 +++++- packages/workflow-renderer/vitest.setup.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx b/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx index 24eadd3b8ab..eb495dac05b 100644 --- a/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx +++ b/packages/workflow-renderer/src/workflow-block/workflow-block-border-mount.test.tsx @@ -13,6 +13,7 @@ import { import { createRoot, type Root } from 'react-dom/client' import { ReactFlowProvider } from 'reactflow' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { CONTAINER_DIMENSIONS } from '../dimensions' import { ERROR_SOURCE_HANDLE_POSITION, getCursorBranchSourceHandleId, @@ -959,7 +960,10 @@ describe('WorkflowBlockBorder mount', () => { ) const header = host.querySelector('[data-subflow-header]') - expect(header).toHaveClass('h-[40px]') + /* Height comes from `CONTAINER_DIMENSIONS`, which the layout math also + measures against — asserting the rendered value rather than a utility + class keeps the two from drifting apart again. */ + expect(header).toHaveStyle({ height: `${CONTAINER_DIMENSIONS.HEADER_HEIGHT}px` }) expect(header).not.toHaveClass('border-b') expect(header).not.toHaveClass('bg-[var(--surface-2)]') expect(host.querySelector('[data-subflow-type-tag="loop"]')).toHaveTextContent('Loop') diff --git a/packages/workflow-renderer/vitest.setup.ts b/packages/workflow-renderer/vitest.setup.ts index 0c78bee3922..a024256e3e5 100644 --- a/packages/workflow-renderer/vitest.setup.ts +++ b/packages/workflow-renderer/vitest.setup.ts @@ -5,4 +5,12 @@ */ if (typeof document !== 'undefined') { await import('@testing-library/jest-dom/vitest') + /* + * React only treats `act` as supported when it can see this flag, and without + * it every render in the mount tests logs "The current testing environment is + * not configured to support act(...)". The warning is noise here — the tests + * already wrap their renders — but it buries real output, and React does not + * flush effects the way the tests assume until the flag is set. + */ + globalThis.IS_REACT_ACT_ENVIRONMENT = true } From a12011e368f3dc6eebda0222d663dae6c67f3996 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 13:13:13 -0700 Subject: [PATCH 08/10] fix(workflow-renderer): declare the act-environment global `vitest.setup.ts` is inside the package's tsconfig, so assigning an undeclared property on `globalThis` failed type-check (TS7017) even though the tests ran. --- packages/workflow-renderer/vitest.setup.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/workflow-renderer/vitest.setup.ts b/packages/workflow-renderer/vitest.setup.ts index a024256e3e5..1468f99e728 100644 --- a/packages/workflow-renderer/vitest.setup.ts +++ b/packages/workflow-renderer/vitest.setup.ts @@ -1,3 +1,11 @@ +declare global { + /** + * React reads this to decide whether `act` is supported. It is not one of the + * ambient globals, so it has to be declared before it can be assigned. + */ + var IS_REACT_ACT_ENVIRONMENT: boolean +} + /** * jest-dom only registers DOM matchers (`toHaveStyle`, `toHaveClass`, …), so it is * dead weight outside a DOM environment. This package's mount tests opt into jsdom @@ -6,11 +14,10 @@ if (typeof document !== 'undefined') { await import('@testing-library/jest-dom/vitest') /* - * React only treats `act` as supported when it can see this flag, and without - * it every render in the mount tests logs "The current testing environment is - * not configured to support act(...)". The warning is noise here — the tests - * already wrap their renders — but it buries real output, and React does not - * flush effects the way the tests assume until the flag is set. + * Without this React treats `act` as unsupported, and every render in the + * mount tests logs "The current testing environment is not configured to + * support act(...)" — around forty lines a run, which buries the output that + * matters when one of them fails. */ globalThis.IS_REACT_ACT_ENVIRONMENT = true } From ce31c53213aed572af87e7bb0faa83b33b1ce07f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 13:15:02 -0700 Subject: [PATCH 09/10] fix(workflow): size a container from one snapshot of the store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `calculateLoopDimensions` took child positions from the live store but child dimensions from the hook's render snapshot, so it was reading two ages of the same data. `resizeLoopNodes` walks deepest-first: an inner container resized earlier in the pass was already updated in the live snapshot and still stale in the closed-over one, so its parent sized against the old inner box and only caught up on a later render — a nested container visibly resizing twice, which is the symptom this branch set out to remove. Take both from the snapshot the function already reads. --- .../[workflowId]/hooks/use-node-utilities.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index db6f98f05f7..4d075177f7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -40,9 +40,8 @@ export function useNodeUtilities(blocks: Record) { * because measurements are not persisted. Estimating from state removes the * gap rather than waiting it out: both passes now produce the same number. */ - const getBlockDimensions = useCallback( - (blockId: string): { width: number; height: number } => { - const block = blocks[blockId] + const dimensionsOfBlock = useCallback( + (block: any): { width: number; height: number } => { if (!block) { return { width: BLOCK_DIMENSIONS.FIXED_WIDTH, height: BLOCK_DIMENSIONS.MIN_HEIGHT } } @@ -66,7 +65,12 @@ export function useNodeUtilities(blocks: Record) { height: block.type === 'note' && block.height ? block.height : metrics.height, } }, - [blocks, isContainerType] + [isContainerType] + ) + + const getBlockDimensions = useCallback( + (blockId: string): { width: number; height: number } => dimensionsOfBlock(blocks[blockId]), + [blocks, dimensionsOfBlock] ) /** @@ -298,14 +302,21 @@ export function useNodeUtilities(blocks: Record) { .map((childId) => { const child = currentBlocks[childId] if (!child?.position) return null - const { width, height } = getBlockDimensions(childId) + /* Sized from `currentBlocks`, the same snapshot the position came + from. Reading dimensions off the hook's render snapshot instead + mixed two ages of the same store: `resizeLoopNodes` walks + deepest-first, so an inner container resized earlier in the pass + was already updated here but still old there, and the parent sized + against a stale inner box — leaving nested containers to converge + over a second pass. */ + const { width, height } = dimensionsOfBlock(child) return { x: child.position.x, y: child.position.y, width, height } }) .filter((position): position is NonNullable => position !== null) return calculateContainerDimensions(childPositions) }, - [getBlockDimensions] + [dimensionsOfBlock] ) /** From a32bbe5202e79e7ab5439160742800b0d3af5ff4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 12 Aug 2026 13:18:00 -0700 Subject: [PATCH 10/10] fix(workflow): size an unmeasured note as a note Routing every non-container block through `getBlockMetrics` sent notes through the workflow-card estimate, which counts sub-block rows and an error row a note does not have. A note that had not reported a height yet got a card's box, so a container holding one sized itself around the wrong shape. Give a note its own branch, as the estimate it replaced did: measured height when there is one, and the height an empty note paints when there is not. --- .../w/[workflowId]/hooks/use-node-utilities.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index 4d075177f7f..5b59431bd5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' -import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@sim/workflow-renderer' +import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS, getNoteBlockHeight } from '@sim/workflow-renderer' import { useReactFlow } from 'reactflow' import { getBlockMetrics } from '@/lib/workflows/autolayout/utils' import { @@ -59,11 +59,17 @@ export function useNodeUtilities(blocks: Record) { } } - const metrics = getBlockMetrics(block) - return { - width: block.type === 'note' ? BLOCK_DIMENSIONS.NOTE_WIDTH : metrics.width, - height: block.type === 'note' && block.height ? block.height : metrics.height, + /* A note is not a card: it has no sub-block rows and no error row, so the + card estimate does not describe it. Its own height is what it was + measured at, or the height an empty one paints. */ + if (block.type === 'note') { + return { + width: BLOCK_DIMENSIONS.NOTE_WIDTH, + height: block.height || getNoteBlockHeight(true), + } } + + return getBlockMetrics(block) }, [isContainerType] )