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.
This commit is contained in:
Waleed
2026-07-17 13:06:11 -07:00
committed by GitHub
parent 345369266d
commit 92feb24cb9
7 changed files with 195 additions and 118 deletions
@@ -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<HTMLElement>('.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,
})
}
@@ -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<Element | null>(null)
const terminalElRef = useRef<HTMLElement | null>(null)
const captureTerminalElement = useCallback(() => {
terminalElRef.current = document.querySelector('[aria-label="Terminal"]')
return terminalElRef.current !== null
const getTerminalElement = useCallback(() => {
terminalElRef.current = document.querySelector<HTMLElement>('.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,
})
}
@@ -13,26 +13,33 @@ function computeTerminalHeight(ev: PointerEvent): number {
return Math.min(Math.max(newHeight, TERMINAL_HEIGHT.MIN), maxHeight)
}
/**
* 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.
*/
function applyTerminalHeight(height: number): void {
document.documentElement.style.setProperty('--terminal-height', `${height}px`)
/** The `.terminal-container` element sizes itself from `--terminal-height`. */
function getTerminalContainer(): HTMLElement | null {
return document.querySelector<HTMLElement>('.terminal-container')
}
const store = useTerminalStore.getState()
const wasExpanded = store.terminalHeight > TERMINAL_CONFIG.NEAR_MIN_THRESHOLD
/**
* 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 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,
})
}
@@ -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` 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.
*
* 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,6 +1272,8 @@ export const Terminal = memo(function Terminal() {
const handleResize = () => {
if (!selectedEntry) return
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,10 +1282,10 @@ export const Terminal = memo(function Terminal() {
return
}
const liveWidth = Number.parseFloat(
const committed = Number.parseFloat(
document.documentElement.style.getPropertyValue('--output-panel-width')
)
const currentWidth = Number.isNaN(liveWidth) ? outputPanelWidth : liveWidth
const currentWidth = Number.isNaN(committed) ? outputPanelWidth : committed
if (currentWidth > maxWidth) {
setOutputPanelWidth(Math.max(maxWidth, MIN_OUTPUT_PANEL_WIDTH_PX))
}
@@ -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<HTMLElement>(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()
@@ -12,30 +12,39 @@ 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
* 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<HTMLElement>) => {
if (cleanupRef.current) return
if (teardownRef.current) return
const handle = e.currentTarget
const pointerId = e.pointerId
const sidebar = document.querySelector<HTMLElement>('.sidebar-container')
const shell = document.querySelector<HTMLElement>('.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 +52,15 @@ export function useSidebarResize() {
handle.setPointerCapture?.(pointerId)
let rafId: number | null = null
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)
document.documentElement.style.setProperty('--sidebar-width', `${clamped}px`)
target.style.setProperty('--sidebar-width', `${clamped}px`)
rafId = null
})
}
@@ -68,17 +79,18 @@ export function useSidebarResize() {
document.removeEventListener('pointerup', endDrag)
document.removeEventListener('pointercancel', endDrag)
window.removeEventListener('blur', endDrag)
cleanupRef.current = null
teardownRef.current = null
}
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
teardownRef.current = endDrag
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', endDrag)
document.addEventListener('pointercancel', endDrag)
@@ -87,7 +99,7 @@ export function useSidebarResize() {
[setSidebarWidth]
)
useEffect(() => () => cleanupRef.current?.(), [])
useEffect(() => () => teardownRef.current?.(), [])
return { handlePointerDown }
}
+71 -35
View File
@@ -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.
*/
apply: (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).
* 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).
*/
commit: (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 per-frame hook invoked after the variable is written (e.g. the
* terminal's expanded-threshold store sync). Runs inside the rAF callback.
*/
onApply?: (value: number) => void
/**
* 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,25 +52,31 @@ 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 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(() => {
@@ -63,17 +84,26 @@ export function useDragResize(options: UseDragResizeOptions) {
}, [options])
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (cleanupRef.current) return
if (teardownRef.current) return
if (optionsRef.current.onStart?.() === false) return
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)
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)
}
const onPointerMove = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return
@@ -82,7 +112,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)
})
}
@@ -98,17 +128,23 @@ 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() {
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) {
optionsRef.current.apply(value)
optionsRef.current.commit(value)
}
if (value !== null) applyValue(value)
}
if (lastApplied !== null) {
optionsRef.current.commit(lastApplied)
if (target !== document.documentElement) target.style.removeProperty(cssVar)
}
optionsRef.current.onEnd?.()
}
@@ -118,14 +154,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 }
}