From 67420f45d4275d8ff19ecd46797c9a5802f58587 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 12:19:50 -0700 Subject: [PATCH 1/5] perf(workflow): scope resize CSS-var writes to the container, not :root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resizing the editor panel, terminal, output panel, and sidebar was still laggy on large workflows even after the drag handles stopped re-rendering React per frame. A CDP trace showed the cost was style recalculation, not JS or layout: ~3.9s of Document::recalcStyle over a 50-move panel drag. Root cause: each drag frame wrote its CSS variable to document.documentElement. On a large document (~42k elements) any inline custom-property write on the root recalculates style for the whole tree — measured at ~77ms per write, independent of what actually reads the variable (an unused var costs the same). Writing the same variable to the element that consumes it recalcs only that subtree (~0.5ms) — a ~150x reduction. useDragResize now writes the variable to a caller-provided target element during the drag and reconciles to :root once on release (so on-demand readers and the pre-hydration script are unchanged): panel -> .panel-container, terminal -> .terminal-container, output panel -> .terminal-container (both consumers inherit it), sidebar -> .sidebar-shell-outer. The terminal's expanded-threshold sync writes store state directly instead of setTerminalHeight so it no longer touches :root mid-drag. Measured (near-empty canvas, 50-move drag): panel style+layout 3891ms -> 56ms, frame p95 91.6ms -> 8.4ms, main-thread blocking 4566ms -> 0ms; terminal recalc 3899ms -> 16ms, blocking 5558ms -> 0ms; sidebar recalc ~77ms/frame -> ~1ms/frame. --- .../panel/hooks/use-panel-resize.ts | 20 ++--- .../terminal/hooks/use-output-panel-resize.ts | 33 ++++---- .../terminal/hooks/use-terminal-resize.ts | 37 +++++---- .../sidebar/hooks/use-sidebar-resize.ts | 23 ++++-- apps/sim/hooks/use-drag-resize.ts | 76 +++++++++++++------ 5 files changed, 113 insertions(+), 76 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts index c46b23f8880..5f20d08e113 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts @@ -13,18 +13,17 @@ function computePanelWidth(ev: PointerEvent): number { return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth) } -/** - * Applies the panel width per frame. The `--panel-width` CSS variable alone - * sizes `.panel-container`, so no React work happens during the drag. - */ -function applyPanelWidth(width: number): void { - document.documentElement.style.setProperty('--panel-width', `${width}px`) +/** The `.panel-container` element sizes itself from `--panel-width`. */ +function getPanelContainer(): HTMLElement | null { + return document.querySelector('.panel-container') } /** - * Handles panel drag-resize with zero React renders during the drag. - * The final width is committed to the store (one re-render + one - * localStorage write) when the drag ends. + * Handles panel drag-resize with zero React renders during the drag. The + * `--panel-width` variable is written to `.panel-container` (a scoped style + * recalc) rather than `:root` (a whole-document recalc), and the final width + * is committed to the store (one re-render + one localStorage write) when the + * drag ends. * * @returns Pointer-down handler for the resize handle */ @@ -33,8 +32,9 @@ export function usePanelResize() { return useDragResize({ cursor: 'ew-resize', + cssVar: '--panel-width', + getTarget: getPanelContainer, compute: computePanelWidth, - apply: applyPanelWidth, commit: setPanelWidth, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts index 9684457616f..8f3fa7bedf1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts @@ -3,32 +3,25 @@ import { useDragResize } from '@/hooks/use-drag-resize' import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants' import { useTerminalStore } from '@/stores/terminal' -/** - * Applies the output panel width per frame. The `--output-panel-width` CSS - * variable alone sizes the output panel and its sibling logs column, so no - * React work happens during the drag. - */ -function applyOutputPanelWidth(width: number): void { - document.documentElement.style.setProperty('--output-panel-width', `${width}px`) -} - /** * Handles the terminal output panel drag-resize with zero React renders - * during the drag. The terminal element is captured once on drag start and - * its rect is re-read per frame (rAF-aligned, before the CSS-var write), so - * the clamp stays correct even if the terminal resizes mid-drag. The final - * width is committed to the store (one re-render + one localStorage write) - * when the drag ends. + * during the drag. `--output-panel-width` is written to `.terminal-container` + * (which both the output panel and its sibling logs column inherit from) — a + * scoped style recalc rather than a whole-document one on `:root`. The + * terminal rect is re-read per frame (rAF-aligned, before the write), so the + * clamp stays correct even if the terminal resizes mid-drag. The final width + * is committed to the store (one re-render + one localStorage write) when the + * drag ends. * * @returns Pointer-down handler for the resize handle */ export function useOutputPanelResize() { const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth) - const terminalElRef = useRef(null) + const terminalElRef = useRef(null) - const captureTerminalElement = useCallback(() => { - terminalElRef.current = document.querySelector('[aria-label="Terminal"]') - return terminalElRef.current !== null + const getTerminalElement = useCallback(() => { + terminalElRef.current = document.querySelector('.terminal-container') + return terminalElRef.current }, []) const computeOutputPanelWidth = useCallback((ev: PointerEvent) => { @@ -42,9 +35,9 @@ export function useOutputPanelResize() { return useDragResize({ cursor: 'ew-resize', + cssVar: '--output-panel-width', + getTarget: getTerminalElement, compute: computeOutputPanelWidth, - apply: applyOutputPanelWidth, commit: setOutputPanelWidth, - onStart: captureTerminalElement, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts index f68403e901a..750e0c17358 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts @@ -13,26 +13,33 @@ function computeTerminalHeight(ev: PointerEvent): number { return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight) } +/** The `.terminal-container` element sizes itself from `--terminal-height`. */ +function getTerminalContainer(): HTMLElement | null { + return document.querySelector('.terminal-container') +} + /** - * Applies the terminal height per frame. The `--terminal-height` CSS - * variable alone sizes `.terminal-container`, so no React work happens on - * ordinary frames. The store is committed mid-drag only when the height - * crosses the expanded threshold, so `isExpanded` subscribers (header - * chevron, auto-open logic) still flip live during the drag. + * Updates the store height mid-drag only when it crosses the expanded + * threshold, so `isExpanded` subscribers (header chevron, auto-open logic) + * still flip live during the drag. Writes store state directly rather than + * calling `setTerminalHeight` so it does not also write `--terminal-height` to + * `:root` (a whole-document recalc) — the scoped CSS-var write handled by + * {@link useDragResize} already drives the visual, and the final value is + * persisted through `setTerminalHeight` on release. */ -function applyTerminalHeight(height: number): void { - document.documentElement.style.setProperty('--terminal-height', `${height}px`) - - const store = useTerminalStore.getState() - const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD +function syncExpandedThreshold(height: number): void { + const wasExpanded = + useTerminalStore.getState().terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD const nowExpanded = height > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD - if (wasExpanded !== nowExpanded) store.setTerminalHeight(height) + if (wasExpanded !== nowExpanded) useTerminalStore.setState({ terminalHeight: height }) } /** * Handles terminal drag-resize with zero React renders during the drag - * (except at expanded-threshold crossings). The final height is committed - * to the store (one re-render + one localStorage write) when the drag ends. + * (except at expanded-threshold crossings). The `--terminal-height` variable + * is written to `.terminal-container` (a scoped style recalc) rather than + * `:root` (a whole-document recalc), and the final height is committed to the + * store (one re-render + one localStorage write) when the drag ends. * * @returns Pointer-down handler for the resize handle */ @@ -41,8 +48,10 @@ export function useTerminalResize() { return useDragResize({ cursor: 'ns-resize', + cssVar: '--terminal-height', + getTarget: getTerminalContainer, compute: computeTerminalHeight, - apply: applyTerminalHeight, commit: setTerminalHeight, + onApply: syncExpandedThreshold, }) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts index dd4f3b04d51..819434e2bec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts @@ -12,10 +12,14 @@ import { useSidebarStore } from '@/stores/sidebar/store' * add `is-resizing` class directly to the DOM (no React * round-trip, so the CSS width transition is suppressed from the * very first frame) - * pointermove → write to --sidebar-width inside a requestAnimationFrame - * callback (aligns work with the browser paint cycle) + * pointermove → write --sidebar-width to `.sidebar-shell-outer` (the element + * that sizes the rail) inside a requestAnimationFrame callback. + * Scoping the variable to that subtree keeps the style recalc + * local; writing it to `:root` instead forces a whole-document + * recalc (~150x slower on a large canvas). * pointerup → cancel any pending RAF, tear down, persist final width to - * Zustand once (one React re-render to save to localStorage) + * Zustand once (writes the authoritative `:root` value for + * on-demand readers), then drop the scoped override * * The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an * interrupted gesture (release outside the window, alt-tab, context menu, the OS @@ -36,6 +40,8 @@ export function useSidebarResize() { const handle = e.currentTarget const pointerId = e.pointerId const sidebar = document.querySelector('.sidebar-container') + const shell = document.querySelector('.sidebar-shell-outer') + const target = shell ?? document.documentElement sidebar?.classList.add('is-resizing') document.documentElement.classList.add('sidebar-resizing') document.body.style.cursor = 'ew-resize' @@ -43,13 +49,15 @@ export function useSidebarResize() { handle.setPointerCapture?.(pointerId) let rafId: number | null = null + let lastWidth: number | null = null const onPointerMove = (ev: PointerEvent) => { if (rafId !== null) cancelAnimationFrame(rafId) rafId = requestAnimationFrame(() => { const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE) const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max) - document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`) + target.style.setProperty('--sidebar-width', `${clamped}px`) + lastWidth = clamped rafId = null }) } @@ -73,9 +81,10 @@ export function useSidebarResize() { function endDrag() { cleanup() - const raw = document.documentElement.style.getPropertyValue('--sidebar-width') - const finalWidth = Number.parseFloat(raw) - if (!Number.isNaN(finalWidth)) setSidebarWidth(finalWidth) + if (lastWidth !== null) { + setSidebarWidth(lastWidth) + if (target !== document.documentElement) target.style.removeProperty('--sidebar-width') + } } cleanupRef.current = cleanup diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts index d21ebba16e7..75e6bf97f42 100644 --- a/apps/sim/hooks/use-drag-resize.ts +++ b/apps/sim/hooks/use-drag-resize.ts @@ -3,27 +3,42 @@ import { useCallback, useEffect, useRef } from 'react' interface UseDragResizeOptions { /** Cursor applied to the document body for the duration of the drag */ cursor: 'ew-resize' | 'ns-resize' + /** + * The CSS custom property this drag drives (e.g. `--panel-width`). During + * the drag it is written as `${value}px`. + */ + cssVar: string + /** + * Returns the element that consumes {@link cssVar} (or an ancestor of every + * consumer). During the drag the variable is written here — a style recalc + * scoped to that subtree — instead of on `:root`, where on a large document + * every custom-property write recalculates the whole tree (~150x slower). + * Captured once on drag start; a `null` return falls back to + * `document.documentElement`. + */ + getTarget: () => HTMLElement | null /** * Maps a pointer position to the clamped target dimension, or `null` to - * ignore the move. Runs at most once per animation frame (before `apply`, + * ignore the move. Runs at most once per animation frame (before the write, * so a layout read here happens against clean layout) and once more on * release, so it may read layout but must stay cheap. */ compute: (ev: PointerEvent) => number | null /** - * Applies per-frame visual feedback (typically a CSS variable write). - * Invoked inside requestAnimationFrame, at most once per frame. + * Persists the final value once when the drag ends. Should write the + * authoritative value to `:root` (typically via the store setter) so + * on-demand readers of {@link cssVar} stay correct; the scoped override is + * then removed. Not called when the pointer never moved (a plain click). */ - apply: (value: number) => void + commit: (value: number) => void /** - * Persists the final value once when the drag ends. Not called when the - * pointer never moved (a plain click on the handle). + * Optional per-frame hook invoked after the variable is written (e.g. the + * terminal's expanded-threshold store sync). Runs inside the rAF callback. */ - commit: (value: number) => void + onApply?: (value: number) => void /** - * Optional drag-start hook (e.g. capture an anchor element, set a store - * flag). Return `false` to abort the drag before any listeners are - * attached. + * Optional drag-start hook (e.g. set a resize class). Return `false` to + * abort the drag before any listeners are attached. */ onStart?: () => boolean | undefined /** Optional drag-end hook, invoked after teardown and commit */ @@ -37,22 +52,25 @@ interface UseDragResizeOptions { * * pointerdown → capture the pointer on the handle (so move/up keep arriving * even when the cursor leaves the window or crosses an iframe) + * and capture the scoped target element (see {@link + * UseDragResizeOptions.getTarget}) * pointermove → remember the latest pointer event and schedule a * requestAnimationFrame callback that `compute`s the clamped - * value from it and `apply`s it, so both any layout read and - * the DOM write align with the browser paint cycle - * pointerup → tear down, `compute` the final value from the latest - * pointer event, `apply` and `commit` it once — deriving the - * final value from the event (rather than reading state back - * out of the DOM) means a fast single-frame flick is never - * lost to a cancelled RAF + * value from it and writes `cssVar` to the target element, so + * both any layout read and the DOM write align with the browser + * paint cycle + * pointerup → tear down, `compute` the final value from the latest pointer + * event, write it, `commit` it once, then drop the scoped + * override so the committed `:root` value takes over — deriving + * the final value from the event (rather than reading state back + * out of the DOM) means a fast single-frame flick is never lost + * to a cancelled RAF * - * The drag is torn down by `pointerup`/`pointercancel` of the captured - * pointer (other pointers are ignored, so a second touch cannot kill the - * gesture) or window `blur`, so an interrupted gesture can never leave the - * listeners or body cursor stuck. A single-flight guard prevents stacking - * listeners across rapid presses, and an unmount cleanup tears down a drag - * still in flight. + * The drag is torn down by `pointerup`/`pointercancel` of the captured pointer + * (other pointers are ignored, so a second touch cannot kill the gesture) or + * window `blur`, so an interrupted gesture can never leave the listeners or + * body cursor stuck. A single-flight guard prevents stacking listeners across + * rapid presses, and an unmount cleanup tears down a drag still in flight. */ export function useDragResize(options: UseDragResizeOptions) { const cleanupRef = useRef<(() => void) | null>(null) @@ -68,6 +86,8 @@ export function useDragResize(options: UseDragResizeOptions) { const handle = e.currentTarget const pointerId = e.pointerId + const { cssVar } = optionsRef.current + const target = optionsRef.current.getTarget() ?? document.documentElement document.body.style.cursor = optionsRef.current.cursor document.body.style.userSelect = 'none' handle.setPointerCapture?.(pointerId) @@ -75,6 +95,11 @@ export function useDragResize(options: UseDragResizeOptions) { let rafId: number | null = null let lastEvent: PointerEvent | null = null + const applyValue = (value: number) => { + target.style.setProperty(cssVar, `${value}px`) + optionsRef.current.onApply?.(value) + } + const onPointerMove = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return lastEvent = ev @@ -82,7 +107,7 @@ export function useDragResize(options: UseDragResizeOptions) { rafId = null if (lastEvent === null) return const value = optionsRef.current.compute(lastEvent) - if (value !== null) optionsRef.current.apply(value) + if (value !== null) applyValue(value) }) } @@ -106,8 +131,9 @@ export function useDragResize(options: UseDragResizeOptions) { if (lastEvent !== null) { const value = optionsRef.current.compute(lastEvent) if (value !== null) { - optionsRef.current.apply(value) + applyValue(value) optionsRef.current.commit(value) + if (target !== document.documentElement) target.style.removeProperty(cssVar) } } optionsRef.current.onEnd?.() From 4b869607d4299efdc211e784d1f7d08d94002122 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 12:30:23 -0700 Subject: [PATCH 2/5] perf(workflow): keep float boundary-sync live during scoped resize drags The resize hooks now write their CSS variable to the consuming container element during a drag (not :root), so use-float-boundary-sync's MutationObserver on :root no longer fired mid-drag and open floats (chat, search-replace, variables) only re-clamped on release. Read each boundary dimension from its scoped element with a :root fallback, and observe the container elements as well as :root, so an open float tracks the drag live exactly as before. --- .../hooks/float/use-float-boundary-sync.ts | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts index 88fa9838d90..d8577eab5bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts @@ -12,9 +12,29 @@ interface UseFloatBoundarySyncProps { const CONTENT_WINDOW_GAP = 8 /** - * Hook to synchronize floats position with layout boundary changes. - * Keeps the float within bounds when sidebar, panel, or terminal resize. - * Uses requestAnimationFrame for smooth real-time updates + * Layout dimensions a float must stay clear of. During a resize drag the live + * value is an inline override on the consuming element (a scoped style recalc); + * at rest it lives on `:root` (committed via the store / pre-hydration script). + * Each entry pairs the element the drag writes to with its variable so the + * float tracks the drag live and re-clamps at rest. + */ +const BOUNDARY_DIMENSIONS = [ + { selector: '.sidebar-shell-outer', cssVar: '--sidebar-width' }, + { selector: '.panel-container', cssVar: '--panel-width' }, + { selector: '.terminal-container', cssVar: '--terminal-height' }, +] as const + +/** Reads a boundary dimension, preferring the drag's scoped inline override. */ +function readBoundaryDimension(selector: string, cssVar: string): number { + const inline = document.querySelector(selector)?.style.getPropertyValue(cssVar) + const value = inline || getComputedStyle(document.documentElement).getPropertyValue(cssVar) + return Number.parseInt(value || '0') +} + +/** + * Hook to synchronize a float's position with layout boundary changes. + * Keeps the float within bounds when the sidebar, panel, or terminal resize. + * Uses requestAnimationFrame for smooth real-time updates. */ export function useFloatBoundarySync({ isOpen, @@ -30,15 +50,9 @@ export function useFloatBoundarySync({ positionRef.current = position const checkAndUpdatePosition = useCallback(() => { - const sidebarWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0' - ) - const panelWidth = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0' - ) - const terminalHeight = Number.parseInt( - getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0' - ) + const sidebarWidth = readBoundaryDimension('.sidebar-shell-outer', '--sidebar-width') + const panelWidth = readBoundaryDimension('.panel-container', '--panel-width') + const terminalHeight = readBoundaryDimension('.terminal-container', '--terminal-height') const prev = previousDimensionsRef.current if ( @@ -83,11 +97,17 @@ export function useFloatBoundarySync({ window.addEventListener('resize', handleResize) + /** + * Watch both `:root` (at-rest commits, the pre-hydration script) and each + * container the resize hooks write to mid-drag, so the float re-clamps live + * throughout a drag rather than only on release. + */ const observer = new MutationObserver(handleResize) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['style'], - }) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] }) + for (const { selector } of BOUNDARY_DIMENSIONS) { + const el = document.querySelector(selector) + if (el) observer.observe(el, { attributes: true, attributeFilter: ['style'] }) + } checkAndUpdatePosition() From d7579edb512c83995b7f8a1c5d11e063ce7611b5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 12:39:14 -0700 Subject: [PATCH 3/5] fix(workflow): finalize drag on unmount + clamp reads scoped output width - useDragResize: unmounting mid-drag now runs endDrag (commit + drop the scoped override) instead of a bare cleanup, so navigating away can neither lose the resize nor strand an inline override on a surviving target element. - terminal output-panel clamp: read the live --output-panel-width from the terminal element first (where a drag writes its scoped override), then :root, then the store, so a mid-drag terminal/window resize can't clamp against a stale value. --- .../[workflowId]/components/terminal/terminal.tsx | 11 ++++++----- apps/sim/hooks/use-drag-resize.ts | 15 +++++++++------ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index 4d6c086ffdf..e2242dda5f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1255,10 +1255,10 @@ export const Terminal = memo(function Terminal() { /** * Adjust output panel width on resize. * Closes the output panel if there's not enough space for the minimum width. - * The clamp compares against the live `--output-panel-width` CSS variable - * (the visual width — during a drag it runs ahead of the store, which only - * commits on release) so a resize mid-drag can never stomp the drag with a - * stale store value; the store value is the fallback before any write. + * The clamp compares against the live `--output-panel-width` — read from the + * terminal element first (where a drag writes its scoped override), then + * `:root` (the committed value), then the store — so a resize mid-drag can + * never stomp the drag with a stale value. */ useEffect(() => { const el = terminalRef.current @@ -1276,7 +1276,8 @@ export const Terminal = memo(function Terminal() { } const liveWidth = Number.parseFloat( - document.documentElement.style.getPropertyValue('--output-panel-width') + el.style.getPropertyValue('--output-panel-width') || + document.documentElement.style.getPropertyValue('--output-panel-width') ) const currentWidth = Number.isNaN(liveWidth) ? outputPanelWidth : liveWidth if (currentWidth > maxWidth) { diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts index 75e6bf97f42..766fc9375da 100644 --- a/apps/sim/hooks/use-drag-resize.ts +++ b/apps/sim/hooks/use-drag-resize.ts @@ -70,10 +70,13 @@ interface UseDragResizeOptions { * (other pointers are ignored, so a second touch cannot kill the gesture) or * window `blur`, so an interrupted gesture can never leave the listeners or * body cursor stuck. A single-flight guard prevents stacking listeners across - * rapid presses, and an unmount cleanup tears down a drag still in flight. + * rapid presses, and unmounting mid-drag finalizes it the same way a release + * does — committing the last value and dropping the scoped override — so + * navigating away can neither lose the resize nor strand the override on a + * surviving target element. */ export function useDragResize(options: UseDragResizeOptions) { - const cleanupRef = useRef<(() => void) | null>(null) + const teardownRef = useRef<(() => void) | null>(null) const optionsRef = useRef(options) useEffect(() => { @@ -81,7 +84,7 @@ export function useDragResize(options: UseDragResizeOptions) { }, [options]) const handlePointerDown = useCallback((e: React.PointerEvent) => { - if (cleanupRef.current) return + if (teardownRef.current) return if (optionsRef.current.onStart?.() === false) return const handle = e.currentTarget @@ -123,7 +126,7 @@ export function useDragResize(options: UseDragResizeOptions) { document.removeEventListener('pointerup', onPointerEnd) document.removeEventListener('pointercancel', onPointerEnd) window.removeEventListener('blur', endDrag) - cleanupRef.current = null + teardownRef.current = null } function endDrag() { @@ -144,14 +147,14 @@ export function useDragResize(options: UseDragResizeOptions) { endDrag() } - cleanupRef.current = cleanup + teardownRef.current = endDrag document.addEventListener('pointermove', onPointerMove) document.addEventListener('pointerup', onPointerEnd) document.addEventListener('pointercancel', onPointerEnd) window.addEventListener('blur', endDrag) }, []) - useEffect(() => () => cleanupRef.current?.(), []) + useEffect(() => () => teardownRef.current?.(), []) return { handlePointerDown } } From 558ea274f5907485c13f5022bd99036b3256b2f0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 12:52:54 -0700 Subject: [PATCH 4/5] =?UTF-8?q?fix(workflow):=20robust=20drag=20finalize?= =?UTF-8?q?=20=E2=80=94=20last-applied=20value,=20sidebar=20unmount,=20cla?= =?UTF-8?q?mp=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review findings on the scoped-resize change: - useDragResize: track the last applied value and commit that on release; only recompute from the pointer event while the target is still connected. On an unmount the target's rect can be detached, so a layout-reading compute (e.g. the output panel's) would return a degenerate MIN — committing the last shown value avoids persisting the wrong width, and keeps flick-safety on a normal release. - use-sidebar-resize: finalize on unmount (run endDrag, not bare cleanup) so a drag interrupted by unmount persists the width and drops the scoped override. This matters more than for the panel/terminal because .sidebar-shell-outer lives in the workspace chrome and outlives the sidebar, so a stranded override would win over :root. - terminal output-panel clamp: skip while an output-panel drag is active (its scoped inline override is present). That drag's own compute clamps every frame against the live terminal rect, and a store-driven :root write here would be masked by the inline override anyway. --- .../components/terminal/terminal.tsx | 13 +++++++------ .../sidebar/hooks/use-sidebar-resize.ts | 17 ++++++++++------- apps/sim/hooks/use-drag-resize.ts | 19 +++++++++++++------ 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index e2242dda5f5..d50e4ce179c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1267,6 +1267,12 @@ export const Terminal = memo(function Terminal() { const handleResize = () => { if (!selectedEntry) return + // An active output-panel drag owns clamping: its own compute clamps every + // frame against the live terminal rect, and its scoped inline override on + // `.terminal-container` would mask a store-driven `:root` write here + // anyway. The inline override is present only while that drag is active. + if (el.style.getPropertyValue('--output-panel-width')) return + const maxWidth = el.getBoundingClientRect().width - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX if (maxWidth < MIN_OUTPUT_PANEL_WIDTH_PX) { @@ -1275,12 +1281,7 @@ export const Terminal = memo(function Terminal() { return } - const liveWidth = Number.parseFloat( - el.style.getPropertyValue('--output-panel-width') || - document.documentElement.style.getPropertyValue('--output-panel-width') - ) - const currentWidth = Number.isNaN(liveWidth) ? outputPanelWidth : liveWidth - if (currentWidth > maxWidth) { + if (outputPanelWidth > maxWidth) { setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts index 819434e2bec..539450f1892 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts @@ -26,16 +26,19 @@ import { useSidebarStore } from '@/stores/sidebar/store' * stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes * stuck — which would otherwise freeze the sidebar at a tiny width with the * collapse transition permanently disabled. A single-flight guard prevents - * stacking listeners across rapid presses, and an unmount cleanup tears down a - * drag still in flight when the sidebar unmounts (e.g. route change). + * stacking listeners across rapid presses, and unmounting mid-drag finalizes it + * the same way a release does — persisting the last width and dropping the + * scoped override — which matters because `.sidebar-shell-outer` lives in the + * workspace chrome and outlives the sidebar, so a stranded override would + * otherwise win over the committed `:root` value. */ export function useSidebarResize() { const setSidebarWidth = useSidebarStore((s) => s.setSidebarWidth) - const cleanupRef = useRef<(() => void) | null>(null) + const teardownRef = useRef<(() => void) | null>(null) const handlePointerDown = useCallback( (e: React.PointerEvent) => { - if (cleanupRef.current) return + if (teardownRef.current) return const handle = e.currentTarget const pointerId = e.pointerId @@ -76,7 +79,7 @@ export function useSidebarResize() { document.removeEventListener('pointerup', endDrag) document.removeEventListener('pointercancel', endDrag) window.removeEventListener('blur', endDrag) - cleanupRef.current = null + teardownRef.current = null } function endDrag() { @@ -87,7 +90,7 @@ export function useSidebarResize() { } } - cleanupRef.current = cleanup + teardownRef.current = endDrag document.addEventListener('pointermove', onPointerMove) document.addEventListener('pointerup', endDrag) document.addEventListener('pointercancel', endDrag) @@ -96,7 +99,7 @@ export function useSidebarResize() { [setSidebarWidth] ) - useEffect(() => () => cleanupRef.current?.(), []) + useEffect(() => () => teardownRef.current?.(), []) return { handlePointerDown } } diff --git a/apps/sim/hooks/use-drag-resize.ts b/apps/sim/hooks/use-drag-resize.ts index 766fc9375da..622196ea1e2 100644 --- a/apps/sim/hooks/use-drag-resize.ts +++ b/apps/sim/hooks/use-drag-resize.ts @@ -97,9 +97,11 @@ export function useDragResize(options: UseDragResizeOptions) { let rafId: number | null = null let lastEvent: PointerEvent | null = null + let lastApplied: number | null = null const applyValue = (value: number) => { target.style.setProperty(cssVar, `${value}px`) + lastApplied = value optionsRef.current.onApply?.(value) } @@ -131,13 +133,18 @@ export function useDragResize(options: UseDragResizeOptions) { function endDrag() { cleanup() - if (lastEvent !== null) { + // Recompute the final value from the last pointer position for an exact + // finish (and flick-safety when no frame ran) — but only while the target + // is still attached. On an unmount its rect can be detached, so a + // layout-reading compute would return a degenerate value; there, commit + // the last value actually shown to the user instead. + if (lastEvent !== null && target.isConnected) { const value = optionsRef.current.compute(lastEvent) - if (value !== null) { - applyValue(value) - optionsRef.current.commit(value) - if (target !== document.documentElement) target.style.removeProperty(cssVar) - } + if (value !== null) applyValue(value) + } + if (lastApplied !== null) { + optionsRef.current.commit(lastApplied) + if (target !== document.documentElement) target.style.removeProperty(cssVar) } optionsRef.current.onEnd?.() } From 4a8b2b98c23103b47a8e932025d193a482644294 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Jul 2026 13:02:00 -0700 Subject: [PATCH 5/5] fix(workflow): sidebar flick-safety + clamp reads committed :root width - use-sidebar-resize: compute the clamped width synchronously on each pointer move (storing lastWidth) and defer only the DOM write to rAF, so a fast flick released before the frame runs still commits the final pointer position instead of a stale frame or nothing. - terminal output-panel clamp: when not mid-drag, read the committed --output-panel-width from :root (written synchronously by the store setter) rather than the React store value, which lags a render behind the commit; fall back to the store before any commit. Comment corrected to match. --- .../components/terminal/terminal.tsx | 23 +++++++++++-------- .../sidebar/hooks/use-sidebar-resize.ts | 6 ++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx index d50e4ce179c..7d36f6a97bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx @@ -1255,10 +1255,15 @@ export const Terminal = memo(function Terminal() { /** * Adjust output panel width on resize. * Closes the output panel if there's not enough space for the minimum width. - * The clamp compares against the live `--output-panel-width` — read from the - * terminal element first (where a drag writes its scoped override), then - * `:root` (the committed value), then the store — so a resize mid-drag can - * never stomp the drag with a stale value. + * + * An active output-panel drag owns clamping — its own compute clamps every + * frame against the live terminal rect, and its scoped inline override on + * `.terminal-container` (present only while that drag runs) would mask a + * store-driven `:root` write here anyway — so this skips while it is active. + * Otherwise the width is read from the committed `--output-panel-width` on + * `:root`, which the store setter writes synchronously and is therefore + * fresher than the React store value (a render behind the commit), falling + * back to the store value before any commit exists. */ useEffect(() => { const el = terminalRef.current @@ -1267,10 +1272,6 @@ export const Terminal = memo(function Terminal() { const handleResize = () => { if (!selectedEntry) return - // An active output-panel drag owns clamping: its own compute clamps every - // frame against the live terminal rect, and its scoped inline override on - // `.terminal-container` would mask a store-driven `:root` write here - // anyway. The inline override is present only while that drag is active. if (el.style.getPropertyValue('--output-panel-width')) return const maxWidth = el.getBoundingClientRect().width - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX @@ -1281,7 +1282,11 @@ export const Terminal = memo(function Terminal() { return } - if (outputPanelWidth > maxWidth) { + const committed = Number.parseFloat( + document.documentElement.style.getPropertyValue('--output-panel-width') + ) + const currentWidth = Number.isNaN(committed) ? outputPanelWidth : committed + if (currentWidth > maxWidth) { setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX)) } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts index 539450f1892..c7f7cdec75b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts @@ -55,12 +55,12 @@ export function useSidebarResize() { let lastWidth: number | null = null const onPointerMove = (ev: PointerEvent) => { + const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE) + const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max) + lastWidth = clamped if (rafId !== null) cancelAnimationFrame(rafId) rafId = requestAnimationFrame(() => { - const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE) - const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max) target.style.setProperty('--sidebar-width', `${clamped}px`) - lastWidth = clamped rafId = null }) }