Skip to content

Commit

Permalink
fix(hooks): don't cause forced updates
Browse files Browse the repository at this point in the history
On mount the useLayoutSnapshot hook performed an update
which caused a forced layout. By scheduling this event
via the normal listener update this will cause less layout trashing.

Fixes #8
  • Loading branch information
garthenweb committed Sep 1, 2019
1 parent 7fc4dd5 commit 0b322d4
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 23 deletions.
32 changes: 25 additions & 7 deletions examples/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,27 +39,43 @@ const DisplayViewport = React.memo(() => {
const { documentHeight, clientWidth } = useDimensions({
priority: 'low',
});
const offsetTop = useLayoutSnapshot<number>(({ scroll }) => {
if (!div.current) {
return 0;
}
return div.current.getBoundingClientRect().top + scroll.y;
});
const rect = useRect(div);
return (
<div ref={div}>
x: {x}, y: {y}
<br />
documentHeight: {documentHeight}
<br />
clientWidth: {clientWidth}, element offsetTop: {offsetTop}
clientWidth: {clientWidth}
<br />
rect.top: {rect ? rect.top : 'null'}, rect.bottom:{' '}
{rect ? rect.bottom : 'null'}
</div>
);
});

const LayoutSnapshot = () => {
const div = React.useRef(null);
const offsetTop = useLayoutSnapshot<number>(({ scroll }) => {
if (!div.current) {
return 0;
}
return div.current.getBoundingClientRect().top + scroll.y;
});
console.log('hook:layout snapshot', offsetTop);
return <div ref={div} />;
};

const LayoutOutside = React.memo(() => {
const [active, setActive] = React.useState(true);
return (
<>
<button onClick={() => setActive(!active)}>change active</button>
{active && <LayoutSnapshot />}
</>
);
});

class Example extends React.PureComponent<{}, { disabled: boolean }> {
private container1: React.RefObject<any>;
private container2: React.RefObject<any>;
Expand Down Expand Up @@ -164,6 +180,7 @@ render(
<Placeholder />
<Placeholder />
<Placeholder />
<LayoutOutside />
</main>
</ViewportProvider>,
document.getElementById('root'),
Expand All @@ -178,6 +195,7 @@ setInterval(() => {
<Placeholder />
<Placeholder />
<Placeholder />
<LayoutOutside />
</main>
</ViewportProvider>,
document.getElementById('root'),
Expand Down
61 changes: 50 additions & 11 deletions lib/ViewportProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import ViewportCollector, {
getClientDimensions,
getClientScroll,
} from './ViewportCollector';
import { createPerformanceMarker, now } from './utils';
import {
createPerformanceMarker,
now,
requestAnimationFrame,
cancelAnimationFrame,
} from './utils';

interface IProps {
experimentalSchedulerEnabled?: boolean;
Expand All @@ -19,6 +24,7 @@ interface IProps {
interface IListener extends IViewportChangeOptions {
handler: TViewportChangeHandler;
iterations: number;
initialized: boolean;
averageExecutionCost: number;
skippedIterations: number;
}
Expand Down Expand Up @@ -92,6 +98,7 @@ export default class ViewportProvider extends React.PureComponent<
};
private listeners: IListener[] = [];
private updateListenersTick?: NodeJS.Timer;
private initializeListenersTick?: number;

constructor(props: IProps) {
super(props);
Expand All @@ -109,20 +116,27 @@ export default class ViewportProvider extends React.PureComponent<
triggerUpdateToListeners = (
state: IViewport,
{ scrollDidUpdate, dimensionsDidUpdate }: IViewportCollectorUpdateOptions,
options?: { isIdle: boolean },
options?: { isIdle?: boolean; shouldInitialize?: boolean },
) => {
const { isIdle } = Object.assign({ isIdle: false }, options);
const { isIdle, shouldInitialize } = Object.assign(
{ isIdle: false, shouldInitialize: false },
options,
);
let updatableListeners = this.listeners.filter(
({
notifyScroll,
notifyDimensions,
notifyOnlyWhenIdle,
skippedIterations,
initialized,
}) => {
const needsUpdate = skippedIterations > 0;
if (notifyOnlyWhenIdle() !== isIdle && !needsUpdate) {
return false;
}
if (shouldInitialize && !initialized) {
return true;
}
const updateForScroll = notifyScroll() && scrollDidUpdate;
const updateForDimensions = notifyDimensions() && dimensionsDidUpdate;
return updateForScroll || updateForDimensions;
Expand All @@ -132,7 +146,9 @@ export default class ViewportProvider extends React.PureComponent<
if (!isIdle) {
const budget = 16 / updatableListeners.length;
updatableListeners = updatableListeners.filter(listener => {
const skip = shouldSkipIteration(listener, budget);
const skip = listener.initialized
? shouldSkipIteration(listener, budget)
: false;
if (skip) {
listener.skippedIterations++;
return false;
Expand Down Expand Up @@ -165,6 +181,7 @@ export default class ViewportProvider extends React.PureComponent<

listener.averageExecutionCost = averageExecutionCost + diff / i;
listener.iterations = i;
listener.initialized = true;
});
};

Expand All @@ -177,25 +194,47 @@ export default class ViewportProvider extends React.PureComponent<
iterations: 0,
averageExecutionCost: 0,
skippedIterations: 0,
initialized: false,
...options,
});
this.updateHasListenersState();
this.handleListenerUpdate();
};

removeViewportChangeListener = (h: TViewportChangeHandler) => {
this.listeners = this.listeners.filter(({ handler }) => handler !== h);
this.updateHasListenersState();
this.handleListenerUpdate();
};

updateHasListenersState() {
handleListenerUpdate() {
if (typeof this.updateListenersTick === 'number') {
clearTimeout(this.updateListenersTick);
}
if (typeof this.initializeListenersTick === 'number') {
cancelAnimationFrame(this.initializeListenersTick);
}
this.updateListenersTick = setTimeout(() => {
this.setState({
hasListeners: this.listeners.length !== 0,
});
}, 0);
const nextState = this.listeners.length !== 0;
if (this.state.hasListeners !== nextState) {
this.setState({
hasListeners: this.listeners.length !== 0,
});
}
}, 1);
this.initializeListenersTick = requestAnimationFrame(() => {
if (this.collector.current && this.listeners.some(l => !l.initialized)) {
this.triggerUpdateToListeners(
this.collector.current.getPropsFromState(),
{
dimensionsDidUpdate: false,
scrollDidUpdate: false,
},
{
isIdle: false,
shouldInitialize: true,
},
);
}
});
}

private collector = React.createRef<ViewportCollector>();
Expand Down
5 changes: 0 additions & 5 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,11 @@ export const useLayoutSnapshot = <T = unknown>(
recalculateLayoutBeforeUpdate: (viewport: IViewport) => T,
options: IFullOptions = {},
): null | T => {
const { getCurrentViewport } = useContext(ViewportContext);
const [state, setSnapshot] = useState<null | T>(null);
useViewportEffect((_, snapshot: T) => setSnapshot(snapshot), {
...options,
recalculateLayoutBeforeUpdate,
});

useEffect(() => {
setSnapshot(recalculateLayoutBeforeUpdate(getCurrentViewport()));
}, []);

return state;
};

0 comments on commit 0b322d4

Please sign in to comment.