Skip to content

Commit 92feb24

Browse files
authored
perf(workflow): scope resize CSS-var writes to the container, not :root (#5738)
* perf(workflow): scope resize CSS-var writes to the container, not :root 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 <html> 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. * 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. * 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. * fix(workflow): robust drag finalize — last-applied value, sidebar unmount, clamp skip 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. * 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.
1 parent 3453692 commit 92feb24

7 files changed

Lines changed: 193 additions & 116 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks/use-panel-resize.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,17 @@ function computePanelWidth(ev: PointerEvent): number {
1313
return Math.min(Math.max(newWidth, PANEL_WIDTH.MIN), maxWidth)
1414
}
1515

16-
/**
17-
* Applies the panel width per frame. The `--panel-width` CSS variable alone
18-
* sizes `.panel-container`, so no React work happens during the drag.
19-
*/
20-
function applyPanelWidth(width: number): void {
21-
document.documentElement.style.setProperty('--panel-width', `${width}px`)
16+
/** The `.panel-container` element sizes itself from `--panel-width`. */
17+
function getPanelContainer(): HTMLElement | null {
18+
return document.querySelector<HTMLElement>('.panel-container')
2219
}
2320

2421
/**
25-
* Handles panel drag-resize with zero React renders during the drag.
26-
* The final width is committed to the store (one re-render + one
27-
* localStorage write) when the drag ends.
22+
* Handles panel drag-resize with zero React renders during the drag. The
23+
* `--panel-width` variable is written to `.panel-container` (a scoped style
24+
* recalc) rather than `:root` (a whole-document recalc), and the final width
25+
* is committed to the store (one re-render + one localStorage write) when the
26+
* drag ends.
2827
*
2928
* @returns Pointer-down handler for the resize handle
3029
*/
@@ -33,8 +32,9 @@ export function usePanelResize() {
3332

3433
return useDragResize({
3534
cursor: 'ew-resize',
35+
cssVar: '--panel-width',
36+
getTarget: getPanelContainer,
3637
compute: computePanelWidth,
37-
apply: applyPanelWidth,
3838
commit: setPanelWidth,
3939
})
4040
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-output-panel-resize.ts

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,25 @@ import { useDragResize } from '@/hooks/use-drag-resize'
33
import { OUTPUT_PANEL_WIDTH, TERMINAL_BLOCK_COLUMN_WIDTH } from '@/stores/constants'
44
import { useTerminalStore } from '@/stores/terminal'
55

6-
/**
7-
* Applies the output panel width per frame. The `--output-panel-width` CSS
8-
* variable alone sizes the output panel and its sibling logs column, so no
9-
* React work happens during the drag.
10-
*/
11-
function applyOutputPanelWidth(width: number): void {
12-
document.documentElement.style.setProperty('--output-panel-width', `${width}px`)
13-
}
14-
156
/**
167
* Handles the terminal output panel drag-resize with zero React renders
17-
* during the drag. The terminal element is captured once on drag start and
18-
* its rect is re-read per frame (rAF-aligned, before the CSS-var write), so
19-
* the clamp stays correct even if the terminal resizes mid-drag. The final
20-
* width is committed to the store (one re-render + one localStorage write)
21-
* when the drag ends.
8+
* during the drag. `--output-panel-width` is written to `.terminal-container`
9+
* (which both the output panel and its sibling logs column inherit from) — a
10+
* scoped style recalc rather than a whole-document one on `:root`. The
11+
* terminal rect is re-read per frame (rAF-aligned, before the write), so the
12+
* clamp stays correct even if the terminal resizes mid-drag. The final width
13+
* is committed to the store (one re-render + one localStorage write) when the
14+
* drag ends.
2215
*
2316
* @returns Pointer-down handler for the resize handle
2417
*/
2518
export function useOutputPanelResize() {
2619
const setOutputPanelWidth = useTerminalStore((s) => s.setOutputPanelWidth)
27-
const terminalElRef = useRef<Element | null>(null)
20+
const terminalElRef = useRef<HTMLElement | null>(null)
2821

29-
const captureTerminalElement = useCallback(() => {
30-
terminalElRef.current = document.querySelector('[aria-label="Terminal"]')
31-
return terminalElRef.current !== null
22+
const getTerminalElement = useCallback(() => {
23+
terminalElRef.current = document.querySelector<HTMLElement>('.terminal-container')
24+
return terminalElRef.current
3225
}, [])
3326

3427
const computeOutputPanelWidth = useCallback((ev: PointerEvent) => {
@@ -42,9 +35,9 @@ export function useOutputPanelResize() {
4235

4336
return useDragResize({
4437
cursor: 'ew-resize',
38+
cssVar: '--output-panel-width',
39+
getTarget: getTerminalElement,
4540
compute: computeOutputPanelWidth,
46-
apply: applyOutputPanelWidth,
4741
commit: setOutputPanelWidth,
48-
onStart: captureTerminalElement,
4942
})
5043
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/hooks/use-terminal-resize.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,33 @@ function computeTerminalHeight(ev: PointerEvent): number {
1313
return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight)
1414
}
1515

16+
/** The `.terminal-container` element sizes itself from `--terminal-height`. */
17+
function getTerminalContainer(): HTMLElement | null {
18+
return document.querySelector<HTMLElement>('.terminal-container')
19+
}
20+
1621
/**
17-
* Applies the terminal height per frame. The `--terminal-height` CSS
18-
* variable alone sizes `.terminal-container`, so no React work happens on
19-
* ordinary frames. The store is committed mid-drag only when the height
20-
* crosses the expanded threshold, so `isExpanded` subscribers (header
21-
* chevron, auto-open logic) still flip live during the drag.
22+
* Updates the store height mid-drag only when it crosses the expanded
23+
* threshold, so `isExpanded` subscribers (header chevron, auto-open logic)
24+
* still flip live during the drag. Writes store state directly rather than
25+
* calling `setTerminalHeight` so it does not also write `--terminal-height` to
26+
* `:root` (a whole-document recalc) — the scoped CSS-var write handled by
27+
* {@link useDragResize} already drives the visual, and the final value is
28+
* persisted through `setTerminalHeight` on release.
2229
*/
23-
function applyTerminalHeight(height: number): void {
24-
document.documentElement.style.setProperty('--terminal-height', `${height}px`)
25-
26-
const store = useTerminalStore.getState()
27-
const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
30+
function syncExpandedThreshold(height: number): void {
31+
const wasExpanded =
32+
useTerminalStore.getState().terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
2833
const nowExpanded = height > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
29-
if (wasExpanded !== nowExpanded) store.setTerminalHeight(height)
34+
if (wasExpanded !== nowExpanded) useTerminalStore.setState({ terminalHeight: height })
3035
}
3136

3237
/**
3338
* Handles terminal drag-resize with zero React renders during the drag
34-
* (except at expanded-threshold crossings). The final height is committed
35-
* to the store (one re-render + one localStorage write) when the drag ends.
39+
* (except at expanded-threshold crossings). The `--terminal-height` variable
40+
* is written to `.terminal-container` (a scoped style recalc) rather than
41+
* `:root` (a whole-document recalc), and the final height is committed to the
42+
* store (one re-render + one localStorage write) when the drag ends.
3643
*
3744
* @returns Pointer-down handler for the resize handle
3845
*/
@@ -41,8 +48,10 @@ export function useTerminalResize() {
4148

4249
return useDragResize({
4350
cursor: 'ns-resize',
51+
cssVar: '--terminal-height',
52+
getTarget: getTerminalContainer,
4453
compute: computeTerminalHeight,
45-
apply: applyTerminalHeight,
4654
commit: setTerminalHeight,
55+
onApply: syncExpandedThreshold,
4756
})
4857
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,10 +1255,15 @@ export const Terminal = memo(function Terminal() {
12551255
/**
12561256
* Adjust output panel width on resize.
12571257
* Closes the output panel if there's not enough space for the minimum width.
1258-
* The clamp compares against the live `--output-panel-width` CSS variable
1259-
* (the visual width — during a drag it runs ahead of the store, which only
1260-
* commits on release) so a resize mid-drag can never stomp the drag with a
1261-
* stale store value; the store value is the fallback before any write.
1258+
*
1259+
* An active output-panel drag owns clamping — its own compute clamps every
1260+
* frame against the live terminal rect, and its scoped inline override on
1261+
* `.terminal-container` (present only while that drag runs) would mask a
1262+
* store-driven `:root` write here anyway — so this skips while it is active.
1263+
* Otherwise the width is read from the committed `--output-panel-width` on
1264+
* `:root`, which the store setter writes synchronously and is therefore
1265+
* fresher than the React store value (a render behind the commit), falling
1266+
* back to the store value before any commit exists.
12621267
*/
12631268
useEffect(() => {
12641269
const el = terminalRef.current
@@ -1267,6 +1272,8 @@ export const Terminal = memo(function Terminal() {
12671272
const handleResize = () => {
12681273
if (!selectedEntry) return
12691274

1275+
if (el.style.getPropertyValue('--output-panel-width')) return
1276+
12701277
const maxWidth = el.getBoundingClientRect().width - TERMINAL_CONFIG.BLOCK_COLUMN_WIDTH_PX
12711278

12721279
if (maxWidth < MIN_OUTPUT_PANEL_WIDTH_PX) {
@@ -1275,10 +1282,10 @@ export const Terminal = memo(function Terminal() {
12751282
return
12761283
}
12771284

1278-
const liveWidth = Number.parseFloat(
1285+
const committed = Number.parseFloat(
12791286
document.documentElement.style.getPropertyValue('--output-panel-width')
12801287
)
1281-
const currentWidth = Number.isNaN(liveWidth) ? outputPanelWidth : liveWidth
1288+
const currentWidth = Number.isNaN(committed) ? outputPanelWidth : committed
12821289
if (currentWidth > maxWidth) {
12831290
setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX))
12841291
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/float/use-float-boundary-sync.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,29 @@ interface UseFloatBoundarySyncProps {
1212
const CONTENT_WINDOW_GAP = 8
1313

1414
/**
15-
* Hook to synchronize floats position with layout boundary changes.
16-
* Keeps the float within bounds when sidebar, panel, or terminal resize.
17-
* Uses requestAnimationFrame for smooth real-time updates
15+
* Layout dimensions a float must stay clear of. During a resize drag the live
16+
* value is an inline override on the consuming element (a scoped style recalc);
17+
* at rest it lives on `:root` (committed via the store / pre-hydration script).
18+
* Each entry pairs the element the drag writes to with its variable so the
19+
* float tracks the drag live and re-clamps at rest.
20+
*/
21+
const BOUNDARY_DIMENSIONS = [
22+
{ selector: '.sidebar-shell-outer', cssVar: '--sidebar-width' },
23+
{ selector: '.panel-container', cssVar: '--panel-width' },
24+
{ selector: '.terminal-container', cssVar: '--terminal-height' },
25+
] as const
26+
27+
/** Reads a boundary dimension, preferring the drag's scoped inline override. */
28+
function readBoundaryDimension(selector: string, cssVar: string): number {
29+
const inline = document.querySelector<HTMLElement>(selector)?.style.getPropertyValue(cssVar)
30+
const value = inline || getComputedStyle(document.documentElement).getPropertyValue(cssVar)
31+
return Number.parseInt(value || '0')
32+
}
33+
34+
/**
35+
* Hook to synchronize a float's position with layout boundary changes.
36+
* Keeps the float within bounds when the sidebar, panel, or terminal resize.
37+
* Uses requestAnimationFrame for smooth real-time updates.
1838
*/
1939
export function useFloatBoundarySync({
2040
isOpen,
@@ -30,15 +50,9 @@ export function useFloatBoundarySync({
3050
positionRef.current = position
3151

3252
const checkAndUpdatePosition = useCallback(() => {
33-
const sidebarWidth = Number.parseInt(
34-
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width') || '0'
35-
)
36-
const panelWidth = Number.parseInt(
37-
getComputedStyle(document.documentElement).getPropertyValue('--panel-width') || '0'
38-
)
39-
const terminalHeight = Number.parseInt(
40-
getComputedStyle(document.documentElement).getPropertyValue('--terminal-height') || '0'
41-
)
53+
const sidebarWidth = readBoundaryDimension('.sidebar-shell-outer', '--sidebar-width')
54+
const panelWidth = readBoundaryDimension('.panel-container', '--panel-width')
55+
const terminalHeight = readBoundaryDimension('.terminal-container', '--terminal-height')
4256

4357
const prev = previousDimensionsRef.current
4458
if (
@@ -83,11 +97,17 @@ export function useFloatBoundarySync({
8397

8498
window.addEventListener('resize', handleResize)
8599

100+
/**
101+
* Watch both `:root` (at-rest commits, the pre-hydration script) and each
102+
* container the resize hooks write to mid-drag, so the float re-clamps live
103+
* throughout a drag rather than only on release.
104+
*/
86105
const observer = new MutationObserver(handleResize)
87-
observer.observe(document.documentElement, {
88-
attributes: true,
89-
attributeFilter: ['style'],
90-
})
106+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
107+
for (const { selector } of BOUNDARY_DIMENSIONS) {
108+
const el = document.querySelector(selector)
109+
if (el) observer.observe(el, { attributes: true, attributeFilter: ['style'] })
110+
}
91111

92112
checkAndUpdatePosition()
93113

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-sidebar-resize.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,44 +12,55 @@ import { useSidebarStore } from '@/stores/sidebar/store'
1212
* add `is-resizing` class directly to the DOM (no React
1313
* round-trip, so the CSS width transition is suppressed from the
1414
* very first frame)
15-
* pointermove → write to --sidebar-width inside a requestAnimationFrame
16-
* callback (aligns work with the browser paint cycle)
15+
* pointermove → write --sidebar-width to `.sidebar-shell-outer` (the element
16+
* that sizes the rail) inside a requestAnimationFrame callback.
17+
* Scoping the variable to that subtree keeps the style recalc
18+
* local; writing it to `:root` instead forces a whole-document
19+
* recalc (~150x slower on a large canvas).
1720
* pointerup → cancel any pending RAF, tear down, persist final width to
18-
* Zustand once (one React re-render to save to localStorage)
21+
* Zustand once (writes the authoritative `:root` value for
22+
* on-demand readers), then drop the scoped override
1923
*
2024
* The drag is torn down by `pointerup`, `pointercancel`, or window `blur`, so an
2125
* interrupted gesture (release outside the window, alt-tab, context menu, the OS
2226
* stealing focus) can never leave the `is-resizing` / `sidebar-resizing` classes
2327
* stuck — which would otherwise freeze the sidebar at a tiny width with the
2428
* collapse transition permanently disabled. A single-flight guard prevents
25-
* stacking listeners across rapid presses, and an unmount cleanup tears down a
26-
* drag still in flight when the sidebar unmounts (e.g. route change).
29+
* stacking listeners across rapid presses, and unmounting mid-drag finalizes it
30+
* the same way a release does — persisting the last width and dropping the
31+
* scoped override — which matters because `.sidebar-shell-outer` lives in the
32+
* workspace chrome and outlives the sidebar, so a stranded override would
33+
* otherwise win over the committed `:root` value.
2734
*/
2835
export function useSidebarResize() {
2936
const setSidebarWidth = useSidebarStore((s) => s.setSidebarWidth)
30-
const cleanupRef = useRef<(() => void) | null>(null)
37+
const teardownRef = useRef<(() => void) | null>(null)
3138

3239
const handlePointerDown = useCallback(
3340
(e: React.PointerEvent<HTMLElement>) => {
34-
if (cleanupRef.current) return
41+
if (teardownRef.current) return
3542

3643
const handle = e.currentTarget
3744
const pointerId = e.pointerId
3845
const sidebar = document.querySelector<HTMLElement>('.sidebar-container')
46+
const shell = document.querySelector<HTMLElement>('.sidebar-shell-outer')
47+
const target = shell ?? document.documentElement
3948
sidebar?.classList.add('is-resizing')
4049
document.documentElement.classList.add('sidebar-resizing')
4150
document.body.style.cursor = 'ew-resize'
4251
document.body.style.userSelect = 'none'
4352
handle.setPointerCapture?.(pointerId)
4453

4554
let rafId: number | null = null
55+
let lastWidth: number | null = null
4656

4757
const onPointerMove = (ev: PointerEvent) => {
58+
const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
59+
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
60+
lastWidth = clamped
4861
if (rafId !== null) cancelAnimationFrame(rafId)
4962
rafId = requestAnimationFrame(() => {
50-
const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
51-
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
52-
document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`)
63+
target.style.setProperty('--sidebar-width', `${clamped}px`)
5364
rafId = null
5465
})
5566
}
@@ -68,17 +79,18 @@ export function useSidebarResize() {
6879
document.removeEventListener('pointerup', endDrag)
6980
document.removeEventListener('pointercancel', endDrag)
7081
window.removeEventListener('blur', endDrag)
71-
cleanupRef.current = null
82+
teardownRef.current = null
7283
}
7384

7485
function endDrag() {
7586
cleanup()
76-
const raw = document.documentElement.style.getPropertyValue('--sidebar-width')
77-
const finalWidth = Number.parseFloat(raw)
78-
if (!Number.isNaN(finalWidth)) setSidebarWidth(finalWidth)
87+
if (lastWidth !== null) {
88+
setSidebarWidth(lastWidth)
89+
if (target !== document.documentElement) target.style.removeProperty('--sidebar-width')
90+
}
7991
}
8092

81-
cleanupRef.current = cleanup
93+
teardownRef.current = endDrag
8294
document.addEventListener('pointermove', onPointerMove)
8395
document.addEventListener('pointerup', endDrag)
8496
document.addEventListener('pointercancel', endDrag)
@@ -87,7 +99,7 @@ export function useSidebarResize() {
8799
[setSidebarWidth]
88100
)
89101

90-
useEffect(() => () => cleanupRef.current?.(), [])
102+
useEffect(() => () => teardownRef.current?.(), [])
91103

92104
return { handlePointerDown }
93105
}

0 commit comments

Comments
 (0)