Skip to content

Commit

Permalink
Rewrite useScroll to be more performant by being ref based instead of…
Browse files Browse the repository at this point in the history
… state to avoid re-renders when scrolling
  • Loading branch information
joel-jeremy committed Nov 1, 2024
1 parent 180faf8 commit 7ec1d8e
Show file tree
Hide file tree
Showing 3 changed files with 150 additions and 51 deletions.
162 changes: 138 additions & 24 deletions packages/desktop-client/src/components/ScrollProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,28 @@ import React, {
type ReactNode,
type RefObject,
createContext,
useState,
useContext,
useEffect,
useCallback,
useRef,
} from 'react';

import debounce from 'debounce';

type ScrollDirection = 'up' | 'down' | 'left' | 'right';

type ScrollListenerArgs = {
scrollX: number;
scrollY: number;
isScrolling: (direction: ScrollDirection) => boolean;
hasScrolledToEnd: (direction: ScrollDirection, tolerance?: number) => boolean;
};

type ScrollListener = (values: ScrollListenerArgs) => void;

type IScrollContext = {
scrollY: number | undefined;
hasScrolledToBottom: (tolerance?: number) => boolean;
registerListener: (listener: ScrollListener) => void;
unregisterListener: (listener: ScrollListener) => void;
};

const ScrollContext = createContext<IScrollContext | undefined>(undefined);
Expand All @@ -28,24 +39,100 @@ export function ScrollProvider<T extends Element>({
isDisabled,
children,
}: ScrollProviderProps<T>) {
const [scrollY, setScrollY] = useState<number | undefined>(undefined);
const [scrollHeight, setScrollHeight] = useState<number | undefined>(
undefined,
);
const [clientHeight, setClientHeight] = useState<number | undefined>(
undefined,
);
const previousScrollX = useRef<number | undefined>(undefined);
const scrollX = useRef<number | undefined>(undefined);
const previousScrollY = useRef<number | undefined>(undefined);
const scrollY = useRef<number | undefined>(undefined);
const scrollWidth = useRef<number | undefined>(undefined);
const scrollHeight = useRef<number | undefined>(undefined);
const clientWidth = useRef<number | undefined>(undefined);
const clientHeight = useRef<number | undefined>(undefined);
const listeners = useRef<ScrollListener[]>([]);

const hasScrolledToBottom = useCallback(
(tolerance = 1) => {
if (scrollHeight && scrollY && clientHeight) {
return scrollHeight - scrollY <= clientHeight + tolerance;
const hasScrolledToEnd = useCallback(
(direction: ScrollDirection, tolerance = 1) => {
switch (direction) {
case 'up':
const hasScrolledToTop = () => {
if (scrollY.current) {
return scrollY.current <= tolerance;
}
return false;
};
return hasScrolledToTop();
case 'down':
const hasScrolledToBottom = () => {
if (
scrollHeight.current &&
scrollY.current &&
clientHeight.current
) {
return (
scrollHeight.current - scrollY.current <=
clientHeight.current + tolerance
);
}
return false;
};
return hasScrolledToBottom();
case 'left':
const hasScrollToLeft = () => {
if (scrollX.current) {
return scrollX.current <= tolerance;
}
return false;
};
return hasScrollToLeft();
case 'right':
const hasScrolledToRight = () => {
if (scrollWidth.current && scrollX.current && clientWidth.current) {
return (
scrollWidth.current - scrollX.current <=
clientWidth.current + tolerance
);
}

return false;
};
return hasScrolledToRight();
default:
return false;
}
return false;
},
[clientHeight, scrollHeight, scrollY],
[],
);

const isScrolling = useCallback((direction: ScrollDirection) => {
switch (direction) {
case 'up':
return (
previousScrollY.current !== undefined &&
scrollY.current !== undefined &&
previousScrollY.current > scrollY.current
);
case 'down':
return (
previousScrollY.current !== undefined &&
scrollY.current !== undefined &&
previousScrollY.current < scrollY.current
);
case 'left':
return (
previousScrollX.current !== undefined &&
scrollX.current !== undefined &&
previousScrollX.current > scrollX.current
);
case 'right':
return (
previousScrollX.current !== undefined &&
scrollX.current !== undefined &&
previousScrollX.current < scrollX.current
);
default:
return false;
}
}, []);

useEffect(() => {
if (isDisabled) {
return;
Expand All @@ -54,9 +141,21 @@ export function ScrollProvider<T extends Element>({
const listenToScroll = debounce((e: Event) => {
const target = e.target;
if (target instanceof Element) {
setScrollY(target.scrollTop || 0);
setScrollHeight(target.scrollHeight || 0);
setClientHeight(target.clientHeight || 0);
previousScrollX.current = scrollX.current;
scrollX.current = target.scrollLeft || 0;
previousScrollY.current = scrollY.current;
scrollY.current = target.scrollTop || 0;
scrollHeight.current = target.scrollHeight || 0;
clientHeight.current = target.clientHeight || 0;

listeners.current.forEach(listener =>
listener({
scrollX: scrollX.current!,
scrollY: scrollY.current!,
isScrolling,
hasScrolledToEnd,
}),
);
}
}, 10);

Expand All @@ -70,19 +169,34 @@ export function ScrollProvider<T extends Element>({
ref?.removeEventListener('scroll', listenToScroll, {
capture: true,
});
}, [isDisabled, scrollableRef]);
}, [hasScrolledToEnd, isDisabled, isScrolling, scrollableRef]);

const registerListener = useCallback((listener: ScrollListener) => {
listeners.current.push(listener);
}, []);

const unregisterListener = useCallback((listener: ScrollListener) => {
listeners.current = listeners.current.filter(l => l !== listener);
}, []);

return (
<ScrollContext.Provider value={{ scrollY, hasScrolledToBottom }}>
<ScrollContext.Provider value={{ registerListener, unregisterListener }}>
{children}
</ScrollContext.Provider>
);
}

export function useScroll(): IScrollContext {
export function useScrollEffect(listener: ScrollListener) {
const context = useContext(ScrollContext);
if (!context) {
throw new Error('useScroll must be used within a ScrollProvider');
throw new Error('useScrollEffect must be used within a ScrollProvider');
}
return context;

const { registerListener, unregisterListener } = context;

useEffect(() => {
const _listener = listener;
registerListener(_listener);
return () => unregisterListener(_listener);
}, [listener, registerListener, unregisterListener]);
}
23 changes: 5 additions & 18 deletions packages/desktop-client/src/components/mobile/MobileNavTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
// @ts-strict-ignore
import React, {
type ComponentType,
useEffect,
type CSSProperties,
} from 'react';
import React, { type ComponentType, type CSSProperties } from 'react';
import { NavLink } from 'react-router-dom';
import { useSpring, animated, config } from 'react-spring';

import { useDrag } from '@use-gesture/react';

import { usePrevious } from '../../hooks/usePrevious';
import {
SvgAdd,
SvgCog,
Expand All @@ -23,7 +18,7 @@ import { SvgCalendar } from '../../icons/v2';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { View } from '../common/View';
import { useScroll } from '../ScrollProvider';
import { useScrollEffect } from '../ScrollProvider';

const COLUMN_COUNT = 3;
const PILL_HEIGHT = 15;
Expand All @@ -32,7 +27,6 @@ export const MOBILE_NAV_HEIGHT = ROW_HEIGHT + PILL_HEIGHT;

export function MobileNavTabs() {
const { isNarrowWidth } = useResponsive();
const { scrollY } = useScroll();

const navTabStyle = {
flex: `1 1 ${100 / COLUMN_COUNT}%`,
Expand Down Expand Up @@ -129,20 +123,13 @@ export function MobileNavTabs() {
});
};

const previousScrollY = usePrevious(scrollY);

useEffect(() => {
if (
scrollY &&
previousScrollY &&
scrollY > previousScrollY &&
previousScrollY !== 0
) {
useScrollEffect(({ isScrolling }) => {
if (isScrolling('down')) {
hide();
} else {
close();
}
}, [scrollY]);
});

const bind = useDrag(
({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import React, { useRef } from 'react';
import { useListBox } from 'react-aria';
import { useListState } from 'react-stately';

import { usePrevious } from '../../../hooks/usePrevious';
import { useScroll } from '../../ScrollProvider';
import { useScrollEffect } from '../../ScrollProvider';

import { ListBoxSection } from './ListBoxSection';

Expand All @@ -13,13 +12,12 @@ export function ListBox(props) {
const { listBoxProps, labelProps } = useListBox(props, state, listBoxRef);
const { loadMore } = props;

const { hasScrolledToBottom } = useScroll();
const scrolledToBottom = hasScrolledToBottom(5);
const prevScrolledToBottom = usePrevious(scrolledToBottom);

if (!prevScrolledToBottom && scrolledToBottom) {
loadMore?.();
}
useScrollEffect(({ hasScrolledToEnd }) => {
const scrolledToBottom = hasScrolledToEnd('down', 5);
if (scrolledToBottom) {
loadMore?.();
}
});

return (
<>
Expand Down

0 comments on commit 7ec1d8e

Please sign in to comment.