Skip to content

Commit c6d9f60

Browse files
committed
refactor: rewrite Scrollable utilizing platform features
This updates the Scrollable component to be a function component, but also changes the fundamental implementation strategy. Previously, the Scrollable component held internal state for the scroll position and controlled the scroll of the actual DOM node as a side effect. With this change, there is no controlling of the DOM node's scroll, allowing the platform to handle all scrolling interaction and only using native features to supply enhancements for the ScrollTo component, the hint functionality.
1 parent 60191f3 commit c6d9f60

File tree

2 files changed

+101
-205
lines changed

2 files changed

+101
-205
lines changed

.changeset/many-otters-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/polaris': patch
3+
---
4+
5+
Improve performance of the Scrollable component with React 18
Lines changed: 96 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {Component} from 'react';
1+
import React, {useEffect, useRef, useState, useCallback} from 'react';
22

33
import {debounce} from '../../utilities/debounce';
44
import {classNames} from '../../utilities/css';
@@ -12,11 +12,7 @@ import {ScrollTo} from './components';
1212
import {ScrollableContext} from './context';
1313
import styles from './Scrollable.scss';
1414

15-
const MAX_SCROLL_DISTANCE = 100;
16-
const DELTA_THRESHOLD = 0.2;
17-
const DELTA_PERCENTAGE = 0.2;
18-
const EVENTS_TO_LOCK = ['scroll', 'touchmove', 'wheel'];
19-
const PREFERS_REDUCED_MOTION = prefersReducedMotion();
15+
const MAX_SCROLL_HINT_DISTANCE = 100;
2016
const LOW_RES_BUFFER = 2;
2117

2218
export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
@@ -40,218 +36,86 @@ export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
4036
onScrolledToBottom?(): void;
4137
}
4238

