Skip to content

Commit

Permalink
feat: improve link with prefetch (#12844)
Browse files Browse the repository at this point in the history
* feat: improve link with prefetch

* feat: support preload=render

* feat: support preload=viewport

* chore: throw error when prefetch props is invalid

* chore: docs and default prefetch config

* ci: fix
  • Loading branch information
sorrycc authored Dec 12, 2024
1 parent 13588c7 commit a59824a
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 31 deletions.
5 changes: 3 additions & 2 deletions docs/docs/docs/api/api.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ Type definition is as follows:

```ts
declare function Link(props: {
prefetch?: boolean;
prefetch?: boolean | 'intent' | 'render' | 'viewport' | 'none';
prefetchTimeout?: number;
to: string | Partial<{ pathname: string; search: string; hash: string }>;
replace?: boolean;
state?: any;
Expand All @@ -207,7 +208,7 @@ function IndexPage({ user }) {

`<Link to>` supports relative path navigation; `<Link reloadDocument>` does not do routing navigation and is equivalent to the jump behavior of `<a href>`.

If `prefetch` is enabled, then when the user hovers over the component, Umi will automatically start preloading the component js files and data for the routing jump. (Note: Use this feature when `routePrefetch` and `manifest` are enabled)
If `prefetch` is enabled, then when the user hovers over the component, Umi will automatically start preloading the component js files and data for the routing jump. (Note: Use this feature when `routePrefetch` is enabled)

### matchPath

Expand Down
5 changes: 3 additions & 2 deletions docs/docs/docs/api/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ unlisten();

```ts
declare function Link(props: {
prefetch?: boolean;
prefetch?: boolean | 'intent' | 'render' | 'viewport' | 'none';
prefetchTimeout?: number;
to: string | Partial<{ pathname: string; search: string; hash: string }>;
replace?: boolean;
state?: any;
Expand All @@ -206,7 +207,7 @@ function IndexPage({ user }) {

`<Link to>` 支持相对路径跳转;`<Link reloadDocument>` 不做路由跳转,等同于 `<a href>` 的跳转行为。

若开启了 `prefetch` 则当用户将鼠标放到该组件上方时,Umi 就会自动开始进行跳转路由的组件 js 文件和数据预加载。(注:使用此功能请同时开启 `routePrefetch``manifest` 配置)
若开启了 `prefetch` 则当用户将鼠标放到该组件上方时,Umi 就会自动开始进行跳转路由的组件 js 文件和数据预加载。(注:使用此功能请开启 `routePrefetch` 配置)

### matchPath

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/docs/api/config.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -1304,7 +1304,7 @@ Configure how routes are loaded. Setting moduleType to 'cjs' will load route com
## routePrefetch
- Type: `boolean`
- Type: `{ defaultPrefetch: 'none' | 'intent' | 'render' | 'viewport', defaultPrefetchTimeout: number } | false`
- Default: `false`
Enable route preloading functionality.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/docs/api/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1311,7 +1311,7 @@ proxy: {

## routePrefetch

- 类型:`boolean`
- 类型:`{ defaultPrefetch: 'none' | 'intent' | 'render' | 'viewport', defaultPrefetchTimeout: number } | false`
- 默认值:`false`

启用路由预加载功能。
Expand Down
18 changes: 16 additions & 2 deletions packages/preset-umi/src/features/routePrefetch/routePrefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@ export default (api: IApi) => {
api.describe({
config: {
schema({ zod }) {
return zod.object({});
return zod.object({
defaultPrefetch: zod
.enum(['none', 'intent', 'render', 'viewport'])
.optional(),
defaultPrefetchTimeout: zod.number().optional(),
});
},
},
enableBy: api.EnableBy.config,
});

api.addEntryCodeAhead(() => {
return `if(typeof window !== 'undefined') window.__umi_route_prefetch__ = true;`;
return `if(typeof window !== 'undefined') window.__umi_route_prefetch__ =
{
defaultPrefetch: ${JSON.stringify(
api.config.routePrefetch.defaultPrefetch || 'none',
)},
defaultPrefetchTimeout: ${JSON.stringify(
api.config.routePrefetch.defaultPrefetchTimeout || 50,
)},
};
`;
});
};
130 changes: 107 additions & 23 deletions packages/renderer-react/src/link.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,111 @@
import React, { PropsWithChildren } from 'react';
import React, { PropsWithChildren, useLayoutEffect } from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { useAppData } from './appContext';
import { useIntersectionObserver } from './useIntersectionObserver';

export function LinkWithPrefetch(
props: PropsWithChildren<
{
prefetch?: boolean;
} & LinkProps &
React.RefAttributes<HTMLAnchorElement>
>,
) {
const { prefetch, ...linkProps } = props;
const appData = useAppData();
const to = typeof props.to === 'string' ? props.to : props.to?.pathname;
// compatible with old code
// which to might be undefined
if (!to) return null;
return (
<Link
onMouseEnter={() => prefetch && to && appData.preloadRoute?.(to)}
{...linkProps}
>
{props.children}
</Link>
);
function useForwardedRef<T>(ref?: React.ForwardedRef<T>) {
const innerRef = React.useRef<T>(null);
React.useEffect(() => {
if (!ref) return;
if (typeof ref === 'function') {
ref(innerRef.current);
} else {
ref.current = innerRef.current;
}
});
return innerRef;
}

export const LinkWithPrefetch = React.forwardRef(
(
props: PropsWithChildren<
{
prefetch?: boolean | 'intent' | 'render' | 'viewport' | 'none';
prefetchTimeout?: number;
} & LinkProps &
React.RefAttributes<HTMLAnchorElement>
>,
forwardedRef,
) => {
const { prefetch: prefetchProp, ...linkProps } = props;
const { defaultPrefetch, defaultPrefetchTimeout } = (typeof window !==
'undefined' && // @ts-ignore
window.__umi_route_prefetch__) || {
defaultPrefetch: 'none',
defaultPrefetchTimeout: 50,
};

const prefetch =
(prefetchProp === true
? 'intent'
: prefetchProp === false
? 'none'
: prefetchProp) || defaultPrefetch;
if (!['intent', 'render', 'viewport', 'none'].includes(prefetch)) {
throw new Error(
`Invalid prefetch value ${prefetch} found in Link component`,
);
}
const appData = useAppData();
const to = typeof props.to === 'string' ? props.to : props.to?.pathname;
const hasRenderFetched = React.useRef(false);
const ref = useForwardedRef(forwardedRef);
// prefetch intent
const handleMouseEnter = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (prefetch !== 'intent') return;
const eventTarget = (e.target || {}) as HTMLElement & {
preloadTimeout?: NodeJS.Timeout | null;
};
if (eventTarget.preloadTimeout) return;
eventTarget.preloadTimeout = setTimeout(() => {
eventTarget.preloadTimeout = null;
appData.preloadRoute?.(to!);
}, props.prefetchTimeout || defaultPrefetchTimeout);
};
const handleMouseLeave = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (prefetch !== 'intent') return;
const eventTarget = (e.target || {}) as HTMLElement & {
preloadTimeout?: NodeJS.Timeout | null;
};
if (eventTarget.preloadTimeout) {
clearTimeout(eventTarget.preloadTimeout);
eventTarget.preloadTimeout = null;
}
};

// prefetch render
useLayoutEffect(() => {
if (prefetch === 'render' && !hasRenderFetched.current) {
appData.preloadRoute?.(to!);
hasRenderFetched.current = true;
}
}, [prefetch, to]);

// prefetch viewport
useIntersectionObserver(
ref as React.RefObject<HTMLAnchorElement>,
(entry) => {
if (entry?.isIntersecting) {
appData.preloadRoute?.(to!);
}
},
{ rootMargin: '100px' },
{ disabled: prefetch !== 'viewport' },
);

// compatible with old code
// which to might be undefined
if (!to) return null;

return (
<Link
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={ref as React.RefObject<HTMLAnchorElement>}
{...linkProps}
>
{props.children}
</Link>
);
},
);
33 changes: 33 additions & 0 deletions packages/renderer-react/src/useIntersectionObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React from 'react';

export function useIntersectionObserver<T extends Element>(
ref: React.RefObject<T>,
callback: (entry: IntersectionObserverEntry | undefined) => void,
intersectionObserverOptions: IntersectionObserverInit = {},
options: { disabled?: boolean } = {},
): IntersectionObserver | null {
// check if IntersectionObserver is available
if (typeof IntersectionObserver !== 'function') return null;

const isIntersectionObserverAvailable = React.useRef(
typeof IntersectionObserver === 'function',
);
const observerRef = React.useRef<IntersectionObserver | null>(null);
React.useEffect(() => {
if (
!ref.current ||
!isIntersectionObserverAvailable.current ||
options.disabled
) {
return;
}
observerRef.current = new IntersectionObserver(([entry]) => {
callback(entry);
}, intersectionObserverOptions);
observerRef.current.observe(ref.current);
return () => {
observerRef.current?.disconnect();
};
}, [callback, intersectionObserverOptions, options.disabled, ref]);
return observerRef.current;
}

0 comments on commit a59824a

Please sign in to comment.