From 6c7dad0771e16f314e850f09d01b47e474d23a74 Mon Sep 17 00:00:00 2001 From: Alex Reardon Date: Thu, 3 May 2018 15:25:13 +1000 Subject: [PATCH] collector --- src/state/dimension-marshal/buffer-marshal.js | 62 +++ src/state/dimension-marshal/collector.js | 118 +++++ .../dimension-marshal-types.js | 6 +- .../dimension-marshal/dimension-marshal.js | 464 ++++++------------ .../drag-drop-context/drag-drop-context.jsx | 8 + 5 files changed, 340 insertions(+), 318 deletions(-) create mode 100644 src/state/dimension-marshal/buffer-marshal.js create mode 100644 src/state/dimension-marshal/collector.js diff --git a/src/state/dimension-marshal/buffer-marshal.js b/src/state/dimension-marshal/buffer-marshal.js new file mode 100644 index 0000000000..1211635430 --- /dev/null +++ b/src/state/dimension-marshal/buffer-marshal.js @@ -0,0 +1,62 @@ +// @flow +import invariant from 'tiny-invariant'; + +type Args = {| + collector: Collector, + publisher: Publisher, +|} + +type Phase = 'IDLE' | 'RUNNING'; + +const rafWait = () => new Promise(resolve => requestAnimationFrame(resolve)); + +export default ({ collector, publisher }: Args) => { + let phase: Phase = 'IDLE'; + let isRunQueued: boolean = false; + + const reset = () => { + // forcing phase to IDLE + phase = 'IDLE'; + isRunQueued = false; + }; + + const stopIfIdle = () => (phase === 'IDLE' ? Promise.reject() : Promise.resolve()); + + const run = () => { + phase = 'RUNNING'; + + // This would be easier to read with async/await but the runtime is 10kb + + rafWait() + .then(stopIfIdle) + .then(collector.perform) + .then(rafWait) + .then(stopIfIdle) + .then(publisher.perform) + // collection was stopped - we can just exit + .catch() + .then(() => { + if (isRunQueued) { + run(); + return; + } + reset(); + }); + }; + + const execute = () => { + // A run is already queued + if (isRunQueued) { + return; + } + + // We are already performing a run + if (phase === 'RUNNING') { + return; + } + + run(); + }; + + return { execute, reset }; +}; diff --git a/src/state/dimension-marshal/collector.js b/src/state/dimension-marshal/collector.js new file mode 100644 index 0000000000..d1b3b86915 --- /dev/null +++ b/src/state/dimension-marshal/collector.js @@ -0,0 +1,118 @@ +// @flow +import invariant from 'tiny-invariant'; +import * as timings from '../../debug/timings'; +import type { + DraggableId, + DroppableId, + DraggableDimension, + DroppableDimension, + ScrollOptions, +} from '../../types'; +import type { ToBeCollected } from './dimension-marshal-types'; + +type Collected = {| + draggables: DraggableDimension[], + droppables: DroppableDimension[], +|} + +type Args = {| + getToBeCollected: () => ToBeCollected, + getDraggable: (id: DraggableId) => DraggableDimension, + getDroppable: (id: DroppableId) => DroppableDimension, + publish: (droppables: DroppableDimension[], draggables: DraggableDimension[]) => void, +|} + +export type Collector = {| + start: (options: ScrollOptions) => void, + stop: () => void, + collect: () => void, +|} + +export default ({ + publish, + getDraggable, + getDroppable, + getToBeCollected, +}: Args): Collector => { + let isActive: boolean = false; + let frameId: ?AnimationFrameID = null; + let isQueued: boolean = false; + let isRunning: boolean = false; + + const collectFromDOM = (toBeCollected: ToBeCollected): Collected => { + const droppables: DroppableDimension[] = toBeCollected.droppables + .map((id: DroppableId): DroppableDimension => getDroppable(id)); + + const draggables: DraggableDimension[] = toBeCollected.draggables + .map((id: DraggableId): DraggableDimension => getDraggable(id)); + + return { draggables, droppables }; + }; + + const run = () => { + invariant(isRunning, 'Cannot start a new run when a run is already occurring'); + + isRunning = true; + + // Perform DOM collection in next frame + frameId = requestAnimationFrame(() => { + timings.start('DOM collection'); + const toBeCollected: ToBeCollected = getToBeCollected(); + const collected: Collected = collectFromDOM(toBeCollected); + timings.finish('DOM collection'); + + // Perform publish in next frame + frameId = requestAnimationFrame(() => { + timings.start('Bulk dimension publish'); + publish(collected.droppables, collected.draggables); + timings.finish('Bulk dimension publish'); + + // TODO: what if publish caused collection? + + frameId = null; + isRunning = false; + + if (isQueued) { + isQueued = false; + run(); + } + }); + }); + }; + + const start = () => { + invariant(!isActive, 'Collector has already been started'); + isActive = true; + }; + + const collect = () => { + invariant(isActive, 'Can only collect when active'); + // A run is already queued + if (isQueued) { + return; + } + + // We are running and a collection is not queued + // Queue another run + if (isRunning) { + isQueued = true; + } + + run(); + }; + + const stop = () => { + if (frameId) { + cancelAnimationFrame(frameId); + } + isRunning = false; + isQueued = false; + isActive = false; + }; + + return { + start, + stop, + collect, + }; +}; diff --git a/src/state/dimension-marshal/dimension-marshal-types.js b/src/state/dimension-marshal/dimension-marshal-types.js index bec71331cb..9a73a823ba 100644 --- a/src/state/dimension-marshal/dimension-marshal-types.js +++ b/src/state/dimension-marshal/dimension-marshal-types.js @@ -45,8 +45,10 @@ export type DroppableEntryMap = { [key: DroppableId]: DroppableEntry, } -export type UnknownDescriptorType = DraggableDescriptor | DroppableDescriptor; -export type UnknownDimensionType = DraggableDimension | DroppableDimension; +export type ToBeCollected = {| + draggables: DraggableId[], + droppables: DroppableId[], +|} export type DimensionMarshal = {| // Draggable diff --git a/src/state/dimension-marshal/dimension-marshal.js b/src/state/dimension-marshal/dimension-marshal.js index f914b01764..a052801792 100644 --- a/src/state/dimension-marshal/dimension-marshal.js +++ b/src/state/dimension-marshal/dimension-marshal.js @@ -1,9 +1,10 @@ // @flow import { type Position } from 'css-box-model'; +import invariant from 'tiny-invariant'; +import createCollector, { type Collector } from './collector'; import type { DraggableId, DroppableId, - TypeId, DroppableDescriptor, DraggableDescriptor, DraggableDimension, @@ -18,103 +19,85 @@ import type { Callbacks, GetDraggableDimensionFn, DroppableCallbacks, - UnknownDimensionType, - UnknownDescriptorType, + ToBeCollected, DroppableEntry, DraggableEntry, DroppableEntryMap, DraggableEntryMap, } from './dimension-marshal-types'; -type State = {| - // long lived - droppables: DroppableEntryMap, - draggables: DraggableEntryMap, - // short lived - isCollecting: boolean, - scrollOptions: ?ScrollOptions, - request: ?LiftRequest, - requestType: ?TypeId, - frameId: ?AnimationFrameID, +type Collection = {| + scrollOptions: ScrollOptions, + critical: {| + draggable: DraggableDescriptor, + droppable: DroppableDescriptor, + |} |} -type ToBePublished = {| - droppables: DroppableDimension[], - draggables: DraggableDimension[], +type Entries = {| + droppables: DroppableEntryMap, + draggables: DraggableEntryMap, |} export default (callbacks: Callbacks) => { - let state: State = { + const entries: Entries = { droppables: {}, draggables: {}, - isCollecting: false, - scrollOptions: null, - request: null, - requestType: null, - frameId: null, }; + let collection: ?Collection = null; - const setState = (partial: Object) => { - const newState: State = { - ...state, - ...partial, - }; - state = newState; - }; - - const cancel = (...args: mixed[]) => { - console.error(...args); - - // We want to cancel the drag even if we are not collecting yet - // This is true when trying to lift something that has not been published - callbacks.cancel(); - - if (!state.isCollecting) { - return; - } + const getToBeCollected = (): ToBeCollected => { + invariant(collection, 'Cannot collect dimensions when no collection is occurring'); - // eslint-disable-next-line no-use-before-define - stopCollecting(); - }; + const home: DroppableDescriptor = collection.critical.droppable; - const cancelIfModifyingActiveDraggable = (descriptor: DraggableDescriptor) => { - if (!state.isCollecting) { - return; - } + const draggables: DraggableId[] = + Object.keys(entries.draggables) + // remove draggables that do not have the same droppable type + .filter((id: DraggableId): boolean => { + const entry: DraggableEntry = entries.draggables[id]; + const parent: ?DroppableEntry = entries.droppables[entry.descriptor.droppableId]; - const home: ?DroppableEntry = state.droppables[descriptor.droppableId]; + // This should never happen + // but it is better to print this information and continue on + if (!parent) { + console.warn(` + Orphan Draggable found [id: ${entry.descriptor.id}] which says + it belongs to unknown Droppable ${entry.descriptor.droppableId} + `); + return false; + } - // In React 16 children are mounted before parents are. - // This case can happen when a list of Draggables are being - // moved using a React.Portal. - if (!home) { - return; - } + return parent.descriptor.type === home.type; + }); - // Adding something of a different type - not relevant to the drag - if (home.descriptor.type !== state.requestType) { - return; - } + const droppables: DroppableId[] = + Object.keys(entries.droppables) + // remove droppables with a different type + .filter((id: DroppableId): boolean => entries.droppables[id].descriptor.type === home.type); - // Technically we could let the drag go on - but this is being more explicit - // with consumers to prevent undesirable states - cancel('Adding or removing a Draggable during a drag is currently not supported'); + return { + draggables, + droppables, + }; }; - const cancelIfModifyingActiveDroppable = (descriptor: DroppableDescriptor) => { - if (!state.isCollecting) { - return; - } + const collector: Collector = createCollector({ + publish: callbacks.bulkPublish, + getDraggable: (id: DraggableId): DraggableDimension => { + const entry: ?DraggableEntry = entries.draggables[id]; + invariant(entry); - // Adding something of a different type - not relevant to the drag - // This can happen when dragging a Draggable that has a child Droppable - // when using a React.Portal as the child Droppable component will be remounted - if (descriptor.type !== state.requestType) { - return; - } + return entry.getDimension(); + }, + getDroppable: (id: DroppableId): DroppableDimension => { + const entry: ?DroppableEntry = entries.droppables[id]; + invariant(entry); - cancel('Adding or removing a Droppable during a drag is currently not supported'); - }; + return entry.callbacks.getDimension(); + }, + getToBeCollected, + }); const registerDraggable = ( descriptor: DraggableDescriptor, @@ -131,16 +114,27 @@ export default (callbacks: Callbacks) => { descriptor, getDimension, }; - const draggables: DraggableEntryMap = { - ...state.draggables, - [id]: entry, - }; + entries.draggables[id] = entry; - setState({ - draggables, - }); + if (!collection) { + return; + } + + const home: ?DroppableEntry = entries.droppables[descriptor.droppableId]; + + // In React 16 children are mounted before parents are. + // This case can happen when a list of Draggables are being + // moved using a React.Portal. + if (!home) { + return; + } - cancelIfModifyingActiveDraggable(descriptor); + // Adding something of a different type - not relevant to the drag + if (home.descriptor.type !== collection.critical.droppable.type) { + return; + } + + collector.collect(); }; const registerDroppable = ( @@ -158,27 +152,28 @@ export default (callbacks: Callbacks) => { callbacks: droppableCallbacks, }; - const droppables: DroppableEntryMap = { - ...state.droppables, - [id]: entry, - }; + entries.droppables[id] = entry; - setState({ - droppables, - }); + if (!collection) { + return; + } - cancelIfModifyingActiveDroppable(descriptor); + // Not of the same type - we do not need to publish + if (descriptor.type !== collection.critical.droppable.type) { + return; + } + + collector.collect(); }; const updateDroppableIsEnabled = (id: DroppableId, isEnabled: boolean) => { - if (!state.droppables[id]) { - cancel(`Cannot update the scroll on Droppable ${id} as it is not registered`); - return; - } // no need to update the application state if a collection is not occurring - if (!state.isCollecting) { + if (!collection) { return; } + + invariant(entries.droppables[id], `Cannot update the scroll on Droppable ${id} as it is not registered`); + // At this point a non primary droppable dimension might not yet be published // but may have its enabled state changed. For now we still publish this change // and let the reducer exit early if it cannot find the dimension in the state. @@ -186,24 +181,21 @@ export default (callbacks: Callbacks) => { }; const updateDroppableScroll = (id: DroppableId, newScroll: Position) => { - if (!state.droppables[id]) { - cancel(`Cannot update the scroll on Droppable ${id} as it is not registered`); - return; - } + invariant(entries.droppables[id], `Cannot update the scroll on Droppable ${id} as it is not registered`); + // no need to update the application state if a collection is not occurring - if (!state.isCollecting) { + if (!collecting) { return; } callbacks.updateDroppableScroll(id, newScroll); }; const scrollDroppable = (id: DroppableId, change: Position) => { - const entry: ?DroppableEntry = state.droppables[id]; - if (!entry) { - return; - } + const entry: ?DroppableEntry = entries.droppables[id]; - if (!state.isCollecting) { + invariant(entry, 'Cannot scroll Droppable if not in entries'); + + if (!collection) { return; } @@ -211,12 +203,9 @@ export default (callbacks: Callbacks) => { }; const unregisterDraggable = (descriptor: DraggableDescriptor) => { - const entry: ?DraggableEntry = state.draggables[descriptor.id]; + const entry: ?DraggableEntry = entries.draggables[descriptor.id]; - if (!entry) { - cancel(`Cannot unregister Draggable with id ${descriptor.id} as it is not registered`); - return; - } + invariant(entry, `Cannot unregister Draggable with id ${descriptor.id} as it is not registered`); // Entry has already been overwritten. // This can happen when a new Draggable with the same draggableId @@ -225,25 +214,19 @@ export default (callbacks: Callbacks) => { return; } - const newMap: DraggableEntryMap = { - ...state.draggables, - }; - delete newMap[descriptor.id]; + delete entries.draggables[descriptor.id]; - setState({ - draggables: newMap, - }); + if (!collection) { + return; + } - cancelIfModifyingActiveDraggable(descriptor); + console.warn('TODO: batch unpublish draggable'); }; const unregisterDroppable = (descriptor: DroppableDescriptor) => { - const entry: ?DroppableEntry = state.droppables[descriptor.id]; + const entry: ?DroppableEntry = entries.droppables[descriptor.id]; - if (!entry) { - cancel(`Cannot unregister Droppable with id ${descriptor.id} as as it is not registered`); - return; - } + invariant(entry, `Cannot unregister Droppable with id ${descriptor.id} as as it is not registered`); // entry has already been overwritten // in which can we will not remove it @@ -255,238 +238,87 @@ export default (callbacks: Callbacks) => { // unmounts parents before it unmounts children: // https://twitter.com/alexandereardon/status/941514612624703488 - const newMap: DroppableEntryMap = { - ...state.droppables, - }; - delete newMap[descriptor.id]; - - setState({ - droppables: newMap, - }); - - cancelIfModifyingActiveDroppable(descriptor); - }; + delete entries.droppables[descriptor.id]; - const getToBeCollected = (): UnknownDescriptorType[] => { - const draggables: DraggableEntryMap = state.draggables; - const droppables: DroppableEntryMap = state.droppables; - const request: ?LiftRequest = state.request; - - if (!request) { - console.error('cannot find request in state'); - return []; + if (!collection) { + return; } - const draggableId: DraggableId = request.draggableId; - const descriptor: DraggableDescriptor = draggables[draggableId].descriptor; - const home: DroppableDescriptor = droppables[descriptor.droppableId].descriptor; - - const draggablesToBeCollected: DraggableDescriptor[] = - Object.keys(draggables) - .map((id: DraggableId): DraggableDescriptor => draggables[id].descriptor) - // remove the original draggable from the list - .filter((item: DraggableDescriptor): boolean => item.id !== descriptor.id) - // remove draggables that do not have the same droppable type - .filter((item: DraggableDescriptor): boolean => { - const entry: ?DroppableEntry = droppables[item.droppableId]; - - // This should never happen - // but it is better to print this information and continue on - if (!entry) { - console.warn(`Orphan Draggable found ${item.id} which says it belongs to unknown Droppable ${item.droppableId}`); - return false; - } - - return entry.descriptor.type === home.type; - }); - - const droppablesToBeCollected: DroppableDescriptor[] = - Object.keys(droppables) - .map((id: DroppableId): DroppableDescriptor => droppables[id].descriptor) - // remove the home droppable from the list - .filter((item: DroppableDescriptor): boolean => item.id !== home.id) - // remove droppables with a different type - .filter((item: DroppableDescriptor): boolean => { - const droppable: DroppableDescriptor = droppables[item.id].descriptor; - return droppable.type === home.type; - }); - - const toBeCollected: UnknownDescriptorType[] = [ - ...droppablesToBeCollected, - ...draggablesToBeCollected, - ]; - return toBeCollected; + console.warn('TODO: publish droppable unpublish'); }; - const processPrimaryDimensions = (request: ?LiftRequest) => { - if (state.isCollecting) { - cancel('Cannot start capturing dimensions for a drag it is already dragging'); - return; - } - - if (!request) { - cancel('Cannot start capturing dimensions with an invalid request', request); - return; - } + const collectCriticalDimensions = (request: ?LiftRequest) => { + invariant(collection, 'Cannot start capturing dimensions for a drag it is already dragging'); + invariant(request, 'Cannot start capturing dimensions with an invalid request', request); - const draggables: DraggableEntryMap = state.draggables; - const droppables: DroppableEntryMap = state.droppables; + const draggables: DraggableEntryMap = entries.draggables; + const droppables: DroppableEntryMap = entries.droppables; const draggableId: DraggableId = request.draggableId; const draggableEntry: ?DraggableEntry = draggables[draggableId]; - if (!draggableEntry) { - cancel(`Cannot find Draggable with id ${draggableId} to start collecting dimensions`); - return; - } + invariant(draggableEntry, `Cannot find Draggable with id ${draggableId} to start collecting dimensions`); const homeEntry: ?DroppableEntry = droppables[draggableEntry.descriptor.droppableId]; - if (!homeEntry) { - cancel(` - Cannot find home Droppable [id:${draggableEntry.descriptor.droppableId}] - for Draggable [id:${request.draggableId}] - `); - return; - } - - setState({ - isCollecting: true, - request, - requestType: homeEntry.descriptor.type, - }); + invariant(homeEntry, ` + Cannot find home Droppable [id:${draggableEntry.descriptor.droppableId}] + for Draggable [id:${request.draggableId}] + `); + + collection = { + scrollOptions: request.scrollOptions, + critical: { + draggable: draggableEntry.descriptor, + droppable: homeEntry.descriptor, + }, + }; // Get the minimum dimensions to start a drag const home: DroppableDimension = homeEntry.callbacks.getDimension(); const draggable: DraggableDimension = draggableEntry.getDimension(); - // Publishing dimensions callbacks.publishDroppable(home); callbacks.publishDraggable(draggable); - // Watching the scroll of the home droppable - homeEntry.callbacks.watchScroll(request.scrollOptions); - }; - - const setFrameId = (frameId: ?AnimationFrameID) => { - setState({ - frameId, - }); - }; - - const processSecondaryDimensions = (requestInAppState: ?LiftRequest): void => { - if (!state.isCollecting) { - cancel('Cannot collect secondary dimensions when collection is not occurring'); - return; - } - - const request: ?LiftRequest = state.request; - - if (!request) { - cancel('Cannot process secondary dimensions without a request'); - return; - } - - if (!requestInAppState) { - cancel('Cannot process secondary dimensions without a request on the state'); - return; - } - - if (requestInAppState.draggableId !== request.draggableId) { - cancel('Cannot process secondary dimensions as local request does not match app state'); - return; - } - - const toBeCollected: UnknownDescriptorType[] = getToBeCollected(); - - // Phase 1: collect dimensions in a single frame - const collectFrameId: AnimationFrameID = requestAnimationFrame(() => { - const toBePublishedBuffer: UnknownDimensionType[] = toBeCollected.map( - (descriptor: UnknownDescriptorType): UnknownDimensionType => { - // is a droppable - if (descriptor.type) { - return state.droppables[descriptor.id].callbacks.getDimension(); - } - // is a draggable - return state.draggables[descriptor.id].getDimension(); - } - ); - - // Phase 2: publish all dimensions to the store - const publishFrameId: AnimationFrameID = requestAnimationFrame(() => { - const toBePublished: ToBePublished = toBePublishedBuffer.reduce( - (previous: ToBePublished, dimension: UnknownDimensionType): ToBePublished => { - // is a draggable - if (dimension.placeholder) { - previous.draggables.push(dimension); - } else { - previous.droppables.push(dimension); - } - return previous; - }, { draggables: [], droppables: [] } - ); - - callbacks.bulkPublish( - toBePublished.droppables, - toBePublished.draggables, - ); - - // need to watch the scroll on each droppable - toBePublished.droppables.forEach((dimension: DroppableDimension) => { - const entry: DroppableEntry = state.droppables[dimension.descriptor.id]; - entry.callbacks.watchScroll(request.scrollOptions); - }); - - setFrameId(null); - }); - - setFrameId(publishFrameId); - }); - - setFrameId(collectFrameId); }; const stopCollecting = () => { + invariant(collection, 'Cannot stop collecting when there is no collection'); + // Tell all droppables to stop watching scroll // all good if they where not already listening - Object.keys(state.droppables) - .forEach((id: DroppableId) => state.droppables[id].callbacks.unwatchScroll()); - - if (state.frameId) { - cancelAnimationFrame(state.frameId); - } + Object.keys(entries.droppables) + .forEach((id: DroppableId) => entries.droppables[id].callbacks.unwatchScroll()); - // reset collections state - setState({ - isCollecting: false, - request: null, - frameId: null, - }); + collection = null; + collector.stop(); }; const onPhaseChange = (current: AppState) => { const phase: Phase = current.phase; if (phase === 'COLLECTING_INITIAL_DIMENSIONS') { - processPrimaryDimensions(current.dimension.request); + collectCriticalDimensions(current.dimension.request); return; } if (phase === 'DRAGGING') { - processSecondaryDimensions(current.dimension.request); - return; - } + invariant(collection, 'Cannot start a drag without a collection'); + + // Sanity checking that our recorded collection matches the request in app state + const request: ?LiftRequest = current.dimension.request; + invariant(request); + invariant(request.draggableId === collection.critical.draggable.id, + 'Recorded request does not match app state' + ); + invariant(request.scrollOptions === collection.scrollOptions, + 'Recorded scroll options does not match app state' + ); - // No need to collect any more as the user has finished interacting - if (phase === 'DROP_ANIMATING' || phase === 'DROP_COMPLETE') { - if (state.isCollecting) { - stopCollecting(); - } + collector.start(collection); return; } - // drag potentially cleaned - if (phase === 'IDLE') { - if (state.isCollecting) { - stopCollecting(); - } + if (collection) { + stopCollecting(); } }; diff --git a/src/view/drag-drop-context/drag-drop-context.jsx b/src/view/drag-drop-context/drag-drop-context.jsx index f2c502a022..02fc8b17cb 100644 --- a/src/view/drag-drop-context/drag-drop-context.jsx +++ b/src/view/drag-drop-context/drag-drop-context.jsx @@ -205,6 +205,14 @@ export default class DragDropContext extends React.Component { // This is useful when the user canLift = (id: DraggableId) => canStartDrag(this.store.getState(), id); + componentDidCatch(error: Error) { + console.warn('Error caught in DragDropContext. Cancelling any drag', error); + this.store.dispatch(clean()); + + // Not swallowing the error - letting it pass through + throw error; + } + componentDidMount() { this.styleMarshal.mount(); this.announcer.mount();