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..4d3e642 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 {DashKitContext} from '../../context'; +import {COMPACT_TYPE_HORIZONTAL_NOWRAP, DEFAULT_GROUP, TEMPORARY_ITEM_ID} from '../../constants'; 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'; -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 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 = + 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 +366,9 @@ 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; + + // Non-source groups get stable false/null — memo skips re-render on drag move. + 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 +890,15 @@ export default class GridLayout extends React.PureComponent; + compactType: 'vertical' | 'horizontal' | null | undefined; + callbacks: GroupCallbacks; + layout: ConfigLayout[]; + temporaryPlaceholder: React.ReactNode; + + // Refs read imperatively by DragOverLayout — no re-render 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 only on group-scoped props; drag refs are stable, never trigger this. + 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 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; + + 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..d5fcacd 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,18 @@ class DragOverLayout extends ReactGridLayout { return gridItem; } + // 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) + : 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 +345,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 {