Skip to content

Commit b29eea9

Browse files
authored
Merge pull request #46 from koordinates/route-preloading
2 parents 42a7bc6 + eca4d27 commit b29eea9

File tree

10 files changed

+303
-25
lines changed

10 files changed

+303
-25
lines changed

package-lock.json

Lines changed: 9 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
"@testing-library/dom": "^8.14.0",
3535
"@testing-library/jest-dom": "^5.16.1",
3636
"@testing-library/react": "^13.4.0",
37-
"@testing-library/user-event": "^13.5.0",
37+
"@testing-library/user-event": "^14.4.3",
3838
"@types/history": "^4.7.7",
3939
"@types/jest": "^28.1.4",
4040
"@types/react": "^17.0.29",

src/routing/Link.spec.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { render } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { createMemoryHistory } from "history";
4+
import React from "react";
5+
import { z } from "zod";
6+
7+
import { delay } from "../utils";
8+
9+
import { Link } from "./Link";
10+
import { buildCreateRoute } from "./createRoute";
11+
12+
const hist = createMemoryHistory<{ meta?: unknown }>();
13+
const createRoute = buildCreateRoute(() => hist, "/");
14+
15+
const route = createRoute.simpleRoute()({
16+
event: "event",
17+
url: "/url/:param",
18+
paramsSchema: z.object({ param: z.string() }),
19+
});
20+
21+
describe("Link", () => {
22+
describe("preloading", () => {
23+
it("calls the route preload on mouseDown if preloadOnInteraction is true", async () => {
24+
route.preload = jest.fn();
25+
26+
const { getByText, rerender } = render(
27+
<Link to={route} params={{ param: "test" }}>
28+
Link
29+
</Link>
30+
);
31+
32+
await userEvent.click(getByText("Link"));
33+
expect(route.preload).not.toHaveBeenCalled();
34+
35+
rerender(
36+
<Link to={route} params={{ param: "test" }} preloadOnInteraction>
37+
Link
38+
</Link>
39+
);
40+
41+
await userEvent.click(getByText("Link"));
42+
expect(route.preload).toHaveBeenCalledWith({ params: { param: "test" } });
43+
});
44+
45+
it("calls the route preload on hover if preloadOnHoverMs is set", async () => {
46+
route.preload = jest.fn();
47+
48+
const { getByText, rerender } = render(
49+
<Link to={route} params={{ param: "test" }}>
50+
Link
51+
</Link>
52+
);
53+
54+
await userEvent.hover(getByText("Link"));
55+
expect(route.preload).not.toHaveBeenCalled();
56+
57+
rerender(
58+
<Link to={route} params={{ param: "test" }} preloadOnHoverMs={0}>
59+
Link
60+
</Link>
61+
);
62+
63+
await userEvent.hover(getByText("Link"));
64+
await delay(0);
65+
expect(route.preload).toHaveBeenCalledWith({ params: { param: "test" } });
66+
});
67+
68+
it("does not call the preload if the element isn't hovered for long enough", async () => {
69+
route.preload = jest.fn();
70+
71+
const { getByText } = render(
72+
<Link to={route} params={{ param: "test" }} preloadOnHoverMs={15}>
73+
Link
74+
</Link>
75+
);
76+
77+
await userEvent.hover(getByText("Link"));
78+
await delay(2);
79+
await userEvent.unhover(getByText("Link"));
80+
81+
await delay(15);
82+
expect(route.preload).not.toHaveBeenCalled();
83+
});
84+
85+
it("calls user supplied onMouse Down/Enter/Leave when preloading is not active", async () => {
86+
const onMouseDown = jest.fn();
87+
const onMouseEnter = jest.fn();
88+
const onMouseLeave = jest.fn();
89+
90+
const { getByText } = render(
91+
<Link
92+
to={route}
93+
params={{ param: "test" }}
94+
onMouseDown={onMouseDown}
95+
onMouseEnter={onMouseEnter}
96+
onMouseLeave={onMouseLeave}
97+
>
98+
Link
99+
</Link>
100+
);
101+
102+
await userEvent.hover(getByText("Link"));
103+
await userEvent.click(getByText("Link"));
104+
await userEvent.unhover(getByText("Link"));
105+
106+
expect(onMouseDown).toHaveBeenCalledTimes(1);
107+
expect(onMouseEnter).toHaveBeenCalledTimes(2);
108+
expect(onMouseLeave).toHaveBeenCalledTimes(1);
109+
});
110+
111+
it("calls user supplied onMouse Down/Enter/Leave when preloading is active", async () => {
112+
const onMouseDown = jest.fn();
113+
const onMouseEnter = jest.fn();
114+
const onMouseLeave = jest.fn();
115+
route.preload = jest.fn();
116+
117+
const { getByText } = render(
118+
<Link
119+
to={route}
120+
params={{ param: "test" }}
121+
onMouseDown={onMouseDown}
122+
onMouseEnter={onMouseEnter}
123+
onMouseLeave={onMouseLeave}
124+
preloadOnHoverMs={0}
125+
preloadOnInteraction
126+
>
127+
Link
128+
</Link>
129+
);
130+
131+
await userEvent.hover(getByText("Link"));
132+
await userEvent.click(getByText("Link"));
133+
await userEvent.unhover(getByText("Link"));
134+
135+
expect(onMouseDown).toHaveBeenCalledTimes(1);
136+
expect(onMouseEnter).toHaveBeenCalledTimes(2);
137+
expect(onMouseLeave).toHaveBeenCalledTimes(1);
138+
expect(route.preload).toHaveBeenCalledTimes(3);
139+
});
140+
});
141+
});

