From 1f8f65db3b370be0fa455d8f457962d549632ef2 Mon Sep 17 00:00:00 2001 From: Daniil Filippov Date: Thu, 19 Mar 2026 09:21:00 +0300 Subject: [PATCH 1/2] fix: add memoized group layout to reduce re-renders during dnd and with layout changes fix: replacing the prop transfer of the drag state with refs feat: Add transformScaleRef to DashK public interface --- src/components/GridItem/GridItem.tsx | 16 ++ src/components/GridLayout/GridLayout.tsx | 196 ++++++++-------- src/components/GridLayout/GroupLayout.tsx | 213 ++++++++++++++++++ src/components/GridLayout/ReactGridLayout.tsx | 40 +++- src/hocs/prepareItem.tsx | 68 ++++-- src/typings/common.ts | 8 + 6 files changed, 416 insertions(+), 125 deletions(-) create mode 100644 src/components/GridLayout/GroupLayout.tsx diff --git a/src/components/GridItem/GridItem.tsx b/src/components/GridItem/GridItem.tsx index 1261603..f8d6d75 100644 --- a/src/components/GridItem/GridItem.tsx +++ b/src/components/GridItem/GridItem.tsx @@ -86,12 +86,24 @@ class GridItem extends React.PureComponent { context!: React.ContextType; _isAsyncItem = false; + _isMounted = false; controller: AbortController | null = null; state: GridItemState = { isFocused: false, }; + componentDidMount() { + this._isMounted = true; + } + + componentWillUnmount() { + this._isMounted = false; + if (this.controller) { + this.controller.abort(); + } + } + renderOverlay() { const {isPlaceholder} = this.props; const {editMode} = this.context; @@ -132,6 +144,10 @@ class GridItem extends React.PureComponent { // requestAnimationFrame to make call after alert() or confirm() requestAnimationFrame(() => { + if (!this._isMounted) { + return; + } + // Adding elment an changing focus document.body.appendChild(focusDummy); focusDummy.focus(); diff --git a/src/components/GridLayout/GridLayout.tsx b/src/components/GridLayout/GridLayout.tsx index 5ae31ee..556e075 100644 --- a/src/components/GridLayout/GridLayout.tsx +++ b/src/components/GridLayout/GridLayout.tsx @@ -4,19 +4,14 @@ import type {DragOverEvent} from 'react-grid-layout'; import type {PluginRef, PluginWidgetProps, ReactGridLayoutProps} from 'src/typings'; -import { - COMPACT_TYPE_HORIZONTAL_NOWRAP, - DEFAULT_GROUP, - DRAGGABLE_CANCEL_CLASS_NAME, - TEMPORARY_ITEM_ID, -} from '../../constants'; +import {COMPACT_TYPE_HORIZONTAL_NOWRAP, DEFAULT_GROUP, TEMPORARY_ITEM_ID} from '../../constants'; import {DashKitContext} from '../../context'; import type {DashKitCtxShape} from '../../context'; import type {ConfigItem, ConfigLayout, DraggedOverItem} from '../../shared'; import {resolveLayoutGroup} from '../../utils'; import GridItem from '../GridItem/GridItem'; -import {Layout} from './ReactGridLayout'; +import {GroupLayout} from './GroupLayout'; import type { CurrentDraggingElement, GridLayoutProps, @@ -49,6 +44,16 @@ export default class GridLayout extends React.PureComponent> = {}; private _memoGroupsLayouts: Record = {}; private _memoCallbacksForGroups: Record = {}; + private _memoGroupsItems: Record = {}; + + private _sharedDragRef: React.MutableRefObject<{ + isDragging: boolean; + sourceGroup: string | null; + }> = {current: {isDragging: false, sourceGroup: null}}; + private _sharedDragPositionRef: React.MutableRefObject<{ + offsetX: number; + offsetY: number; + } | null> = {current: null}; private _timeout?: NodeJS.Timeout; private _lastReloadAt?: number; @@ -83,6 +88,7 @@ export default class GridLayout extends React.PureComponent item === nextItems[i]) + ) { + return prev; + } + this._memoGroupsItems[group] = nextItems; + return nextItems; + } + getMemoGroupProps = ( group: string, renderLayout: ConfigLayout[], - properties: Partial, + nextProperties: Partial, ) => { + // Return a stable properties reference when values are shallowly equal. + // This prevents useMemo inside GroupLayout from invalidating on every render + // when groupGridProperties() returns a structurally identical but new object. + const prevProperties = this._memoGroupsProps[group]; + const keysNext = Object.keys(nextProperties) as Array; + const stableProperties = + prevProperties && + Object.keys(prevProperties).length === keysNext.length && + keysNext.every((k) => prevProperties[k] === nextProperties[k]) + ? prevProperties + : nextProperties; + // Needed for _onDropDragOver - this._memoGroupsProps[group] = properties; + this._memoGroupsProps[group] = stableProperties; return { layout: this.getMemoGroupLayout(group, renderLayout).layout, callbacks: this.getMemoGroupCallbacks(group), + stableProperties, }; }; @@ -336,6 +368,10 @@ export default class GridLayout extends React.PureComponent ReactGridLayoutProps, ) { - const { - registerManager, - editMode, - noOverlay, - focusable, - draggableHandleClassName, - outerDnDEnable, - onItemMountChange, - onItemRender, - onItemFocus, - onItemBlur, - } = this.context; + const {registerManager} = this.context; + const {currentDraggingElement, draggedOverGroup, isDragging, isDraggedOut} = this.state; - const {currentDraggingElement, draggedOverGroup} = this.state; const hasOwnGroupProperties = typeof groupGridProperties === 'function'; const properties = hasOwnGroupProperties ? groupGridProperties({...registerManager.gridLayout}) : registerManager.gridLayout; - let {compactType} = properties; - - if (compactType === COMPACT_TYPE_HORIZONTAL_NOWRAP) { - compactType = 'horizontal'; - } - const {callbacks, layout} = this.getMemoGroupProps(group, renderLayout, properties); - const hasSharedDragItem = Boolean( - currentDraggingElement && currentDraggingElement.group !== group, + const {callbacks, layout, stableProperties} = this.getMemoGroupProps( + group, + renderLayout, + properties, ); + + const compactType: 'vertical' | 'horizontal' | null | undefined = + stableProperties.compactType === COMPACT_TYPE_HORIZONTAL_NOWRAP + ? 'horizontal' + : stableProperties.compactType; + const isDragCaptured = Boolean( currentDraggingElement && group === currentDraggingElement.group && draggedOverGroup !== null && draggedOverGroup !== group, ); + const currentDraggingItemId = + currentDraggingElement && 'id' in currentDraggingElement.item + ? currentDraggingElement.item.id + : null; + + // Scope drag props to the source group only. + // Non-source groups receive stable false/null values so their React.memo + // comparator does not see a change on these three props — only hasSharedDragItem + // can still trigger their re-render (required for cross-group drop readiness). + const isSourceGroup = Boolean(currentDraggingElement?.group === group); + const groupIsAnyDragging = isDragging && isSourceGroup; + const groupCurrentDraggingItemId = isSourceGroup ? currentDraggingItemId : null; + const groupIsAnyDraggedOut = isSourceGroup ? isDraggedOut : false; return ( - - {renderItems.map((item, i) => { - const keyId = item.id; - const isDragging = this.state.isDragging; - const isCurrentDraggedItem = - currentDraggingElement && - 'id' in currentDraggingElement.item && - currentDraggingElement.item.id === keyId; - const isDraggedOut = isCurrentDraggedItem && this.state.isDraggedOut; - const itemNoOverlay = - hasOwnGroupProperties && 'noOverlay' in properties - ? properties.noOverlay - : noOverlay; - - return ( - - ); - })} - {this.renderTemporaryPlaceholder(properties)} - + isAnyDragging={groupIsAnyDragging} + currentDraggingItemId={groupCurrentDraggingItemId} + isAnyDraggedOut={groupIsAnyDraggedOut} + adjustWidgetLayout={this.adjustWidgetLayout} + getMemoForwardRefCallback={this.getMemoForwardRefCallback} + /> ); } @@ -896,8 +896,15 @@ export default class GridLayout extends React.PureComponent; + compactType: 'vertical' | 'horizontal' | null | undefined; + callbacks: GroupCallbacks; + layout: ConfigLayout[]; + temporaryPlaceholder: React.ReactNode; + + // Drag state specific to this group (computed in GridLayout.renderGroup) + // hasSharedDragItem and sharedDragPosition replaced by stable refs read + // imperatively by DragOverLayout — no React prop change on drag start. + isDragCaptured: boolean; + dragStateRef: React.MutableRefObject<{isDragging: boolean; sourceGroup: string | null}>; + sharedDragPositionRef: React.MutableRefObject<{offsetX: number; offsetY: number} | null>; + + // Drag state scoped to this group only (always false/null for non-source groups) + isAnyDragging: boolean; + currentDraggingItemId: string | null; + isAnyDraggedOut: boolean; + + // Stable instance method refs from GridLayout (arrow function class properties) + adjustWidgetLayout: PluginWidgetProps['adjustWidgetLayout']; + getMemoForwardRefCallback: (index: number) => (ref: PluginRef) => void; +} + +function renderItemsAreEqual(a: ConfigItem[], b: ConfigItem[]): boolean { + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + return a.every((item, i) => item === b[i]); +} + +function propertiesAreEqual( + a: Partial, + b: Partial, +): boolean { + if (a === b) { + return true; + } + const keysA = Object.keys(a) as Array; + const keysB = Object.keys(b) as Array; + if (keysA.length !== keysB.length) { + return false; + } + return keysA.every((key) => a[key] === b[key]); +} + +function groupLayoutPropsAreEqual(prev: GroupLayoutProps, next: GroupLayoutProps): boolean { + // Re-render if group identity, items, or computed layout/properties changed + if ( + prev.group !== next.group || + prev.layout !== next.layout || + prev.offset !== next.offset || + !renderItemsAreEqual(prev.renderItems, next.renderItems) || + !propertiesAreEqual(prev.properties, next.properties) + ) { + return false; + } + + // Re-render if drag state scoped to this group changed. + // hasSharedDragItem and sharedDragPosition are now refs — same object reference + // forever — so they never trigger a re-render here. + if ( + prev.isDragCaptured !== next.isDragCaptured || + prev.isAnyDragging !== next.isAnyDragging || + prev.currentDraggingItemId !== next.currentDraggingItemId || + prev.isAnyDraggedOut !== next.isAnyDraggedOut + ) { + return false; + } + + if (prev.temporaryPlaceholder !== next.temporaryPlaceholder) { + return false; + } + + return true; +} + +export const GroupLayout = React.memo(function GroupLayout({ + group, + renderItems, + offset, + properties, + compactType, + callbacks, + layout, + temporaryPlaceholder, + dragStateRef, + sharedDragPositionRef, + isDragCaptured, + isAnyDragging, + currentDraggingItemId, + isAnyDraggedOut, + adjustWidgetLayout, + getMemoForwardRefCallback, +}: GroupLayoutProps) { + const { + editMode, + noOverlay, + focusable, + draggableHandleClassName, + outerDnDEnable, + onItemMountChange, + onItemRender, + onItemFocus, + onItemBlur, + } = React.useContext(DashKitContext); + + // Use group-specific noOverlay if it was explicitly set via groupGridProperties, + // otherwise fall back to the dashboard-level noOverlay from context. + const resolvedNoOverlay = 'noOverlay' in properties ? properties.noOverlay : noOverlay; + + // Memoize the items element array so that when GroupLayout re-renders due to + // hasSharedDragItem changing (all non-source groups become drop-ready on drag start), + // the items subtree is not recreated if the drag-relevant props are unchanged. + // For non-source groups isAnyDragging/currentDraggingItemId/isAnyDraggedOut are + // scoped to stable false/null by GridLayout.renderGroup, so the cached array is + // returned and React skips all GridItem subtrees entirely. + const itemElements = React.useMemo(() => { + return renderItems.map((item, i) => { + const keyId = item.id; + + const isDragging = isAnyDragging && keyId === currentDraggingItemId; + const isDraggedOut = isDragging && isAnyDraggedOut; + + return ( + + ); + }); + }, [ + renderItems, + isAnyDragging, + currentDraggingItemId, + isAnyDraggedOut, + offset, + layout, + adjustWidgetLayout, + resolvedNoOverlay, + focusable, + draggableHandleClassName, + onItemMountChange, + onItemRender, + properties, + onItemFocus, + onItemBlur, + getMemoForwardRefCallback, + ]); + + return ( + + {itemElements} + {temporaryPlaceholder} + + ); +}, groupLayoutPropsAreEqual); diff --git a/src/components/GridLayout/ReactGridLayout.tsx b/src/components/GridLayout/ReactGridLayout.tsx index f34d44e..386d716 100644 --- a/src/components/GridLayout/ReactGridLayout.tsx +++ b/src/components/GridLayout/ReactGridLayout.tsx @@ -25,9 +25,11 @@ type SharedDragPosition = { type DragOverLayoutProps = ReactGridLayout.ReactGridLayoutProps & { innerRef?: React.Ref; isDragCaptured?: boolean; - hasSharedDragItem?: boolean; - sharedDragPosition?: SharedDragPosition; + dragStateRef?: React.MutableRefObject<{isDragging: boolean; sourceGroup: string | null}>; + sharedDragPositionRef?: React.MutableRefObject; + group?: string; onDragTargetRestore?: () => void; + transformScaleRef?: React.MutableRefObject; }; type DragOverLayoutState = { @@ -219,9 +221,16 @@ class DragOverLayout extends ReactGridLayout { } }; + // Returns true when another group's item is being dragged over this grid. + // Reads from the ref directly — no re-render required to stay current. + isSharedDragTarget = (): boolean => { + const drag = this.props.dragStateRef?.current; + return Boolean(drag?.isDragging && drag?.sourceGroup !== this.props.group); + }; + // Proxy mouse events -> drag methods for dnd between groups mouseEnterHandler = (e: MouseEvent): void => { - if (this.props.hasSharedDragItem) { + if (this.isSharedDragTarget()) { // @ts-expect-error - onDragEnter is a protected method in parent class this.onDragEnter(e); } else if (this.props.isDragCaptured) { @@ -230,7 +239,7 @@ class DragOverLayout extends ReactGridLayout { }; mouseLeaveHandler = (e: MouseEvent): void => { - if (this.props.hasSharedDragItem) { + if (this.isSharedDragTarget()) { // @ts-expect-error - onDragLeave is a protected method in parent class this.onDragLeave(e); this.props.onDragTargetRestore?.(); @@ -238,7 +247,7 @@ class DragOverLayout extends ReactGridLayout { }; mouseMoveHandler = (e: MouseEvent): void => { - if (this.props.hasSharedDragItem) { + if (this.isSharedDragTarget()) { if (!(e as MouseEvent & {nativeEvent?: MouseEvent}).nativeEvent) { // Emulate nativeEvent for firefox const target = this.getInnerElement() || (e.target as HTMLElement); @@ -256,7 +265,7 @@ class DragOverLayout extends ReactGridLayout { }; mouseUpHandler = (e: MouseEvent): void => { - if (this.props.hasSharedDragItem) { + if (this.isSharedDragTarget()) { e.preventDefault(); const {droppingItem} = this.props; const {layout} = this.state; @@ -283,7 +292,7 @@ class DragOverLayout extends ReactGridLayout { }): {left: number; top: number} { const {containerWidth, cols, w, h, rowHeight, margin, transformScale, droppingPosition} = itemProps; - const {sharedDragPosition} = this.props; + const sharedDragPosition = this.props.sharedDragPositionRef?.current; let offsetX: number, offsetY: number; @@ -317,11 +326,22 @@ class DragOverLayout extends ReactGridLayout { return gridItem; } + // When a transformScaleRef is provided, inject a lazy proxy so that + // react-draggable reads the fresh scale via valueOf() at drag time + // instead of the stale number baked into the last rendered props. + // This avoids a re-render requirement when the canvas scale changes + // (e.g. after d3-zoom auto-fit) before the user starts dragging. + const {transformScaleRef} = this.props; + const lazyScale = transformScaleRef + ? ({valueOf: () => transformScaleRef.current} as unknown as number) + : undefined; + if (isDroppingItem) { // React.cloneElement is just cleaner then copy-paste whole processGridItem method return React.cloneElement(gridItem, { + ...(lazyScale !== undefined && {transformScale: lazyScale}), // hiding preview if dragging shared item - style: this.props.hasSharedDragItem + style: this.isSharedDragTarget() ? {...gridItem.props.style, opacity: 0} : gridItem.props.style, className: `${OVERLAY_CLASS_NAME} ${DROPPING_ELEMENT_CLASS_NAME}`, @@ -329,6 +349,10 @@ class DragOverLayout extends ReactGridLayout { }); } + if (lazyScale !== undefined) { + return React.cloneElement(gridItem, {transformScale: lazyScale}); + } + return gridItem; } } diff --git a/src/hocs/prepareItem.tsx b/src/hocs/prepareItem.tsx index 65dd690..9339814 100644 --- a/src/hocs/prepareItem.tsx +++ b/src/hocs/prepareItem.tsx @@ -5,6 +5,7 @@ import isEqual from 'lodash/isEqual'; import type {DashKitProps} from '../components/DashKit'; import type {ItemProps, RendererProps} from '../components/Item/types'; import {DashKitContext} from '../context'; +import type {DashKitCtxShape} from '../context'; import type {ConfigItem, ConfigLayout} from '../shared'; import type {PluginRef, PluginWidgetProps, ReactGridLayoutProps} from '../typings'; @@ -35,15 +36,54 @@ export function prepareItem( _currentRenderProps: RendererProps = {} as RendererProps; - shouldComponentUpdate(nextProps: PrepareItemProps) { + shouldComponentUpdate( + nextProps: PrepareItemProps, + _nextState: never, + nextContext: DashKitCtxShape, + ) { const {width, height, transform} = this.props; const {width: widthNext, height: heightNext, transform: transformNext} = nextProps; + // Layout changes (position/size) always trigger re-render + if (width !== widthNext || height !== heightNext || transform !== transformNext) { + return true; + } + + // While dragging — skip content updates + if (!nextProps.shouldItemUpdate) { + return false; + } + + const id = this.props.id; + const ctx = this.context; + + // Re-render only if data relevant to this specific item changed return ( - nextProps.shouldItemUpdate || - width !== widthNext || - height !== heightNext || - transform !== transformNext + this.props.item !== nextProps.item || + this.props.layout !== nextProps.layout || + ctx.itemsParams[id] !== nextContext.itemsParams[id] || + ctx.itemsState[id] !== nextContext.itemsState[id] || + ctx.editMode !== nextContext.editMode || + ctx.settings !== nextContext.settings || + ctx.context !== nextContext.context + ); + } + + render() { + const {item, isPlaceholder, forwardedPluginRef, onItemMountChange, onItemRender} = + this.props; + const {registerManager} = this.context; + + return ( + ); } @@ -93,23 +133,5 @@ export function prepareItem( return this._currentRenderProps; }; - - render() { - const {item, isPlaceholder, forwardedPluginRef, onItemMountChange, onItemRender} = - this.props; - const {registerManager} = this.context; - - return ( - - ); - } }; } diff --git a/src/typings/common.ts b/src/typings/common.ts index be76010..6e1771b 100644 --- a/src/typings/common.ts +++ b/src/typings/common.ts @@ -1,3 +1,5 @@ +import React from 'react'; + import type ReactGridLayout from 'react-grid-layout'; import type {OverlayCustomControlItem} from '../components/OverlayControls/OverlayControls'; @@ -25,6 +27,12 @@ export type ReactGridLayoutProps = Omit< > & { compactType?: CompactType; noOverlay?: boolean; + /** + * When the grid is inside a scaled container, pass a ref to the scale factor. + * DragOverLayout will inject a lazy proxy so react-draggable reads the fresh + * value via valueOf() at drag time — no re-render required. + */ + transformScaleRef?: React.MutableRefObject; }; export interface Settings { From 62c3957a3d3ee57c3ece8d2707c766545fcbe8f6 Mon Sep 17 00:00:00 2001 From: Daniil Filippov Date: Thu, 19 Mar 2026 12:39:16 +0300 Subject: [PATCH 2/2] fix: comments change hasSharedDragItem to ref dragStateRef, sharedDragPositionRef --- src/components/GridLayout/GridLayout.tsx | 14 ++++---------- src/components/GridLayout/GroupLayout.tsx | 15 +++------------ src/components/GridLayout/ReactGridLayout.tsx | 6 +----- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/components/GridLayout/GridLayout.tsx b/src/components/GridLayout/GridLayout.tsx index 556e075..4d3e642 100644 --- a/src/components/GridLayout/GridLayout.tsx +++ b/src/components/GridLayout/GridLayout.tsx @@ -5,8 +5,8 @@ import type {DragOverEvent} from 'react-grid-layout'; import type {PluginRef, PluginWidgetProps, ReactGridLayoutProps} from 'src/typings'; import {COMPACT_TYPE_HORIZONTAL_NOWRAP, DEFAULT_GROUP, TEMPORARY_ITEM_ID} from '../../constants'; -import {DashKitContext} from '../../context'; import type {DashKitCtxShape} from '../../context'; +import {DashKitContext} from '../../context'; import type {ConfigItem, ConfigLayout, DraggedOverItem} from '../../shared'; import {resolveLayoutGroup} from '../../utils'; import GridItem from '../GridItem/GridItem'; @@ -205,9 +205,7 @@ export default class GridLayout extends React.PureComponent, ) => { - // Return a stable properties reference when values are shallowly equal. - // This prevents useMemo inside GroupLayout from invalidating on every render - // when groupGridProperties() returns a structurally identical but new object. + // Return stable ref to prevent useMemo invalidation when properties are shallowly equal. const prevProperties = this._memoGroupsProps[group]; const keysNext = Object.keys(nextProperties) as Array; const stableProperties = @@ -369,8 +367,7 @@ export default class GridLayout extends React.PureComponent; sharedDragPositionRef: React.MutableRefObject<{offsetX: number; offsetY: number} | null>; @@ -75,9 +73,7 @@ function groupLayoutPropsAreEqual(prev: GroupLayoutProps, next: GroupLayoutProps return false; } - // Re-render if drag state scoped to this group changed. - // hasSharedDragItem and sharedDragPosition are now refs — same object reference - // forever — so they never trigger a re-render here. + // Re-render only on group-scoped props; drag refs are stable, never trigger this. if ( prev.isDragCaptured !== next.isDragCaptured || prev.isAnyDragging !== next.isAnyDragging || @@ -128,12 +124,7 @@ export const GroupLayout = React.memo(function GroupLayout({ // otherwise fall back to the dashboard-level noOverlay from context. const resolvedNoOverlay = 'noOverlay' in properties ? properties.noOverlay : noOverlay; - // Memoize the items element array so that when GroupLayout re-renders due to - // hasSharedDragItem changing (all non-source groups become drop-ready on drag start), - // the items subtree is not recreated if the drag-relevant props are unchanged. - // For non-source groups isAnyDragging/currentDraggingItemId/isAnyDraggedOut are - // scoped to stable false/null by GridLayout.renderGroup, so the cached array is - // returned and React skips all GridItem subtrees entirely. + // Memoize items array; non-source groups have stable false/null drag props so React skips their subtrees. const itemElements = React.useMemo(() => { return renderItems.map((item, i) => { const keyId = item.id; diff --git a/src/components/GridLayout/ReactGridLayout.tsx b/src/components/GridLayout/ReactGridLayout.tsx index 386d716..d5fcacd 100644 --- a/src/components/GridLayout/ReactGridLayout.tsx +++ b/src/components/GridLayout/ReactGridLayout.tsx @@ -326,11 +326,7 @@ class DragOverLayout extends ReactGridLayout { return gridItem; } - // When a transformScaleRef is provided, inject a lazy proxy so that - // react-draggable reads the fresh scale via valueOf() at drag time - // instead of the stale number baked into the last rendered props. - // This avoids a re-render requirement when the canvas scale changes - // (e.g. after d3-zoom auto-fit) before the user starts dragging. + // Lazy proxy for transformScaleRef so react-draggable reads fresh scale without re-render. const {transformScaleRef} = this.props; const lazyScale = transformScaleRef ? ({valueOf: () => transformScaleRef.current} as unknown as number)