From 98381b4dce384810bc202acd3f7ad14ca7b7b813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claude=CC=81ric=20Demers?= Date: Thu, 6 Jan 2022 17:51:59 -0500 Subject: [PATCH 1/4] Allow partial recomputation of droppable rects Also renamed `recomputeLayouts` to `recomputeRects` and `willRecomputeLayouts` to `willRecomputeRects` --- .../src/components/DndContext/DndContext.tsx | 12 +-- .../hooks/utilities/useDroppableMeasuring.ts | 92 ++++++++++--------- packages/core/src/store/context.ts | 4 +- packages/core/src/store/types.ts | 4 +- .../src/components/SortableContext.tsx | 12 +-- 5 files changed, 63 insertions(+), 61 deletions(-) diff --git a/packages/core/src/components/DndContext/DndContext.tsx b/packages/core/src/components/DndContext/DndContext.tsx index 18639004..744ed1f7 100644 --- a/packages/core/src/components/DndContext/DndContext.tsx +++ b/packages/core/src/components/DndContext/DndContext.tsx @@ -188,8 +188,8 @@ export const DndContext = memo(function DndContext({ }, [droppableContainers]); const { rectMap: droppableRects, - recomputeLayouts, - willRecomputeLayouts, + recomputeRects, + willRecomputeRects, } = useDroppableMeasuring(enabledDroppableContainers, { dragging: isDragging, dependencies: [translate.x, translate.y], @@ -614,10 +614,10 @@ export const DndContext = memo(function DndContext({ droppableContainers, droppableRects, over, - recomputeLayouts, + recomputeRects, scrollableAncestors, scrollableAncestorRects, - willRecomputeLayouts, + willRecomputeRects, windowRect, }; @@ -637,10 +637,10 @@ export const DndContext = memo(function DndContext({ droppableContainers, droppableRects, over, - recomputeLayouts, + recomputeRects, scrollableAncestors, scrollableAncestorRects, - willRecomputeLayouts, + willRecomputeRects, windowRect, ]); diff --git a/packages/core/src/hooks/utilities/useDroppableMeasuring.ts b/packages/core/src/hooks/utilities/useDroppableMeasuring.ts index ef9690d9..514a1068 100644 --- a/packages/core/src/hooks/utilities/useDroppableMeasuring.ts +++ b/packages/core/src/hooks/utilities/useDroppableMeasuring.ts @@ -3,7 +3,7 @@ import {useLazyMemo} from '@dnd-kit/utilities'; import {Rect, getTransformAgnosticClientRect} from '../../utilities/rect'; import type {DroppableContainer, RectMap} from '../../store/types'; -import type {ClientRect} from '../../types'; +import type {ClientRect, UniqueIdentifier} from '../../types'; interface Arguments { dragging: boolean; @@ -41,14 +41,21 @@ export function useDroppableMeasuring( containers: DroppableContainer[], {dragging, dependencies, config}: Arguments ) { - const [willRecomputeLayouts, setWillRecomputeLayouts] = useState(false); + const [recomputeIds, setRecomputeIds] = useState( + null + ); + const willRecomputeRects = recomputeIds != null; const {frequency, measure, strategy} = { ...defaultConfig, ...config, }; const containersRef = useRef(containers); - const recomputeLayouts = useCallback(() => setWillRecomputeLayouts(true), []); - const recomputeLayoutsTimeoutId = useRef(null); + const recomputeRects = useCallback( + (ids: UniqueIdentifier[] = []) => + setRecomputeIds((value) => (value ? value.concat(ids) : ids)), + [] + ); + const recomputeRectsTimeoutId = useRef(null); const disabled = isDisabled(); const rectMap = useLazyMemo( (previousValue) => { @@ -60,70 +67,89 @@ export function useDroppableMeasuring( !previousValue || previousValue === defaultValue || containersRef.current !== containers || - willRecomputeLayouts + recomputeIds != null ) { + const rectMap: RectMap = new Map(); + for (let container of containers) { if (!container) { continue; } + + if ( + recomputeIds && + recomputeIds.length > 0 && + !recomputeIds.includes(container.id) && + container.rect.current + ) { + // This container does not need to be recomputed + rectMap.set(container.id, container.rect.current); + continue; + } + const node = container.node.current; + const rect = node ? new Rect(measure(node), node) : null; - container.rect.current = node ? new Rect(measure(node), node) : null; + container.rect.current = rect; + + if (rect) { + rectMap.set(container.id, rect); + } } - return createRectMap(containers); + return rectMap; } return previousValue; }, - [containers, dragging, disabled, measure, willRecomputeLayouts] + [containers, dragging, disabled, measure, recomputeIds] ); useEffect(() => { containersRef.current = containers; }, [containers]); - useEffect(() => { - if (willRecomputeLayouts) { - setWillRecomputeLayouts(false); - } - }, [willRecomputeLayouts]); - useEffect( function recompute() { if (disabled) { return; } - requestAnimationFrame(recomputeLayouts); + requestAnimationFrame(() => recomputeRects()); }, // eslint-disable-next-line react-hooks/exhaustive-deps [dragging, disabled] ); + useEffect(() => { + if (willRecomputeRects) { + setRecomputeIds(null); + } + }, [willRecomputeRects]); + useEffect( function forceRecomputeLayouts() { if ( disabled || typeof frequency !== 'number' || - recomputeLayoutsTimeoutId.current !== null + recomputeRectsTimeoutId.current !== null ) { return; } - recomputeLayoutsTimeoutId.current = setTimeout(() => { - recomputeLayouts(); - recomputeLayoutsTimeoutId.current = null; + recomputeRectsTimeoutId.current = setTimeout(() => { + recomputeRects(); + recomputeRectsTimeoutId.current = null; }, frequency); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [frequency, disabled, recomputeLayouts, ...dependencies] + [frequency, disabled, recomputeRects, ...dependencies] ); return { rectMap, - recomputeLayouts, - willRecomputeLayouts, + recomputeRects, + willRecomputeRects, }; function isDisabled() { @@ -137,25 +163,3 @@ export function useDroppableMeasuring( } } } - -function createRectMap(containers: DroppableContainer[] | null): RectMap { - const rectMap: RectMap = new Map(); - - if (containers) { - for (const container of containers) { - if (!container) { - continue; - } - - const {id, rect} = container; - - if (rect.current == null) { - continue; - } - - rectMap.set(id, rect.current); - } - } - - return rectMap; -} diff --git a/packages/core/src/store/context.ts b/packages/core/src/store/context.ts index 2e997b57..ed57013d 100644 --- a/packages/core/src/store/context.ts +++ b/packages/core/src/store/context.ts @@ -29,7 +29,7 @@ export const Context = createContext({ }, scrollableAncestors: [], scrollableAncestorRects: [], - recomputeLayouts: noop, + recomputeRects: noop, windowRect: null, - willRecomputeLayouts: false, + willRecomputeRects: false, }); diff --git a/packages/core/src/store/types.ts b/packages/core/src/store/types.ts index 914f0d93..0ffc9a5d 100644 --- a/packages/core/src/store/types.ts +++ b/packages/core/src/store/types.ts @@ -94,7 +94,7 @@ export interface DndContextDescriptor { }; scrollableAncestors: Element[]; scrollableAncestorRects: ClientRect[]; - recomputeLayouts(): void; - willRecomputeLayouts: boolean; + recomputeRects(ids: UniqueIdentifier[]): void; + willRecomputeRects: boolean; windowRect: ClientRect | null; } diff --git a/packages/sortable/src/components/SortableContext.tsx b/packages/sortable/src/components/SortableContext.tsx index 05b4472a..839b78d9 100644 --- a/packages/sortable/src/components/SortableContext.tsx +++ b/packages/sortable/src/components/SortableContext.tsx @@ -50,8 +50,8 @@ export function SortableContext({ dragOverlay, droppableRects, over, - recomputeLayouts, - willRecomputeLayouts, + recomputeRects, + willRecomputeRects, } = useDndContext(); const containerId = useUniqueId(ID_PREFIX, id); const useDragOverlay = Boolean(dragOverlay.rect !== null); @@ -65,7 +65,6 @@ export function SortableContext({ const isDragging = active != null; const wasDragging = useRef(false); const activeIndex = active ? items.indexOf(active.id) : -1; - const isSorting = activeIndex !== -1; const overIndex = over ? items.indexOf(over.id) : -1; const previousItemsRef = useRef(items); const sortedRects = getSortedRects(items, droppableRects); @@ -74,11 +73,10 @@ export function SortableContext({ (overIndex !== -1 && activeIndex === -1) || itemsHaveChanged; useIsomorphicLayoutEffect(() => { - if (itemsHaveChanged && isSorting && !willRecomputeLayouts) { - // To-do: Add partial recomputation of only subset of rects - recomputeLayouts(); + if (itemsHaveChanged && isDragging && !willRecomputeRects) { + recomputeRects(items); } - }, [itemsHaveChanged, isSorting, recomputeLayouts, willRecomputeLayouts]); + }, [itemsHaveChanged, items, isDragging, recomputeRects, willRecomputeRects]); useEffect(() => { previousItemsRef.current = items; From a044e81688156a16c778b6d5c977980805dbdc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claude=CC=81ric=20Demers?= Date: Thu, 6 Jan 2022 17:54:59 -0500 Subject: [PATCH 2/4] Add resize observer to droppable elements while dragging While sorting, it's possible for the size of droppable elements to change. We add a resize observer to keep track of size changes and schedule recomputation of the bounding rectangles of the droppable elements when resize events occur. The `resizeObserverConfig` param can be used to disable or configure the resize observer. --- packages/core/src/hooks/useDraggable.ts | 4 +- packages/core/src/hooks/useDroppable.ts | 82 +++++++++++++++++-- packages/core/src/hooks/utilities/index.ts | 2 +- .../{useData.ts => useLatestValue.ts} | 6 +- packages/sortable/src/hooks/useSortable.ts | 20 ++++- packages/utilities/src/hooks/useNodeRef.ts | 9 +- 6 files changed, 105 insertions(+), 18 deletions(-) rename packages/core/src/hooks/utilities/{useData.ts => useLatestValue.ts} (66%) diff --git a/packages/core/src/hooks/useDraggable.ts b/packages/core/src/hooks/useDraggable.ts index 7efab94a..cf38d4db 100644 --- a/packages/core/src/hooks/useDraggable.ts +++ b/packages/core/src/hooks/useDraggable.ts @@ -9,7 +9,7 @@ import { import {Context, Data} from '../store'; import {ActiveDraggableContext} from '../components/DndContext'; import { - useData, + useLatestValue, useSyntheticListeners, SyntheticListenerMap, } from './utilities'; @@ -58,7 +58,7 @@ export function useDraggable({ ); const [node, setNodeRef] = useNodeRef(); const listeners = useSyntheticListeners(activators, id); - const dataRef = useData(data); + const dataRef = useLatestValue(data); useIsomorphicLayoutEffect( () => { diff --git a/packages/core/src/hooks/useDroppable.ts b/packages/core/src/hooks/useDroppable.ts index c4ea01f8..4fe64e82 100644 --- a/packages/core/src/hooks/useDroppable.ts +++ b/packages/core/src/hooks/useDroppable.ts @@ -1,4 +1,4 @@ -import {useContext, useEffect, useRef} from 'react'; +import {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import { useIsomorphicLayoutEffect, useNodeRef, @@ -6,27 +6,95 @@ import { } from '@dnd-kit/utilities'; import {Context, Action, Data} from '../store'; -import type {ClientRect} from '../types'; -import {useData} from './utilities'; +import type {ClientRect, UniqueIdentifier} from '../types'; +import {useLatestValue} from './utilities'; + +interface ResizeObserverConfig { + disabled?: boolean; + timeout?: number; + recomputeIds?: UniqueIdentifier[]; +} export interface UseDroppableArguments { - id: string; + id: UniqueIdentifier; disabled?: boolean; data?: Data; + resizeObserverConfig?: ResizeObserverConfig; } const ID_PREFIX = 'Droppable'; +const defaultResizeObserverConfig = { + timeout: 50, +}; + export function useDroppable({ data, disabled = false, id, + resizeObserverConfig, }: UseDroppableArguments) { const key = useUniqueId(ID_PREFIX); - const {active, collisions, dispatch, over} = useContext(Context); + const {active, collisions, dispatch, over, recomputeRects} = useContext( + Context + ); const rect = useRef(null); - const [nodeRef, setNodeRef] = useNodeRef(); - const dataRef = useData(data); + const resizeEventCount = useRef(0); + const callbackId = useRef(null); + const { + disabled: resizeObserverDisabled, + recomputeIds, + timeout: resizeObserverTimeout, + } = { + ...defaultResizeObserverConfig, + ...resizeObserverConfig, + }; + const recomputeIdsRef = useLatestValue(recomputeIds); + const handleResize = useCallback( + () => { + const isFirstResizeEvent = resizeEventCount.current === 0; + + resizeEventCount.current++; + + if (isFirstResizeEvent) { + return; + } + + if (callbackId.current != null) { + clearTimeout(callbackId.current); + } + + callbackId.current = setTimeout(() => { + callbackId.current = null; + + recomputeRects(recomputeIdsRef.current ?? []); + }, resizeObserverTimeout); + }, + //eslint-disable-next-line react-hooks/exhaustive-deps + [recomputeRects, resizeObserverTimeout] + ); + const resizeObserver = useMemo( + () => (resizeObserverDisabled ? null : new ResizeObserver(handleResize)), + [handleResize, resizeObserverDisabled] + ); + const handleNodeChange = useCallback( + (newElement: HTMLElement | null, previousElement: HTMLElement | null) => { + if (!resizeObserver) { + return; + } + + if (previousElement) { + resizeObserver.unobserve(previousElement); + } + + if (newElement) { + resizeObserver.observe(newElement); + } + }, + [resizeObserver] + ); + const [nodeRef, setNodeRef] = useNodeRef(handleNodeChange); + const dataRef = useLatestValue(data); useIsomorphicLayoutEffect( () => { diff --git a/packages/core/src/hooks/utilities/index.ts b/packages/core/src/hooks/utilities/index.ts index 64b88f35..a187ed21 100644 --- a/packages/core/src/hooks/utilities/index.ts +++ b/packages/core/src/hooks/utilities/index.ts @@ -6,7 +6,7 @@ export { export type {Options as AutoScrollOptions} from './useAutoScroller'; export {useCachedNode} from './useCachedNode'; export {useCombineActivators} from './useCombineActivators'; -export {useData} from './useData'; +export {useLatestValue} from './useLatestValue'; export { useDroppableMeasuring, MeasuringFrequency, diff --git a/packages/core/src/hooks/utilities/useData.ts b/packages/core/src/hooks/utilities/useLatestValue.ts similarity index 66% rename from packages/core/src/hooks/utilities/useData.ts rename to packages/core/src/hooks/utilities/useLatestValue.ts index 0aaba4f3..9e32185b 100644 --- a/packages/core/src/hooks/utilities/useData.ts +++ b/packages/core/src/hooks/utilities/useLatestValue.ts @@ -1,10 +1,8 @@ import {useRef} from 'react'; import {useIsomorphicLayoutEffect} from '@dnd-kit/utilities'; -import type {Data} from '../../store'; - -export function useData(data: Data | undefined) { - const dataRef = useRef(data); +export function useLatestValue(data: T) { + const dataRef = useRef(data); useIsomorphicLayoutEffect(() => { if (dataRef.current !== data) { diff --git a/packages/sortable/src/hooks/useSortable.ts b/packages/sortable/src/hooks/useSortable.ts index 0f9b5cb7..bf7c2bfe 100644 --- a/packages/sortable/src/hooks/useSortable.ts +++ b/packages/sortable/src/hooks/useSortable.ts @@ -1,5 +1,10 @@ import {useContext, useEffect, useMemo, useRef} from 'react'; -import {useDraggable, useDroppable, UseDraggableArguments} from '@dnd-kit/core'; +import { + useDraggable, + useDroppable, + UseDraggableArguments, + UseDroppableArguments, +} from '@dnd-kit/core'; import {CSS, useCombinedRefs} from '@dnd-kit/utilities'; import {Context} from '../components'; @@ -20,7 +25,9 @@ import type { } from './types'; import {useDerivedTransform} from './utilities'; -export interface Arguments extends UseDraggableArguments { +export interface Arguments + extends UseDraggableArguments, + Pick { animateLayoutChanges?: AnimateLayoutChanges; getNewIndex?: NewIndexGetter; strategy?: SortingStrategy; @@ -35,6 +42,7 @@ export function useSortable({ getNewIndex = defaultNewIndexGetter, id, strategy: localStrategy, + resizeObserverConfig, transition = defaultTransition, }: Arguments) { const { @@ -53,6 +61,10 @@ export function useSortable({ () => ({sortable: {containerId, index, items}, ...customData}), [containerId, customData, index, items] ); + const itemsAfterCurrentSortable = useMemo( + () => items.slice(items.indexOf(id)), + [items, id] + ); const { collisions, rect, @@ -62,6 +74,10 @@ export function useSortable({ } = useDroppable({ id, data, + resizeObserverConfig: { + recomputeIds: itemsAfterCurrentSortable, + ...resizeObserverConfig, + }, }); const { active, diff --git a/packages/utilities/src/hooks/useNodeRef.ts b/packages/utilities/src/hooks/useNodeRef.ts index 2f6f0461..060c7058 100644 --- a/packages/utilities/src/hooks/useNodeRef.ts +++ b/packages/utilities/src/hooks/useNodeRef.ts @@ -1,11 +1,16 @@ import {useRef, useCallback} from 'react'; -export function useNodeRef(onChange?: (element: HTMLElement | null) => void) { +export function useNodeRef( + onChange?: ( + newElement: HTMLElement | null, + previousElement: HTMLElement | null + ) => void +) { const node = useRef(null); const setNodeRef = useCallback( (element: HTMLElement | null) => { + onChange?.(element, node.current); node.current = element; - onChange?.(element); }, [onChange] ); From 823d4d25eb99018c52431ea25fcdbd8a6ae897de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claude=CC=81ric=20Demers?= Date: Thu, 6 Jan 2022 17:55:07 -0500 Subject: [PATCH 3/4] Update stories --- stories/2 - Presets/Sortable/1-Vertical.story.tsx | 3 ++- stories/components/Item/Item.tsx | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/stories/2 - Presets/Sortable/1-Vertical.story.tsx b/stories/2 - Presets/Sortable/1-Vertical.story.tsx index 954786ab..dc40f8ac 100644 --- a/stories/2 - Presets/Sortable/1-Vertical.story.tsx +++ b/stories/2 - Presets/Sortable/1-Vertical.story.tsx @@ -140,7 +140,8 @@ export const RerenderBeforeSorting = () => { {...props} wrapperStyle={({isDragging}) => { return { - height: isDragging ? undefined : 200, + transition: 'height 250ms ease', + height: isDragging ? 100 : 200, }; }} /> diff --git a/stories/components/Item/Item.tsx b/stories/components/Item/Item.tsx index 9bc781ab..ec520225 100644 --- a/stories/components/Item/Item.tsx +++ b/stories/components/Item/Item.tsx @@ -101,7 +101,9 @@ export const Item = React.memo( style={ { ...wrapperStyle, - transition, + transition: [transition, wrapperStyle?.transition] + .filter(Boolean) + .join(', '), '--translate-x': transform ? `${Math.round(transform.x)}px` : undefined, From b6a9cb800dc930254b165a2ae9d1aa9883bbb34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Claude=CC=81ric=20Demers?= Date: Thu, 6 Jan 2022 22:24:46 -0500 Subject: [PATCH 4/4] Minor refactor and renaming --- .changeset/droppable-resize-observer.md | 30 ++++++++ .changeset/use-node-ref.md | 5 ++ .../src/components/DndContext/DndContext.tsx | 14 ++-- packages/core/src/hooks/useDroppable.ts | 48 +++++++----- .../hooks/utilities/useDroppableMeasuring.ts | 75 +++++++++++-------- packages/core/src/store/context.ts | 4 +- packages/core/src/store/types.ts | 4 +- .../src/components/SortableContext.tsx | 16 ++-- packages/sortable/src/hooks/useSortable.ts | 2 +- 9 files changed, 132 insertions(+), 66 deletions(-) create mode 100644 .changeset/droppable-resize-observer.md create mode 100644 .changeset/use-node-ref.md diff --git a/.changeset/droppable-resize-observer.md b/.changeset/droppable-resize-observer.md new file mode 100644 index 00000000..97169363 --- /dev/null +++ b/.changeset/droppable-resize-observer.md @@ -0,0 +1,30 @@ +--- +'@dnd-kit/core': major +'@dnd-kit/sortable': minor +--- + +Droppable containers now observe the node they are attached to via `setNodeRef` using `ResizeObserver` while dragging. + +This behaviour can be configured using the newly introduced `resizeObserverConfig` property. + +```ts +interface ResizeObserverConfig { + /** Whether the ResizeObserver should be disabled entirely */ + disabled?: boolean; + /** Resize events may affect the layout and position of other droppable containers. + * Specify an array of `UniqueIdentifier` of droppable containers that should also be re-measured + * when this droppable container resizes. Specifying an empty array re-measures all droppable containers. + */ + updateMeasurementsFor?: UniqueIdentifier[]; + /** Represents the debounce timeout between when resize events are observed and when elements are re-measured */ + timeout?: number; +} +``` + +By default, only the current droppable is re-measured when a resize event is observed. However, this may not be suitable for all use-cases. When an element resizes, it can affect the layout and position of other elements, such that it may be necessary to re-measure other droppable nodes in response to that single resize event. The `recomputeIds` property can be used to specify which droppable `id`s should be re-measured in response to resize events being observed. + +For example, the `useSortable` preset re-computes the measurements of all sortable elements after the element that resizes, so long as they are within the same `SortableContext` as the element that resizes, since it's highly likely that their layout will also shift. + +Specifying an empty array for `recomputeIds` forces all droppable containers to be re-measured. + +For consumers that were relyings on the internals of `DndContext` using `useDndContext()`, the `willRecomputeLayouts` property has been renamed to `measuringScheduled`, and the `recomputeLayouts` method has been renamed to `measureDroppableContainers`, and now optionally accepts an array of droppable `UniqueIdentifier` that should be scheduled to be re-measured. diff --git a/.changeset/use-node-ref.md b/.changeset/use-node-ref.md new file mode 100644 index 00000000..c21d2bba --- /dev/null +++ b/.changeset/use-node-ref.md @@ -0,0 +1,5 @@ +--- +'@dnd-kit/utilities': patch +--- + +The `useNodeRef` hook's `onChange` argument now receives both the current node and the previous node that were attached to the ref. diff --git a/packages/core/src/components/DndContext/DndContext.tsx b/packages/core/src/components/DndContext/DndContext.tsx index 744ed1f7..0dacd0f3 100644 --- a/packages/core/src/components/DndContext/DndContext.tsx +++ b/packages/core/src/components/DndContext/DndContext.tsx @@ -187,9 +187,9 @@ export const DndContext = memo(function DndContext({ return droppableContainers.getEnabled(); }, [droppableContainers]); const { - rectMap: droppableRects, - recomputeRects, - willRecomputeRects, + droppableRects, + measureDroppableContainers, + measuringScheduled, } = useDroppableMeasuring(enabledDroppableContainers, { dragging: isDragging, dependencies: [translate.x, translate.y], @@ -614,10 +614,10 @@ export const DndContext = memo(function DndContext({ droppableContainers, droppableRects, over, - recomputeRects, + measureDroppableContainers, scrollableAncestors, scrollableAncestorRects, - willRecomputeRects, + measuringScheduled, windowRect, }; @@ -637,10 +637,10 @@ export const DndContext = memo(function DndContext({ droppableContainers, droppableRects, over, - recomputeRects, + measureDroppableContainers, scrollableAncestors, scrollableAncestorRects, - willRecomputeRects, + measuringScheduled, windowRect, ]); diff --git a/packages/core/src/hooks/useDroppable.ts b/packages/core/src/hooks/useDroppable.ts index 4fe64e82..9441f013 100644 --- a/packages/core/src/hooks/useDroppable.ts +++ b/packages/core/src/hooks/useDroppable.ts @@ -10,9 +10,15 @@ import type {ClientRect, UniqueIdentifier} from '../types'; import {useLatestValue} from './utilities'; interface ResizeObserverConfig { + /** Whether the ResizeObserver should be disabled entirely */ disabled?: boolean; + /** Resize events may affect the layout and position of other droppable containers. + * Specify an array of `UniqueIdentifier` of droppable containers that should also be re-measured + * when this droppable container resizes. Specifying an empty array re-measures all droppable containers. + */ + updateMeasurementsFor?: UniqueIdentifier[]; + /** Represents the debounce timeout between when resize events are observed and when elements are re-measured */ timeout?: number; - recomputeIds?: UniqueIdentifier[]; } export interface UseDroppableArguments { @@ -35,28 +41,31 @@ export function useDroppable({ resizeObserverConfig, }: UseDroppableArguments) { const key = useUniqueId(ID_PREFIX); - const {active, collisions, dispatch, over, recomputeRects} = useContext( - Context - ); + const { + active, + collisions, + dispatch, + over, + measureDroppableContainers, + } = useContext(Context); + const resizeObserverConnected = useRef(false); const rect = useRef(null); - const resizeEventCount = useRef(0); const callbackId = useRef(null); const { disabled: resizeObserverDisabled, - recomputeIds, + updateMeasurementsFor, timeout: resizeObserverTimeout, } = { ...defaultResizeObserverConfig, ...resizeObserverConfig, }; - const recomputeIdsRef = useLatestValue(recomputeIds); + const ids = useLatestValue(updateMeasurementsFor ?? id); const handleResize = useCallback( () => { - const isFirstResizeEvent = resizeEventCount.current === 0; - - resizeEventCount.current++; - - if (isFirstResizeEvent) { + if (!resizeObserverConnected.current) { + // ResizeObserver invokes the `handleResize` callback as soon as `observe` is called, + // assuming the element is rendered and displayed. + resizeObserverConnected.current = true; return; } @@ -65,17 +74,21 @@ export function useDroppable({ } callbackId.current = setTimeout(() => { + measureDroppableContainers( + typeof ids.current === 'string' ? [ids.current] : ids.current + ); callbackId.current = null; - - recomputeRects(recomputeIdsRef.current ?? []); }, resizeObserverTimeout); }, //eslint-disable-next-line react-hooks/exhaustive-deps - [recomputeRects, resizeObserverTimeout] + [resizeObserverTimeout] ); const resizeObserver = useMemo( - () => (resizeObserverDisabled ? null : new ResizeObserver(handleResize)), - [handleResize, resizeObserverDisabled] + () => + !active || resizeObserverDisabled + ? null + : new ResizeObserver(handleResize), + [active, handleResize, resizeObserverDisabled] ); const handleNodeChange = useCallback( (newElement: HTMLElement | null, previousElement: HTMLElement | null) => { @@ -85,6 +98,7 @@ export function useDroppable({ if (previousElement) { resizeObserver.unobserve(previousElement); + resizeObserverConnected.current = false; } if (newElement) { diff --git a/packages/core/src/hooks/utilities/useDroppableMeasuring.ts b/packages/core/src/hooks/utilities/useDroppableMeasuring.ts index 514a1068..03b4082a 100644 --- a/packages/core/src/hooks/utilities/useDroppableMeasuring.ts +++ b/packages/core/src/hooks/utilities/useDroppableMeasuring.ts @@ -41,35 +41,40 @@ export function useDroppableMeasuring( containers: DroppableContainer[], {dragging, dependencies, config}: Arguments ) { - const [recomputeIds, setRecomputeIds] = useState( - null - ); - const willRecomputeRects = recomputeIds != null; + const [ + containerIdsScheduledForMeasurement, + setContainerIdsScheduledForMeasurement, + ] = useState(null); + const measuringScheduled = containerIdsScheduledForMeasurement != null; const {frequency, measure, strategy} = { ...defaultConfig, ...config, }; const containersRef = useRef(containers); - const recomputeRects = useCallback( + const measureDroppableContainers = useCallback( (ids: UniqueIdentifier[] = []) => - setRecomputeIds((value) => (value ? value.concat(ids) : ids)), + setContainerIdsScheduledForMeasurement((value) => + value ? value.concat(ids) : ids + ), [] ); - const recomputeRectsTimeoutId = useRef(null); + const timeoutId = useRef(null); const disabled = isDisabled(); - const rectMap = useLazyMemo( + const droppableRects = useLazyMemo( (previousValue) => { if (disabled && !dragging) { return defaultValue; } + const ids = containerIdsScheduledForMeasurement; + if ( !previousValue || previousValue === defaultValue || containersRef.current !== containers || - recomputeIds != null + ids != null ) { - const rectMap: RectMap = new Map(); + const map: RectMap = new Map(); for (let container of containers) { if (!container) { @@ -77,13 +82,13 @@ export function useDroppableMeasuring( } if ( - recomputeIds && - recomputeIds.length > 0 && - !recomputeIds.includes(container.id) && + ids && + ids.length > 0 && + !ids.includes(container.id) && container.rect.current ) { - // This container does not need to be recomputed - rectMap.set(container.id, container.rect.current); + // This container does not need to be re-measured + map.set(container.id, container.rect.current); continue; } @@ -93,16 +98,22 @@ export function useDroppableMeasuring( container.rect.current = rect; if (rect) { - rectMap.set(container.id, rect); + map.set(container.id, rect); } } - return rectMap; + return map; } return previousValue; }, - [containers, dragging, disabled, measure, recomputeIds] + [ + containers, + containerIdsScheduledForMeasurement, + dragging, + disabled, + measure, + ] ); useEffect(() => { @@ -110,46 +121,46 @@ export function useDroppableMeasuring( }, [containers]); useEffect( - function recompute() { + () => { if (disabled) { return; } - requestAnimationFrame(() => recomputeRects()); + requestAnimationFrame(() => measureDroppableContainers()); }, // eslint-disable-next-line react-hooks/exhaustive-deps [dragging, disabled] ); useEffect(() => { - if (willRecomputeRects) { - setRecomputeIds(null); + if (measuringScheduled) { + setContainerIdsScheduledForMeasurement(null); } - }, [willRecomputeRects]); + }, [measuringScheduled]); useEffect( - function forceRecomputeLayouts() { + () => { if ( disabled || typeof frequency !== 'number' || - recomputeRectsTimeoutId.current !== null + timeoutId.current !== null ) { return; } - recomputeRectsTimeoutId.current = setTimeout(() => { - recomputeRects(); - recomputeRectsTimeoutId.current = null; + timeoutId.current = setTimeout(() => { + measureDroppableContainers(); + timeoutId.current = null; }, frequency); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [frequency, disabled, recomputeRects, ...dependencies] + [frequency, disabled, measureDroppableContainers, ...dependencies] ); return { - rectMap, - recomputeRects, - willRecomputeRects, + droppableRects, + measureDroppableContainers, + measuringScheduled, }; function isDisabled() { diff --git a/packages/core/src/store/context.ts b/packages/core/src/store/context.ts index ed57013d..6199a7df 100644 --- a/packages/core/src/store/context.ts +++ b/packages/core/src/store/context.ts @@ -29,7 +29,7 @@ export const Context = createContext({ }, scrollableAncestors: [], scrollableAncestorRects: [], - recomputeRects: noop, + measureDroppableContainers: noop, windowRect: null, - willRecomputeRects: false, + measuringScheduled: false, }); diff --git a/packages/core/src/store/types.ts b/packages/core/src/store/types.ts index 0ffc9a5d..6912f600 100644 --- a/packages/core/src/store/types.ts +++ b/packages/core/src/store/types.ts @@ -94,7 +94,7 @@ export interface DndContextDescriptor { }; scrollableAncestors: Element[]; scrollableAncestorRects: ClientRect[]; - recomputeRects(ids: UniqueIdentifier[]): void; - willRecomputeRects: boolean; + measureDroppableContainers(ids: UniqueIdentifier[]): void; + measuringScheduled: boolean; windowRect: ClientRect | null; } diff --git a/packages/sortable/src/components/SortableContext.tsx b/packages/sortable/src/components/SortableContext.tsx index 839b78d9..3a428462 100644 --- a/packages/sortable/src/components/SortableContext.tsx +++ b/packages/sortable/src/components/SortableContext.tsx @@ -50,8 +50,8 @@ export function SortableContext({ dragOverlay, droppableRects, over, - recomputeRects, - willRecomputeRects, + measureDroppableContainers, + measuringScheduled, } = useDndContext(); const containerId = useUniqueId(ID_PREFIX, id); const useDragOverlay = Boolean(dragOverlay.rect !== null); @@ -73,10 +73,16 @@ export function SortableContext({ (overIndex !== -1 && activeIndex === -1) || itemsHaveChanged; useIsomorphicLayoutEffect(() => { - if (itemsHaveChanged && isDragging && !willRecomputeRects) { - recomputeRects(items); + if (itemsHaveChanged && isDragging && !measuringScheduled) { + measureDroppableContainers(items); } - }, [itemsHaveChanged, items, isDragging, recomputeRects, willRecomputeRects]); + }, [ + itemsHaveChanged, + items, + isDragging, + measureDroppableContainers, + measuringScheduled, + ]); useEffect(() => { previousItemsRef.current = items; diff --git a/packages/sortable/src/hooks/useSortable.ts b/packages/sortable/src/hooks/useSortable.ts index bf7c2bfe..9fb278ed 100644 --- a/packages/sortable/src/hooks/useSortable.ts +++ b/packages/sortable/src/hooks/useSortable.ts @@ -75,7 +75,7 @@ export function useSortable({ id, data, resizeObserverConfig: { - recomputeIds: itemsAfterCurrentSortable, + updateMeasurementsFor: itemsAfterCurrentSortable, ...resizeObserverConfig, }, });