src/routing/Link.tsx

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export type LinkProps<
3333
* onClick works as normal, but if you return false from it the navigation will not happen
3434
*/
3535
onClick?: (e: React.MouseEvent<HTMLAnchorElement>) => boolean | void;
36+
preloadOnInteraction?: boolean;
37+
preloadOnHoverMs?: number;
3638
} & RouteArguments<TRouteParams, TRouteQuery, TRouteMeta> &
3739
Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick">;
3840

@@ -48,20 +50,58 @@ export function Link<TRoute extends AnyRoute>({
4850
to,
4951
children,
5052
testId,
53+
preloadOnHoverMs,
54+
preloadOnInteraction,
55+
onMouseDown: _onMouseDown,
56+
onMouseEnter: _onMouseEnter,
57+
onMouseLeave: _onMouseLeave,
5158
...rest
5259
}: LinkProps<TRoute>) {
5360
// @ts-ignore, these fields _might_ exist, so typechecking doesn't believe they exist
5461
// and everything that consumes params/query already checks for undefined
5562
const { params, query, meta, ...props } = rest;
5663

64+
let timeout: number | undefined;
5765
const href = useHref(to, params, query);
66+
const onMouseDown: React.MouseEventHandler<HTMLAnchorElement> | undefined =
67+
preloadOnInteraction
68+
? (e) => {
69+
_onMouseDown?.(e);
70+
71+
to.preload({ params, query, meta });
72+
}
73+
: undefined;
74+
const onMouseEnter: React.MouseEventHandler<HTMLAnchorElement> | undefined =
75+
preloadOnHoverMs !== undefined
76+
? (e) => {
77+
_onMouseEnter?.(e);
78+
79+
timeout = setTimeout(() => {
80+
to.preload({ params, query, meta });
81+
}, preloadOnHoverMs);
82+
}
83+
: undefined;
84+
const onMouseLeave: React.MouseEventHandler<HTMLAnchorElement> | undefined =
85+
preloadOnHoverMs !== undefined
86+
? (e) => {
87+
_onMouseLeave?.(e);
88+
89+
if (timeout !== undefined) {
90+
clearTimeout(timeout);
91+
}
92+
}
93+
: undefined;
5894

5995
return (
6096
<a
6197
{...props}
6298
href={href}
6399
data-testid={testId}
100+
onMouseDown={onMouseDown ?? _onMouseDown}
101+
onMouseEnter={onMouseEnter ?? _onMouseEnter}
102+
onMouseLeave={onMouseLeave ?? _onMouseLeave}
64103
onClick={(e) => {
104+
e.preventDefault();
65105
if (props.onClick?.(e) === false) {
66106
return;
67107
}
@@ -72,8 +112,6 @@ export function Link<TRoute extends AnyRoute>({
72112
return;
73113
}
74114

75-
e.preventDefault();
76-
77115
to.navigate({ params, query, meta });
78116
}}
79117
>

src/routing/createRoute/createRoute.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,5 +459,40 @@ describe("createRoute", () => {
459459
route.navigate({ meta: { doNotNotifyReactRouter: true } });
460460
});
461461
});
462+
463+
describe("route preload functions", () => {
464+
it("calls the route + parent route preload functions when preload is called", async () => {
465+
const parentPreload = jest.fn();
466+
const preload = jest.fn();
467+
468+
const parentRoute = createRoute.simpleRoute()({
469+
url: "/foo/:fooParam/",
470+
event: "GO_FOO",
471+
paramsSchema: Z.object({
472+
fooParam: Z.string(),
473+
}),
474+
preload: parentPreload,
475+
});
476+
const route = createRoute.simpleRoute(parentRoute)({
477+
url: "/bar/:barParam",
478+
event: "GO_BAR",
479+
paramsSchema: Z.object({
480+
barParam: Z.string(),
481+
}),
482+
preload,
483+
});
484+
485+
const args = {
486+
params: {
487+
barParam: "123",
488+
fooParam: "456",
489+
},
490+
};
491+
route.preload(args);
492+
493+
expect(parentPreload).toHaveBeenCalledWith(args);
494+
expect(preload).toHaveBeenCalledWith(args);
495+
});
496+
});
462497
});
463498
});

