Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve link with prefetch #12844

Merged
merged 6 commits into from
Dec 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Loading