43-
interface State {
44-
topShadow: boolean;
45-
bottomShadow: boolean;
46-
scrollPosition: number;
47-
canScroll: boolean;
48-
}
49-
50-
export class Scrollable extends Component<ScrollableProps, State> {
51-
static ScrollTo = ScrollTo;
52-
static forNode(node: HTMLElement): HTMLElement | Document {
53-
const closestElement = node.closest(scrollable.selector);
54-
return closestElement instanceof HTMLElement ? closestElement : document;
55-
}
56-
57-
state: State = {
58-
topShadow: false,
59-
bottomShadow: false,
60-
scrollPosition: 0,
61-
canScroll: false,
62-
};
63-
64-
private stickyManager = new StickyManager();
65-
66-
private scrollArea: HTMLElement | null = null;
67-
68-
private handleResize = debounce(
69-
() => {
70-
this.handleScroll();
71-
},
72-
50,
73-
{trailing: true},
74-
);
75-
76-
componentDidMount() {
77-
if (this.scrollArea == null) {
78-
return;
79-
}
80-
this.stickyManager.setContainer(this.scrollArea);
81-
this.scrollArea.addEventListener('scroll', () => {
82-
window.requestAnimationFrame(this.handleScroll);
83-
});
84-
window.addEventListener('resize', this.handleResize);
85-
window.requestAnimationFrame(() => {
86-
this.handleScroll();
87-
if (this.props.hint) {
88-
this.scrollHint();
89-
}
90-
});
91-
}
92-
93-
componentWillUnmount() {
94-
if (this.scrollArea == null) {
95-
return;
39+
export function Scrollable({
40+
children,
41+
className,
42+
horizontal = true,
43+
vertical = true,
44+
shadow,
45+
hint,
46+
focusable,
47+
onScrolledToBottom,
48+
...rest
49+
}: ScrollableProps) {
50+
const [topShadow, setTopShadow] = useState(false);
51+
const [bottomShadow, setBottomShadow] = useState(false);
52+
const stickyManager = useRef(new StickyManager());
53+
const scrollArea = useRef<HTMLDivElement>(null);
54+
const scrollTo = useCallback((scrollY: number) => {
55+
scrollArea.current?.scrollTo({top: scrollY, behavior: 'smooth'});
56+
}, []);
57+
58+
useEffect(() => {
59+
if (hint) {
60+
performScrollHint(scrollArea.current);
9661
}
97-
this.scrollArea.removeEventListener('scroll', this.handleScroll);
98-
window.removeEventListener('resize', this.handleResize);
99-
this.stickyManager.removeScrollListener();
100-
}
101-
102-
componentDidUpdate() {
103-
const {scrollPosition} = this.state;
104-
if (scrollPosition && this.scrollArea && scrollPosition > 0) {
105-
this.scrollArea.scrollTop = scrollPosition;
106-
}
107-
}
108-
109-
render() {
110-
const {topShadow, bottomShadow, canScroll} = this.state;
111-
const {
112-
children,
113-
className,
114-
horizontal = true,
115-
vertical = true,
116-
shadow,
117-
hint,
118-
focusable,
119-
onScrolledToBottom,
120-
...rest
121-
} = this.props;
62+
}, [hint]);
12263

123-
const finalClassName = classNames(
124-
className,
125-
styles.Scrollable,
126-
vertical && styles.vertical,
127-
horizontal && styles.horizontal,
128-
topShadow && styles.hasTopShadow,
129-
bottomShadow && styles.hasBottomShadow,
130-
vertical && canScroll && styles.verticalHasScrolling,
131-
);
64+
useEffect(() => {
65+
const currentScrollArea = scrollArea.current;
13266

133-
return (
134-
<ScrollableContext.Provider value={this.scrollToPosition}>
135-
<StickyManagerContext.Provider value={this.stickyManager}>
136-
<div
137-
className={finalClassName}
138-
{...scrollable.props}
139-
{...rest}
140-
ref={this.setScrollArea}
141-
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
142-
tabIndex={focusable ? 0 : undefined}
143-
>
144-
{children}
145-
</div>
146-
</StickyManagerContext.Provider>
147-
</ScrollableContext.Provider>
148-
);
149-
}
150-
151-
private setScrollArea = (scrollArea: HTMLElement | null) => {
152-
this.scrollArea = scrollArea;
153-
};
154-
155-
private handleScroll = () => {
156-
const {scrollArea} = this;
157-
const {scrollPosition} = this.state;
158-
const {shadow, onScrolledToBottom} = this.props;
159-
if (scrollArea == null) {
67+
if (!currentScrollArea) {
16068
return;
16169
}
162-
const {scrollTop, clientHeight, scrollHeight} = scrollArea;
163-
const shouldBottomShadow = Boolean(
164-
shadow && !(scrollTop + clientHeight >= scrollHeight),
165-
);
166-
const shouldTopShadow = Boolean(
167-
shadow && scrollTop > 0 && scrollPosition > 0,
168-
);
16970

170-
const canScroll = scrollHeight > clientHeight;
171-
const hasScrolledToBottom =
172-
scrollHeight - scrollTop <= clientHeight + LOW_RES_BUFFER;
71+
const handleScroll = () => {
72+
const {scrollTop, clientHeight, scrollHeight} = currentScrollArea;
17373

174-
if (canScroll && hasScrolledToBottom && onScrolledToBottom) {
175-
onScrolledToBottom();
176-
}
74+
setBottomShadow(
75+
Boolean(shadow && !(scrollTop + clientHeight >= scrollHeight)),
76+
);
77+
setTopShadow(Boolean(shadow && scrollTop > 0));
78+
};
17779

178-
this.setState({
179-
topShadow: shouldTopShadow,
180-
bottomShadow: shouldBottomShadow,
181-
scrollPosition: scrollTop,
182-
canScroll,
183-
});
184-
};
80+
const handleResize = debounce(handleScroll, 50, {trailing: true});
18581

186-
private scrollHint = () => {
187-
const {scrollArea} = this;
188-
if (scrollArea == null) {
189-
return;
190-
}
191-
const {clientHeight, scrollHeight} = scrollArea;
192-
if (
193-
PREFERS_REDUCED_MOTION ||
194-
this.state.scrollPosition > 0 ||
195-
scrollHeight <= clientHeight
196-
) {
197-
return;
198-
}
82+
stickyManager.current?.setContainer(currentScrollArea);
83+
currentScrollArea.addEventListener('scroll', handleScroll);
84+
globalThis.addEventListener('resize', handleResize);
19985

200-
const scrollDistance = scrollHeight - clientHeight;
201-
this.toggleLock();
202-
this.setState(
203-
{
204-
scrollPosition:
205-
scrollDistance > MAX_SCROLL_DISTANCE
206-
? MAX_SCROLL_DISTANCE
207-
: scrollDistance,
208-
},
209-
() => {
210-
window.requestAnimationFrame(this.scrollStep);
211-
},
212-
);
213-
};
86+
handleScroll();
21487

215-
private scrollStep = () => {
216-
this.setState(
217-
({scrollPosition}) => {
218-
const delta = scrollPosition * DELTA_PERCENTAGE;
219-
return {
220-
scrollPosition: delta < DELTA_THRESHOLD ? 0 : scrollPosition - delta,
221-
};
222-
},
223-
() => {
224-
if (this.state.scrollPosition > 0) {
225-
window.requestAnimationFrame(this.scrollStep);
226-
} else {
227-
this.toggleLock(false);
228-
}
229-
},
230-
);
231-
};
88+
return () => {
89+
currentScrollArea.removeEventListener('scroll', handleScroll);
90+
globalThis.removeEventListener('resize', handleResize);
91+
};
92+
}, [shadow]);
23293

233-
private toggleLock(shouldLock = true) {
234-
const {scrollArea} = this;
235-
if (scrollArea == null) {
236-
return;
237-
}
238-
239-
EVENTS_TO_LOCK.forEach((eventName) => {
240-
if (shouldLock) {
241-
scrollArea.addEventListener(eventName, prevent);
242-
} else {
243-
scrollArea.removeEventListener(eventName, prevent);
244-
}
245-
});
246-
}
247-
248-
private scrollToPosition = (scrollY: number) => {
249-
this.setState({scrollPosition: scrollY});
250-
};
251-
}
94+
const finalClassName = classNames(
95+
className,
96+
styles.Scrollable,
97+
vertical && styles.vertical,
98+
horizontal && styles.horizontal,
99+
topShadow && styles.hasTopShadow,
100+
bottomShadow && styles.hasBottomShadow,
101+
);
252102

253-
function prevent(evt: Event) {
254-
evt.preventDefault();
103+
return (
104+
<ScrollableContext.Provider value={scrollTo}>
105+
<StickyManagerContext.Provider value={stickyManager.current}>
106+
<div
107+
className={finalClassName}
108+
{...scrollable.props}
109+
{...rest}
110+
ref={scrollArea}
111+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
112+
tabIndex={focusable ? 0 : undefined}
113+
>
114+
{children}
115+
</div>
116+
</StickyManagerContext.Provider>
117+
</ScrollableContext.Provider>
118+
);
255119
}
256120

257121
function prefersReducedMotion() {
@@ -261,3 +125,30 @@ function prefersReducedMotion() {
261125
return false;
262126
}
263127
}
128+
129+
function performScrollHint(elem?: HTMLDivElement | null) {
130+
if (!elem || prefersReducedMotion()) {
131+
return;
132+
}
133+
134+
const scrollableDistance = elem.scrollHeight - elem.clientHeight;
135+
const distanceToPeek =
136+
Math.min(MAX_SCROLL_HINT_DISTANCE, scrollableDistance) - LOW_RES_BUFFER;
137+
138+
const goBackToTop = () => {
139+
if (elem.scrollTop >= distanceToPeek) {
140+
elem.removeEventListener('scroll', goBackToTop);
141+
elem.scrollTo({top: 0, behavior: 'smooth'});
142+
}
143+
};
144+
145+
elem.addEventListener('scroll', goBackToTop);
146+
elem.scrollTo({top: MAX_SCROLL_HINT_DISTANCE, behavior: 'smooth'});
147+
}
148+
149+
Scrollable.ScrollTo = ScrollTo;
150+
151+
Scrollable.forNode = (node: HTMLElement): HTMLElement | Document => {
152+
const closestElement = node.closest(scrollable.selector);
153+
return closestElement instanceof HTMLElement ? closestElement : document;
154+
};

0 commit comments

Comments
 (0)