Skip to content

Commit 19af0a0

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 3be27ba commit 19af0a0

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> {
@@ -36,218 +32,86 @@ export interface ScrollableProps extends React.HTMLProps<HTMLDivElement> {
3632
onScrolledToBottom?(): void;
3733
}
3834

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

119-
const finalClassName = classNames(
120-
className,
121-
styles.Scrollable,
122-
vertical && styles.vertical,
123-
horizontal && styles.horizontal,
124-
topShadow && styles.hasTopShadow,
125-
bottomShadow && styles.hasBottomShadow,
126-
vertical && canScroll && styles.verticalHasScrolling,
127-
);
60+
useEffect(() => {
61+
const currentScrollArea = scrollArea.current;
12862

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

166-
const canScroll = scrollHeight > clientHeight;
167-
const hasScrolledToBottom =
168-
scrollHeight - scrollTop <= clientHeight + LOW_RES_BUFFER;
67+
const handleScroll = () => {
68+
const {scrollTop, clientHeight, scrollHeight} = currentScrollArea;
16969

170-
if (canScroll && hasScrolledToBottom && onScrolledToBottom) {
171-
onScrolledToBottom();
172-
}
70+
setBottomShadow(
71+
Boolean(shadow && !(scrollTop + clientHeight >= scrollHeight)),
72+
);
73+
setTopShadow(Boolean(shadow && scrollTop > 0));
74+
};
17375

174-
this.setState({
175-
topShadow: shouldTopShadow,
176-
bottomShadow: shouldBottomShadow,
177-
scrollPosition: scrollTop,
178-
canScroll,
179-
});
180-
};
76+
const handleResize = debounce(handleScroll, 50, {trailing: true});
18177

182-
private scrollHint = () => {
183-
const {scrollArea} = this;
184-
if (scrollArea == null) {
185-
return;
186-
}
187-
const {clientHeight, scrollHeight} = scrollArea;
188-
if (
189-
PREFERS_REDUCED_MOTION ||
190-
this.state.scrollPosition > 0 ||
191-
scrollHeight <= clientHeight
192-
) {
193-
return;
194-
}
78+
stickyManager.current?.setContainer(currentScrollArea);
79+
currentScrollArea.addEventListener('scroll', handleScroll);
80+
window.addEventListener('resize', handleResize);
19581

196-
const scrollDistance = scrollHeight - clientHeight;
197-
this.toggleLock();
198-
this.setState(
199-
{
200-
scrollPosition:
201-
scrollDistance > MAX_SCROLL_DISTANCE
202-
? MAX_SCROLL_DISTANCE
203-
: scrollDistance,
204-
},
205-
() => {
206-
window.requestAnimationFrame(this.scrollStep);
207-
},
208-
);
209-
};
82+
handleScroll();
21083

211-
private scrollStep = () => {
212-
this.setState(
213-
({scrollPosition}) => {
214-
const delta = scrollPosition * DELTA_PERCENTAGE;
215-
return {
216-
scrollPosition: delta < DELTA_THRESHOLD ? 0 : scrollPosition - delta,
217-
};
218-
},
219-
() => {
220-
if (this.state.scrollPosition > 0) {
221-
window.requestAnimationFrame(this.scrollStep);
222-
} else {
223-
this.toggleLock(false);
224-
}
225-
},
226-
);
227-
};
84+
return () => {
85+
currentScrollArea.removeEventListener('scroll', handleScroll);
86+
window.removeEventListener('resize', handleResize);
87+
};
88+
}, [shadow]);
22889

229-
private toggleLock(shouldLock = true) {
230-
const {scrollArea} = this;
231-
if (scrollArea == null) {
232-
return;
233-
}
234-
235-
EVENTS_TO_LOCK.forEach((eventName) => {
236-
if (shouldLock) {
237-
scrollArea.addEventListener(eventName, prevent);
238-
} else {
239-
scrollArea.removeEventListener(eventName, prevent);
240-
}
241-
});
242-
}
243-
244-
private scrollToPosition = (scrollY: number) => {
245-
this.setState({scrollPosition: scrollY});
246-
};
247-
}
90+
const finalClassName = classNames(
91+
className,
92+
styles.Scrollable,
93+
vertical && styles.vertical,
94+
horizontal && styles.horizontal,
95+
topShadow && styles.hasTopShadow,
96+
bottomShadow && styles.hasBottomShadow,
97+
);
24898

249-
function prevent(evt: Event) {
250-
evt.preventDefault();
99+
return (
100+
<ScrollableContext.Provider value={scrollTo}>
101+
<StickyManagerContext.Provider value={stickyManager.current}>
102+
<div
103+
className={finalClassName}
104+
{...scrollable.props}
105+
{...rest}
106+
ref={scrollArea}
107+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
108+
tabIndex={focusable ? 0 : undefined}
109+
>
110+
{children}
111+
</div>
112+
</StickyManagerContext.Provider>
113+
</ScrollableContext.Provider>
114+
);
251115
}
252116

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

0 commit comments

Comments
 (0)