Skip to content

Commit 1d4dde8

Browse files
authored
Modify prefetch delay and support focus and pointer events to prefetch (#169)
1 parent 349a3db commit 1d4dde8

File tree

6 files changed

+149
-15
lines changed

6 files changed

+149
-15
lines changed

examples/routing-with-resources/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const App = () => {
1919
routes={appRoutes}
2020
history={myHistory}
2121
basePath="/routing-with-resources"
22-
onPrefetch={({ route }) => console.log('Prefetcing route', route.name)}
22+
onPrefetch={({ route }) => console.log('Prefetching route', route.name)}
2323
>
2424
<RouteComponent />
2525
</Router>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { shallow } from 'enzyme';
2+
import React from 'react';
3+
import { useTimeout } from '../../../../../common/utils';
4+
5+
const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
6+
const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
7+
8+
const DEFAULT_DELAY = 1000;
9+
10+
const TestComponent = ({
11+
callback,
12+
delay = DEFAULT_DELAY,
13+
}: {
14+
callback: () => void;
15+
delay?: number;
16+
}) => {
17+
const { schedule, cancel } = useTimeout(delay);
18+
19+
return (
20+
<>
21+
<button id="schedule" onClick={() => schedule(callback)} />
22+
<button id="cancel" onClick={cancel} />
23+
</>
24+
);
25+
};
26+
27+
describe('useTimeout', () => {
28+
const mockCallback = jest.fn();
29+
30+
beforeEach(() => {
31+
mockCallback.mockClear();
32+
setTimeoutSpy.mockClear();
33+
clearTimeoutSpy.mockClear();
34+
});
35+
36+
afterEach(() => {
37+
jest.useRealTimers();
38+
});
39+
40+
it('calls setTimeout on schedule()', () => {
41+
const wrapper = shallow(<TestComponent callback={mockCallback} />);
42+
wrapper.find('#schedule').simulate('click');
43+
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
44+
});
45+
46+
it('calls clearTimeout on cancel()', () => {
47+
const wrapper = shallow(<TestComponent callback={mockCallback} />);
48+
wrapper.find('#cancel').simulate('click');
49+
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it('schedules a callback to be fired', () => {
53+
jest.useFakeTimers();
54+
const wrapper = shallow(<TestComponent callback={mockCallback} />);
55+
wrapper.find('#schedule').simulate('click');
56+
jest.runOnlyPendingTimers();
57+
expect(mockCallback).toHaveBeenCalledTimes(1);
58+
});
59+
60+
it('cancels a scheduled callback', () => {
61+
jest.useFakeTimers();
62+
const wrapper = shallow(<TestComponent callback={mockCallback} />);
63+
wrapper.find('#schedule').simulate('click');
64+
wrapper.find('#cancel').simulate('click');
65+
jest.runAllTimers();
66+
expect(mockCallback).not.toHaveBeenCalled();
67+
});
68+
69+
it('cancels a previously scheduled callback when schedule is called again', () => {
70+
const mockCallback2 = jest.fn();
71+
jest.useFakeTimers();
72+
const wrapper = shallow(<TestComponent callback={mockCallback} />);
73+
wrapper.find('#schedule').simulate('click');
74+
wrapper.setProps({ callback: mockCallback2 });
75+
wrapper.find('#schedule').simulate('click');
76+
77+
jest.runAllTimers();
78+
79+
expect(mockCallback).not.toHaveBeenCalled();
80+
expect(mockCallback2).toHaveBeenCalledTimes(1);
81+
});
82+
});

src/common/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,9 @@ export type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
267267
onClick?: (e: MouseEvent | KeyboardEvent) => void;
268268
onMouseEnter?: (e: MouseEvent) => void;
269269
onMouseLeave?: (e: MouseEvent) => void;
270+
onPointerDown?: (e: PointerEvent) => void;
271+
onFocus?: (e: FocusEvent) => void;
272+
onBlur?: (e: FocusEvent) => void;
270273
params?: MatchParams;
271274
query?: Query;
272275
prefetch?: false | 'hover' | 'mount';

src/common/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { generateLocationFromPath } from './generate-location';
88
export { createLegacyHistory } from './history';
99
export { isServerEnvironment } from './is-server-environment';
1010
export { findRouterContext, createRouterContext } from './router-context';
11+
export { useTimeout } from './use-timeout';

src/common/utils/use-timeout/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useCallback, useRef } from 'react';
2+
3+
export const useTimeout = (delay: number) => {
4+
const timeoutId = useRef<NodeJS.Timeout>();
5+
6+
const schedule = useCallback(
7+
(callback: () => void) => {
8+
if (timeoutId.current) {
9+
clearTimeout(timeoutId.current);
10+
}
11+
12+
timeoutId.current = setTimeout(callback, delay);
13+
},
14+
[delay]
15+
);
16+
17+
const cancel = useCallback(() => {
18+
clearTimeout(timeoutId.current);
19+
}, []);
20+
21+
return { schedule, cancel };
22+
};

src/ui/link/index.tsx

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import {
44
forwardRef,
55
useCallback,
66
useEffect,
7-
useRef,
87
useState,
98
MouseEvent,
109
KeyboardEvent,
10+
FocusEvent,
1111
} from 'react';
1212

1313
import { LinkProps, Route } from '../../common/types';
14+
import { useTimeout } from '../../common/utils';
1415
import {
1516
createRouterContext,
1617
generateLocationFromPath,
@@ -19,7 +20,7 @@ import { useRouterStoreStatic } from '../../controllers/router-store';
1920

2021
import { getValidLinkType, handleNavigation } from './utils';
2122

22-
const PREFETCH_DELAY = 300;
23+
const PREFETCH_DELAY = 225;
2324

2425
const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
2526
(
@@ -32,6 +33,9 @@ const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
3233
onClick = undefined,
3334
onMouseEnter = undefined,
3435
onMouseLeave = undefined,
36+
onPointerDown = undefined,
37+
onFocus = undefined,
38+
onBlur = undefined,
3539
type: linkType = 'a',
3640
params,
3741
query,
@@ -41,7 +45,7 @@ const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
4145
ref
4246
) => {
4347
const routerActions = useRouterStoreStatic()[1];
44-
const prefetchRef = useRef<NodeJS.Timeout>();
48+
const { schedule, cancel } = useTimeout(PREFETCH_DELAY);
4549

4650
const validLinkType = getValidLinkType(linkType);
4751
const [route, setRoute] = useState<Route | void>(() => {
@@ -69,8 +73,6 @@ const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
6973
: to;
7074

7175
const triggerPrefetch = useCallback(() => {
72-
prefetchRef.current = undefined;
73-
7476
// ignore if async route not ready yet
7577
if (typeof to !== 'string' && !route) return;
7678

@@ -84,12 +86,12 @@ const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
8486
}, [route, linkDestination, routerActions]);
8587

8688
useEffect(() => {
87-
let timeout: NodeJS.Timeout;
88-
if (prefetch === 'mount')
89-
timeout = setTimeout(triggerPrefetch, PREFETCH_DELAY);
89+
if (prefetch === 'mount') {
90+
schedule(triggerPrefetch);
91+
}
9092

91-
return () => clearTimeout(timeout);
92-
}, [prefetch, triggerPrefetch]);
93+
return cancel;
94+
}, [prefetch, schedule, cancel, triggerPrefetch]);
9395

9496
const handleLinkPress = (e: MouseEvent | KeyboardEvent) =>
9597
handleNavigation(e, {
@@ -103,19 +105,40 @@ const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
103105

104106
const handleMouseEnter = (e: MouseEvent) => {
105107
if (prefetch === 'hover') {
106-
prefetchRef.current = setTimeout(triggerPrefetch, PREFETCH_DELAY);
108+
schedule(triggerPrefetch);
107109
}
108110
onMouseEnter && onMouseEnter(e);
109111
};
110112

111113
const handleMouseLeave = (e: MouseEvent) => {
112-
if (prefetch === 'hover' && prefetchRef.current) {
113-
clearTimeout(prefetchRef.current);
114-
prefetchRef.current = undefined;
114+
if (prefetch === 'hover') {
115+
cancel();
115116
}
116117
onMouseLeave && onMouseLeave(e);
117118
};
118119

120+
const handleFocus = (e: FocusEvent<HTMLAnchorElement>) => {
121+
if (prefetch === 'hover') {
122+
schedule(triggerPrefetch);
123+
}
124+
onFocus && onFocus(e);
125+
};
126+
127+
const handleBlur = (e: FocusEvent<HTMLAnchorElement>) => {
128+
if (prefetch === 'hover') {
129+
cancel();
130+
}
131+
onBlur && onBlur(e);
132+
};
133+
134+
const handlePointerDown = (e: PointerEvent) => {
135+
if (prefetch === 'hover') {
136+
cancel();
137+
triggerPrefetch();
138+
}
139+
onPointerDown && onPointerDown(e);
140+
};
141+
119142
return createElement(
120143
validLinkType,
121144
{
@@ -126,6 +149,9 @@ const Link = forwardRef<HTMLButtonElement | HTMLAnchorElement, LinkProps>(
126149
onKeyDown: handleLinkPress,
127150
onMouseEnter: handleMouseEnter,
128151
onMouseLeave: handleMouseLeave,
152+
onFocus: handleFocus,
153+
onBlur: handleBlur,
154+
onPointerDown: handlePointerDown,
129155
ref,
130156
},
131157
children

0 commit comments

Comments
 (0)