Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Commit

Permalink
Merge pull request #2545 from Shopify/prefetch-sensitivity
Browse files Browse the repository at this point in the history
[proposal]: Add sensitivity check to prefetcher
  • Loading branch information
devisscher authored Mar 8, 2023
2 parents c9b014c + 0c260a6 commit 90e1d77
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-walls-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/react-async': minor
---

Add a sensitivity check to the Prefetcher component
41 changes: 39 additions & 2 deletions packages/react-async/src/Prefetcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,19 @@ interface NavigatorWithConnection extends Navigator {
}

export const INTENTION_DELAY_MS = 150;
export const SENSITIVITY = 15;

class ConnectedPrefetcher extends React.PureComponent<Props, State> {
state: State = {};
private timeout?: ReturnType<typeof setTimeout>;
private timeoutUrl?: URL;
private prefetchAgressively = shouldPrefetchAggressively();
// Initial position of the mouse
private iX = 0;
private iY = 0;
// Final position of the mouse
private fX = 0;
private fY = 0;

render() {
const {url} = this.state;
Expand Down Expand Up @@ -57,6 +64,11 @@ class ConnectedPrefetcher extends React.PureComponent<Props, State> {
event="focusout"
handler={this.handlePointerLeave}
/>
<EventListener
passive
event="mousemove"
handler={this.handleMouseMove}
/>
</>
) : null;

Expand All @@ -78,6 +90,11 @@ class ConnectedPrefetcher extends React.PureComponent<Props, State> {
);
}

private handleMouseMove = ({clientX, clientY}: MouseEvent) => {
this.iX = clientX;
this.iY = clientY;
};

private handlePressStart = ({target}: MouseEvent) => {
this.clearTimeout();

Expand All @@ -92,6 +109,20 @@ class ConnectedPrefetcher extends React.PureComponent<Props, State> {
}
};

private compare = (url: URL | undefined) => {
const {iX, iY} = this;
this.clearTimeout();
// Calculate the change of the mouse position
// If it is smaller than the sensitivity, we can assume that the user is intending on visiting the link
if (Math.hypot(this.fX - iX, this.fY - iY) < SENSITIVITY) {
this.setState({url});
} else {
this.fX = iX;
this.fY = iY;
this.timeout = setTimeout(() => this.compare(url), INTENTION_DELAY_MS);
}
};

private handlePointerLeave = ({
target,
relatedTarget,
Expand Down Expand Up @@ -127,7 +158,8 @@ class ConnectedPrefetcher extends React.PureComponent<Props, State> {
}
};

private handlePointerEnter = ({target}: MouseEvent | FocusEvent) => {
private handlePointerEnter = (event: MouseEvent | FocusEvent) => {
const {target} = event;
if (target == null) {
return;
}
Expand All @@ -148,9 +180,14 @@ class ConnectedPrefetcher extends React.PureComponent<Props, State> {
}

this.timeoutUrl = url;
// If the event is a mouse event, record initial mouse position upon entering the element
this.timeout = setTimeout(() => {
this.clearTimeout();
this.setState({url});
if ('clientX' in event && 'clientY' in event) {
this.compare(url);
} else {
this.setState({url});
}
}, INTENTION_DELAY_MS);
};

Expand Down
71 changes: 60 additions & 11 deletions packages/react-async/src/tests/Prefetcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,27 +41,74 @@ describe('<Prefetch />', () => {
expect(prefetcher).not.toContainReactComponent(MockComponent);
});

it('prefetches a component when hovering over an element with a matching href for enough time', () => {
it('prefetches a component when hovering over and mousemovement is below sensitivity', () => {
const manager = createPrefetchManager([
{render: () => <MockComponent />, path},
]);
const mockEl = mockElement(`<a href="${path}"></a>`);
const prefetcher = mount(
<PrefetchContext.Provider value={manager}>
<Prefetcher />
</PrefetchContext.Provider>,
<div className="wrapper">
<PrefetchContext.Provider value={manager}>
<Prefetcher />
</PrefetchContext.Provider>
</div>,
);

triggerListener(prefetcher, 'mouseover', {
target: mockElement(`<a href="${path}"></a>`),
triggerListener(
prefetcher,
'mouseover',
{
target: mockEl,
},
{clientX: 6, clientY: 3},
);
prefetcher.act(() => {
triggerListener(
prefetcher,
'mousemove',
{
target: mockEl,
},
{clientX: 5, clientY: 2},
);
clock.tick(INTENTION_DELAY_MS + 1);
});

expect(prefetcher).not.toContainReactComponent(MockComponent);
expect(prefetcher).toContainReactComponent(MockComponent);
});

it('does not prefetch a component when hovering over and mousemovement is above sensitivity', () => {
const manager = createPrefetchManager([
{render: () => <MockComponent />, path},
]);
const mockEl = mockElement(`<a href="${path}"></a>`);
const prefetcher = mount(
<div className="wrapper">
<PrefetchContext.Provider value={manager}>
<Prefetcher />
</PrefetchContext.Provider>
</div>,
);
triggerListener(
prefetcher,
'mouseover',
{
target: mockEl,
},
{clientX: 6, clientY: 3},
);
prefetcher.act(() => {
triggerListener(
prefetcher,
'mousemove',
{
target: mockEl,
},
{clientX: 10, clientY: 25},
);
clock.tick(INTENTION_DELAY_MS + 1);
});

expect(prefetcher).toContainReactComponent(MockComponent);
expect(prefetcher).not.toContainReactComponent(MockComponent);
});

it('prefetches a component when focusing on an element with a matching href for enough time', () => {
Expand Down Expand Up @@ -250,14 +297,16 @@ type EventName =
| 'mouseover'
| 'mouseout'
| 'focusin'
| 'focusout';
| 'focusout'
| 'mousemove';

function triggerListener(
prefetcher: Root<unknown>,
event: EventName,
arg: Partial<FocusEvent>,
options?: Partial<MouseEvent>,
) {
getListener(prefetcher, event)!.trigger('handler', arg);
getListener(prefetcher, event)!.trigger('handler', {...arg, ...options});
}

function getListener(prefetcher: Root<unknown>, event: EventName) {
Expand Down

0 comments on commit 90e1d77

Please sign in to comment.