Skip to content

Commit 67420f4

Browse files
committed
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.
1 parent caa454a commit 67420f4

5 files changed

Lines changed: 113 additions & 76 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/components/sidebar/hooks/use-sidebar-resize.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ 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
@@ -36,20 +40,24 @@ export function useSidebarResize() {
3640
const handle = e.currentTarget
3741
const pointerId = e.pointerId
3842
const sidebar = document.querySelector<HTMLElement>('.sidebar-container')
43+
const shell = document.querySelector<HTMLElement>('.sidebar-shell-outer')
44+
const target = shell ?? document.documentElement
3945
sidebar?.classList.add('is-resizing')
4046
document.documentElement.classList.add('sidebar-resizing')
4147
document.body.style.cursor = 'ew-resize'
4248
document.body.style.userSelect = 'none'
4349
handle.setPointerCapture?.(pointerId)
4450

4551
let rafId: number | null = null
52+
let lastWidth: number | null = null
4653

4754
const onPointerMove = (ev: PointerEvent) => {
4855
if (rafId !== null) cancelAnimationFrame(rafId)
4956
rafId = requestAnimationFrame(() => {
5057
const max = Math.max(SIDEBAR_WIDTH.MIN, window.innerWidth * SIDEBAR_WIDTH.MAX_PERCENTAGE)
5158
const clamped = Math.min(Math.max(ev.clientX, SIDEBAR_WIDTH.MIN), max)
52-
document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`)
59+
target.style.setProperty('--sidebar-width', `${clamped}px`)
60+
lastWidth = clamped
5361
rafId = null
5462
})
5563
}
@@ -73,9 +81,10 @@ export function useSidebarResize() {
7381

7482
function endDrag() {
7583
cleanup()
76-
const raw = document.documentElement.style.getPropertyValue('--sidebar-width')
77-
const finalWidth = Number.parseFloat(raw)
78-
if (!Number.isNaN(finalWidth)) setSidebarWidth(finalWidth)
84+
if (lastWidth !== null) {
85+
setSidebarWidth(lastWidth)
86+
if (target !== document.documentElement) target.style.removeProperty('--sidebar-width')
87+
}
7988
}
8089

8190
cleanupRef.current = cleanup

apps/sim/hooks/use-drag-resize.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,42 @@ import { useCallback, useEffect, useRef } from 'react'
33
interface UseDragResizeOptions {
44
/** Cursor applied to the document body for the duration of the drag */
55
cursor: 'ew-resize' | 'ns-resize'
6+
/**
7+
* The CSS custom property this drag drives (e.g. `--panel-width`). During
8+
* the drag it is written as `${value}px`.
9+
*/
10+
cssVar: string
11+
/**
12+
* Returns the element that consumes {@link cssVar} (or an ancestor of every
13+
* consumer). During the drag the variable is written here — a style recalc
14+
* scoped to that subtree — instead of on `:root`, where on a large document
15+
* every custom-property write recalculates the whole tree (~150x slower).
16+
* Captured once on drag start; a `null` return falls back to
17+
* `document.documentElement`.
18+
*/
19+
getTarget: () => HTMLElement | null
620
/**
721
* Maps a pointer position to the clamped target dimension, or `null` to
8-
* ignore the move. Runs at most once per animation frame (before `apply`,
22+
* ignore the move. Runs at most once per animation frame (before the write,
923
* so a layout read here happens against clean layout) and once more on
1024
* release, so it may read layout but must stay cheap.
1125
*/
1226
compute: (ev: PointerEvent) => number | null
1327
/**
14-
* Applies per-frame visual feedback (typically a CSS variable write).
15-
* Invoked inside requestAnimationFrame, at most once per frame.
28+
* Persists the final value once when the drag ends. Should write the
29+
* authoritative value to `:root` (typically via the store setter) so
30+
* on-demand readers of {@link cssVar} stay correct; the scoped override is
31+
* then removed. Not called when the pointer never moved (a plain click).
1632
*/
17-
apply: (value: number) => void
33+
commit: (value: number) => void
1834
/**
19-
* Persists the final value once when the drag ends. Not called when the
20-
* pointer never moved (a plain click on the handle).
35+
* Optional per-frame hook invoked after the variable is written (e.g. the
36+
* terminal's expanded-threshold store sync). Runs inside the rAF callback.
2137
*/
22-
commit: (value: number) => void
38+
onApply?: (value: number) => void
2339
/**
24-
* Optional drag-start hook (e.g. capture an anchor element, set a store
25-
* flag). Return `false` to abort the drag before any listeners are
26-
* attached.
40+
* Optional drag-start hook (e.g. set a resize class). Return `false` to
41+
* abort the drag before any listeners are attached.
2742
*/
2843
onStart?: () => boolean | undefined
2944
/** Optional drag-end hook, invoked after teardown and commit */
@@ -37,22 +52,25 @@ interface UseDragResizeOptions {
3752
*
3853
* pointerdown → capture the pointer on the handle (so move/up keep arriving
3954
* even when the cursor leaves the window or crosses an iframe)
55+
* and capture the scoped target element (see {@link
56+
* UseDragResizeOptions.getTarget})
4057
* pointermove → remember the latest pointer event and schedule a
4158
* requestAnimationFrame callback that `compute`s the clamped
42-
* value from it and `apply`s it, so both any layout read and
43-
* the DOM write align with the browser paint cycle
44-
* pointerup → tear down, `compute` the final value from the latest
45-
* pointer event, `apply` and `commit` it once — deriving the
46-
* final value from the event (rather than reading state back
47-
* out of the DOM) means a fast single-frame flick is never
48-
* lost to a cancelled RAF
59+
* value from it and writes `cssVar` to the target element, so
60+
* both any layout read and the DOM write align with the browser
61+
* paint cycle
62+
* pointerup → tear down, `compute` the final value from the latest pointer
63+
* event, write it, `commit` it once, then drop the scoped
64+
* override so the committed `:root` value takes over — deriving
65+
* the final value from the event (rather than reading state back
66+
* out of the DOM) means a fast single-frame flick is never lost
67+
* to a cancelled RAF
4968
*
50-
* The drag is torn down by `pointerup`/`pointercancel` of the captured
51-
* pointer (other pointers are ignored, so a second touch cannot kill the
52-
* gesture) or window `blur`, so an interrupted gesture can never leave the
53-
* listeners or body cursor stuck. A single-flight guard prevents stacking
54-
* listeners across rapid presses, and an unmount cleanup tears down a drag
55-
* still in flight.
69+
* The drag is torn down by `pointerup`/`pointercancel` of the captured pointer
70+
* (other pointers are ignored, so a second touch cannot kill the gesture) or
71+
* window `blur`, so an interrupted gesture can never leave the listeners or
72+
* body cursor stuck. A single-flight guard prevents stacking listeners across
73+
* rapid presses, and an unmount cleanup tears down a drag still in flight.
5674
*/
5775
export function useDragResize(options: UseDragResizeOptions) {
5876
const cleanupRef = useRef<(() => void) | null>(null)
@@ -68,21 +86,28 @@ export function useDragResize(options: UseDragResizeOptions) {
6886

6987
const handle = e.currentTarget
7088
const pointerId = e.pointerId
89+
const { cssVar } = optionsRef.current
90+
const target = optionsRef.current.getTarget() ?? document.documentElement
7191
document.body.style.cursor = optionsRef.current.cursor
7292
document.body.style.userSelect = 'none'
7393
handle.setPointerCapture?.(pointerId)
7494

7595
let rafId: number | null = null
7696
let lastEvent: PointerEvent | null = null
7797

98+
const applyValue = (value: number) => {
99+
target.style.setProperty(cssVar, `${value}px`)
100+
optionsRef.current.onApply?.(value)
101+
}
102+
78103
const onPointerMove = (ev: PointerEvent) => {
79104
if (ev.pointerId !== pointerId) return
80105
lastEvent = ev
81106
rafId ??= requestAnimationFrame(() => {
82107
rafId = null
83108
if (lastEvent === null) return
84109
const value = optionsRef.current.compute(lastEvent)
85-
if (value !== null) optionsRef.current.apply(value)
110+
if (value !== null) applyValue(value)
86111
})
87112
}
88113

@@ -106,8 +131,9 @@ export function useDragResize(options: UseDragResizeOptions) {
106131
if (lastEvent !== null) {
107132
const value = optionsRef.current.compute(lastEvent)
108133
if (value !== null) {
109-
optionsRef.current.apply(value)
134+
applyValue(value)
110135
optionsRef.current.commit(value)
136+
if (target !== document.documentElement) target.style.removeProperty(cssVar)
111137
}
112138
}
113139
optionsRef.current.onEnd?.()

0 commit comments

Comments
 (0)