src/routing/createRoute/createRoute.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,17 @@ export type Route<TParams, TQuery, TEvent, TMeta> = {
125125
* url via History.push
126126
*/
127127
navigate: RouteArgumentFunctions<void, TParams, TQuery, TMeta>;
128+
/**
129+
* Preloads data required by the route. Passed in query/params/meta objects as required by the route
130+
*
131+
* Must be idempotent as it may be called multiple times
132+
*
133+
* Can be called on
134+
* * Mouse down on a Link
135+
* * Hovering on a Link
136+
* * When a route is matched
137+
*/
138+
preload: RouteArgumentFunctions<void, TParams, TQuery, TMeta>;
128139

129140
/**
130141
* Returns an event object for this route based on the supplied params/query/meta
@@ -165,6 +176,7 @@ export type AnyRoute = {
165176
navigate: any;
166177
getEvent: any;
167178
event: string;
179+
preload: any;
168180
basePath: string;
169181
history: () => XstateTreeHistory;
170182
parent?: AnyRoute;
@@ -309,6 +321,15 @@ export function buildCreateRoute(
309321
ResolveZodType<TQuerySchema>,
310322
MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta
311323
>;
324+
preload?: RouteArgumentFunctions<
325+
void,
326+
MergeRouteTypes<
327+
RouteParams<TBaseRoute>,
328+
ResolveZodType<TParamsSchema>
329+
>,
330+
ResolveZodType<TQuerySchema>,
331+
MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta>
332+
>;
312333
}): Route<
313334
MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>,
314335
ResolveZodType<TQuerySchema>,
@@ -383,6 +404,7 @@ export function buildCreateRoute(
383404
paramsSchema,
384405
querySchema,
385406
redirect,
407+
preload,
386408
}: {
387409
event: TEvent;
388410
paramsSchema?: TParamsSchema;
@@ -429,6 +451,15 @@ export function buildCreateRoute(
429451
ResolveZodType<TQuerySchema>,
430452
MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta>
431453
>;
454+
preload?: RouteArgumentFunctions<
455+
void,
456+
MergeRouteTypes<
457+
RouteParams<TBaseRoute>,
458+
ResolveZodType<TParamsSchema>
459+
>,
460+
ResolveZodType<TQuerySchema>,
461+
MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta>
462+
>;
432463
}): Route<
433464
MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>,
434465
ResolveZodType<TQuerySchema>,
@@ -541,6 +572,16 @@ export function buildCreateRoute(
541572
history: this.history(),
542573
});
543574
},
575+
// @ts-ignore :cry:
576+
preload(args) {
577+
const parentRoutes = getParentArray();
578+
579+
parentRoutes.forEach((route) => {
580+
route?.preload(args);
581+
});
582+
583+
preload?.(args);
584+
},
544585
};
545586
};
546587
},

0 commit comments

Comments
 (0)