diff --git a/lib/ObserveViewport.tsx b/lib/ObserveViewport.tsx index 4b2ed29..ad03aa9 100644 --- a/lib/ObserveViewport.tsx +++ b/lib/ObserveViewport.tsx @@ -12,6 +12,7 @@ import { IViewport, TViewportChangeHandler, IViewportChangeOptions, + PriorityType, } from './types'; import { warnNoContextAvailable } from './utils'; @@ -29,6 +30,7 @@ interface IProps { disableScrollUpdates: boolean; disableDimensionsUpdates: boolean; deferUpdateUntilIdle: boolean; + priority: PriorityType; } interface IContext { @@ -54,10 +56,11 @@ export default class ObserveViewport extends React.Component { private tickId: NodeJS.Timer; - static defaultProps = { + static defaultProps: IProps = { disableScrollUpdates: false, disableDimensionsUpdates: false, deferUpdateUntilIdle: false, + priority: 'normal', }; constructor(props: IProps) { @@ -134,6 +137,7 @@ export default class ObserveViewport extends React.Component { notifyScroll: () => !this.props.disableScrollUpdates, notifyDimensions: () => !this.props.disableDimensionsUpdates, notifyOnlyWhenIdle: () => this.props.deferUpdateUntilIdle, + priority: () => this.props.priority, recalculateLayoutBeforeUpdate: (viewport: IViewport) => { if (this.props.recalculateLayoutBeforeUpdate) { return this.props.recalculateLayoutBeforeUpdate(viewport); diff --git a/lib/ViewportProvider.tsx b/lib/ViewportProvider.tsx index 211dcdb..b353286 100644 --- a/lib/ViewportProvider.tsx +++ b/lib/ViewportProvider.tsx @@ -8,8 +8,15 @@ import { } from './types'; import ViewportCollector from './ViewportCollector'; +interface IProps { + experimentalSchedulerEnabled?: boolean; +} + interface IListener extends IViewportChangeOptions { handler: TViewportChangeHandler; + iterations: number; + averageExecutionCost: number; + skippedIterations: number; } export const ViewportContext = React.createContext({ @@ -22,14 +29,51 @@ export const ViewportContext = React.createContext({ version: '__VERSION__', }); +const maxIterations = (priority: 'highest' | 'high' | 'normal' | 'low') => { + switch (priority) { + case 'highest': + return 0; + case 'high': + return 4; + case 'normal': + return 16; + case 'low': + return 64; + } +}; + +const shouldSkipIteration = ( + { priority: getPriority, averageExecutionCost, skippedIterations }: IListener, + budget: number, +): boolean => { + const priority = getPriority(); + if (priority === 'highest') { + return false; + } + if (priority !== 'low' && averageExecutionCost <= budget) { + return false; + } + if (averageExecutionCost <= budget / 10) { + return false; + } + const probability = skippedIterations / maxIterations(priority); + if (probability >= 1) { + return false; + } + return Math.random() > probability; +}; + export default class ViewportProvider extends React.PureComponent< - {}, + IProps, { hasListeners: boolean } > { + static defaultProps: { + experimentalSchedulerEnabled: false; + }; private listeners: IListener[] = []; private updateListenersTick: NodeJS.Timer; - constructor(props: {}) { + constructor(props: IProps) { super(props); this.state = { hasListeners: false, @@ -46,7 +90,7 @@ export default class ViewportProvider extends React.PureComponent< options?: { isIdle: boolean }, ) => { const { isIdle } = Object.assign({ isIdle: false }, options); - const updatableListeners = this.listeners.filter( + let updatableListeners = this.listeners.filter( ({ notifyScroll, notifyDimensions, notifyOnlyWhenIdle }) => { if (notifyOnlyWhenIdle() && !isIdle) { return false; @@ -56,18 +100,43 @@ export default class ViewportProvider extends React.PureComponent< return updateForScroll || updateForDimensions; }, ); + if (this.props.experimentalSchedulerEnabled) { + if (!isIdle) { + const budget = 16 / updatableListeners.length; + updatableListeners = updatableListeners.filter(listener => { + const skip = shouldSkipIteration(listener, budget); + if (skip) { + listener.skippedIterations++; + return false; + } + listener.skippedIterations = 0; + return true; + }); + } + } const layouts = updatableListeners.map( ({ recalculateLayoutBeforeUpdate }) => { if (recalculateLayoutBeforeUpdate) { - return recalculateLayoutBeforeUpdate(state); + const start = performance.now(); + const layoutState = recalculateLayoutBeforeUpdate(state); + return [layoutState, performance.now() - start]; } return null; }, ); - updatableListeners.forEach(({ handler }, index) => { - const layout = layouts[index]; + updatableListeners.forEach((listener, index) => { + const { handler, averageExecutionCost, iterations } = listener; + const [layout, layoutCost] = layouts[index] || [null, 0]; + + const start = performance.now(); handler(state, layout); + const totalCost = layoutCost + performance.now() - start; + const diff = totalCost - averageExecutionCost; + const i = iterations + 1; + + listener.averageExecutionCost = averageExecutionCost + diff / i; + listener.iterations = i; }); }; @@ -75,16 +144,22 @@ export default class ViewportProvider extends React.PureComponent< handler: TViewportChangeHandler, options: IViewportChangeOptions, ) => { - this.listeners.push({ handler, ...options }); - this.updateListenersLazy(); + this.listeners.push({ + handler, + iterations: 0, + averageExecutionCost: 0, + skippedIterations: 0, + ...options, + }); + this.updateHasListenersState(); }; removeViewportChangeListener = (h: TViewportChangeHandler) => { this.listeners = this.listeners.filter(({ handler }) => handler !== h); - this.updateListenersLazy(); + this.updateHasListenersState(); }; - updateListenersLazy() { + updateHasListenersState() { clearTimeout(this.updateListenersTick); this.updateListenersTick = setTimeout(() => { this.setState({ diff --git a/lib/hooks.ts b/lib/hooks.ts index eb5c893..6589190 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -5,11 +5,10 @@ import { createInitScrollState, createInitDimensionsState, } from './ViewportCollector'; -import { IViewport, IScroll, IDimensions } from './types'; +import { IViewport, IScroll, IDimensions, PriorityType } from './types'; import { warnNoContextAvailable } from './utils'; -interface IFullOptions { - recalculateLayoutBeforeUpdate?: (props: IViewport) => any; +interface IFullOptions extends IOptions { disableScrollUpdates?: boolean; disableDimensionsUpdates?: boolean; deferUpdateUntilIdle?: boolean; @@ -17,6 +16,7 @@ interface IFullOptions { interface IOptions { deferUpdateUntilIdle?: boolean; + priority?: PriorityType; recalculateLayoutBeforeUpdate?: (props: IViewport) => any; } @@ -43,6 +43,7 @@ const useViewportEffect = ( notifyScroll: () => !options.disableScrollUpdates, notifyDimensions: () => !options.disableDimensionsUpdates, notifyOnlyWhenIdle: () => Boolean(options.deferUpdateUntilIdle), + priority: () => options.priority || 'normal', recalculateLayoutBeforeUpdate: options.recalculateLayoutBeforeUpdate, }); return () => removeViewportChangeListener(handleViewportChange); diff --git a/lib/types.ts b/lib/types.ts index 6512b90..1173af1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -45,6 +45,7 @@ export interface IViewportChangeOptions { notifyScroll: () => boolean; notifyDimensions: () => boolean; notifyOnlyWhenIdle: () => boolean; + priority: () => PriorityType; recalculateLayoutBeforeUpdate?: (viewport: IViewport) => any; } @@ -57,3 +58,5 @@ export type OnUpdateType = ( props: IViewport, options: IViewportCollectorUpdateOptions, ) => void; + +export type PriorityType = 'highest' | 'high' | 'normal' | 'low'