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: add useNextLinkProps #15

Merged
merged 9 commits into from
Jan 21, 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
1 change: 1 addition & 0 deletions docs/src/pages/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"type": "separator",
"title": "Extensions"
},
"use-next-link": {},
"use-next-pathname": {},
"use-react-router-enable-concurrent-navigation": {},
"use-react-router-is-match": {}
Expand Down
49 changes: 49 additions & 0 deletions docs/src/pages/use-next-link.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: useNextLink (Next.js App Router)
---

# useNextLink (Next.js App Router)

import ExportMetaInfo from '../components/export-meta-info';

<ExportMetaInfo />

Build your own `next/link` component with all Next.js features you love (client-side navigation between routes, prefetching), plus React's `isPending` transition state to build "navigating..." animation.

import { Callout } from 'nextra/components'

<Callout type="warning" emoji="⚠️">
The `useNextLink` hook is your last resort. You should always prefer `next/link` when possible. This hook can only be used with Next.js App Router, and should only be used when using the `isPending` transition state to build "navigating..." animation.
</Callout>

```tsx
'use client';

import { useRef } from 'react';
import { unstable_useNextLink as useNextLink } from 'foxact/use-next-link';

export default function Page() {
const ref = useRef<HTMLAnchorElement>(null);

const [isPending, linkProps] = useNextLinkProps(
// `href`
'/about',
// optional, your usual next/link prop like "onClick", "prefetch", "replace" and "scroll" should go here.
{
// If you want to attach your ref to the link, you should put your "ref" here instead of doing <a ref={ref} {...linkProps} />
ref
}
);

return (
<div>
{isPending && <div>Navigating...</div>}
{/** You can only pass non-"next/link" prop directly to the <a /> */}
{/** All "next/link" prop and "ref" should be passed to useNextLink() */}
<a className="link" {...linkProps}>
About
</a>
</div>
);
}
```
231 changes: 231 additions & 0 deletions src/use-next-link/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import 'client-only';

import type { UrlObject } from 'url';
import type { LinkProps } from 'next/link';
import { useCallback, useEffect, useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';

import { formatUrl } from 'next/dist/shared/lib/router/utils/format-url';
import { useIntersection } from '../use-intersection';

import type {
PrefetchOptions as AppRouterPrefetchOptions
} from 'next/dist/shared/lib/app-router-context.shared-runtime';
import type { PrefetchKind } from 'next/dist/client/components/router-reducer/router-reducer-types';

export interface UseNextLinkOptions extends Omit<LinkProps,
| 'as' // Next.js App Router doesn't encourage to use `as` prop (it is only retained for the legacy puprpose)
| 'href' // `href` is passed in as a separate argument (for easier usage)
| 'legacyBehavior' // Dropping `legacyBehavior` prop can simplify things a lot
| 'shallow' // `shallow` is only for Next.js Pages Router
| 'passHref' // Also `legacyBehavior`
| 'locale' // For Next.js Pages Router's built-in i18n only, Next.js App Router doesn't implement i18n yet
> {
ref?: React.RefObject<HTMLAnchorElement> | React.RefCallback<HTMLAnchorElement> | null
}

export interface UseNextLinkReturnProps extends Partial<JSX.IntrinsicElements['a']> {
ref: React.RefCallback<HTMLAnchorElement>,
onTouchStart: React.TouchEventHandler<HTMLAnchorElement>,
onMouseEnter: React.MouseEventHandler<HTMLAnchorElement>,
onClick: React.MouseEventHandler<HTMLAnchorElement>,
href?: string
}

const isModifiedEvent = (event: React.MouseEvent<HTMLAnchorElement>) => {
const eventTarget = event.currentTarget;
const target = eventTarget.getAttribute('target');
return (
(target && target !== '_self')
|| eventTarget.download
|| event.metaKey
|| event.ctrlKey
|| event.shiftKey
|| event.altKey // triggers resource download
|| (event.nativeEvent && event.nativeEvent.which === 2)
);
};

// https://github.com/vercel/next.js/blob/39589ff35003ba73f92b7f7b349b3fdd3458819f/packages/next/src/client/components/router-reducer/router-reducer-types.ts#L148
const PREFETCH_APPROUTER_AUTO = 'auto';
const PREFETCH_APPROUTER_FULL = 'full';

const prefetch = (
router: ReturnType<typeof useRouter>,
href: string,
options: AppRouterPrefetchOptions
) => {
if (typeof window === 'undefined') {
return;
}

// Prefetch the RSC if asked (only in the client)
// We need to handle a prefetch error here since we may be
// loading with priority which can reject but we don't
// want to force navigation since this is only a prefetch
Promise.resolve(router.prefetch(href, options)).catch((err) => {
if (process.env.NODE_ENV !== 'production') {
// rethrow to show invalid URL errors
throw err;
}
});
};

/** @see https://foxact.skk.moe/use-next-link */
const useNextLink = (
hrefProp: string | UrlObject,
{
prefetch: prefetchProp,
ref,
onClick,
onMouseEnter,
onTouchStart,
scroll: routerScroll = true,
replace = false,
...restProps // Record<string, never>
}: UseNextLinkOptions
): [isPending: boolean, linkProps: UseNextLinkReturnProps] => {
// Type guard to make sure there is no more props left in restProps
if (process.env.NODE_ENV === 'development') {
const _: Record<string, never> = restProps;
}

/**
* The possible states for prefetch are:
* - null: this is the default "auto" mode, where we will prefetch partially if the link is in the viewport
* - true: we will prefetch if the link is visible and prefetch the full page, not just partially
* - false: we will not prefetch if in the viewport at all
*/
const appPrefetchKind = prefetchProp == null ? PREFETCH_APPROUTER_AUTO : PREFETCH_APPROUTER_FULL;
const prefetchEnabled = prefetchProp !== false;

const router = useRouter();

const [isPending, startTransition] = useTransition();

const [setIntersectionRef, isVisible, resetVisible] = useIntersection({
rootMargin: '200px'
});

const resolvedHref = useMemo(() => (typeof hrefProp === 'string' ? hrefProp : formatUrl(hrefProp)), [hrefProp]);
const [previousResolvedHref, setPreviousResolvedHref] = useState<string>(resolvedHref);

if (previousResolvedHref !== resolvedHref) {
// It is safe to set the state during render, as long as it won't trigger an infinite render loop.
// React will render the component with the current state, then throws away the render result
// and immediately re-executes the component function with the updated state.
setPreviousResolvedHref(resolvedHref);
resetVisible();
}

// Prefetch the URL if we haven't already and it's visible.
useEffect(() => {
// in dev, we only prefetch on hover to avoid wasting resources as the prefetch will trigger compiling the page.
if (process.env.NODE_ENV !== 'production') {
return;
}

// If we don't need to prefetch the URL, don't do prefetch.
if (!isVisible || !prefetchEnabled) {
return;
}

// Prefetch the URL.
prefetch(
router,
resolvedHref,
{
kind: appPrefetchKind as PrefetchKind
}
);
}, [appPrefetchKind, isVisible, prefetchEnabled, resolvedHref, router]);

const childProps: UseNextLinkReturnProps = {
ref: useCallback<React.RefCallback<HTMLAnchorElement>>((el: HTMLAnchorElement | null) => {
// track the element visibility
setIntersectionRef(el);

if (typeof ref === 'function') {
ref(el);
} else if (ref && el) {
// We are acting on React behalf to assign the passed-in ref
(ref as React.MutableRefObject<HTMLAnchorElement>).current = el;
}
}, [ref, setIntersectionRef]),
onClick: useCallback((e) => {
if (typeof onClick === 'function') {
onClick(e);
}
if (e.defaultPrevented) {
return;
}

const { nodeName } = e.currentTarget;
// anchors inside an svg have a lowercase nodeName
if (
nodeName.toUpperCase() === 'A'
&& isModifiedEvent(e)
) {
// app-router supports external urls out of the box
// ignore click for browser’s default behavior
return;
}

e.preventDefault();

startTransition(() => {
router[replace ? 'replace' : 'push'](resolvedHref, { scroll: routerScroll });
});
}, [onClick, replace, resolvedHref, router, routerScroll]),
onMouseEnter: useCallback((e) => {
if (typeof onMouseEnter === 'function') {
onMouseEnter(e);
}
// Always disable prefetching during the development
if (process.env.NODE_ENV === 'development') {
return;
}
if (!prefetchEnabled) {
return;
}

// Prefetch the URL.
prefetch(
router,
resolvedHref,
{
kind: appPrefetchKind as PrefetchKind
}
);
}, [appPrefetchKind, onMouseEnter, prefetchEnabled, resolvedHref, router]),
onTouchStart: useCallback((e) => {
if (typeof onTouchStart === 'function') {
onTouchStart(e);
}
// Always disable prefetching during the development
if (process.env.NODE_ENV === 'development') {
return;
}
if (!prefetchEnabled) {
return;
}

// Prefetch the URL.
prefetch(
router,
resolvedHref,
{
kind: appPrefetchKind as PrefetchKind
}
);
}, [appPrefetchKind, onTouchStart, prefetchEnabled, resolvedHref, router]),
...restProps
};

return [
isPending,
childProps
] as const;
};

export const unstable_useNextLink = useNextLink;