Repository: vercel/next.js
Pull request: Remove forwardRef from Link in App Router

App Router is running React >19, so we can remove forwardRef and receive the ref as a prop instead.

The Pages Router version of Link is unchanged to support older React versions.

Changed files:
filename: packages/next/src/client/app-dir/link.tsx
patch: @@ -296,11 +296,6 @@ function linkClicked(
   React.startTransition(navigate)
 }
 
-type LinkPropsReal = React.PropsWithChildren<
-  Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> &
-    LinkProps
->
-
 function formatStringOrUrl(urlObjOrString: UrlObject | string): string {
   if (typeof urlObjOrString === 'string') {
     return urlObjOrString
@@ -319,367 +314,367 @@ function formatStringOrUrl(urlObjOrString: UrlObject | string): string {
  *
  * @see https://nextjs.org/docs/app/api-reference/components/link
  */
-const Link = React.forwardRef<HTMLAnchorElement, LinkPropsReal>(
-  function LinkComponent(props, forwardedRef) {
-    let children: React.ReactNode
-
-    const {
-      href: hrefProp,
-      as: asProp,
-      children: childrenProp,
-      prefetch: prefetchProp = null,
-      passHref,
-      replace,
-      shallow,
-      scroll,
-      onClick,
-      onMouseEnter: onMouseEnterProp,
-      onTouchStart: onTouchStartProp,
-      legacyBehavior = false,
-      onNavigate,
-      ...restProps
-    } = props
-
-    children = childrenProp
-
-    if (
-      legacyBehavior &&
-      (typeof children === 'string' || typeof children === 'number')
-    ) {
-      children = <a>{children}</a>
-    }
+export default function LinkComponent(
+  props: LinkProps & {
+    children: React.ReactNode
+    ref: React.Ref<HTMLAnchorElement>
+  }
+) {
+  let children: React.ReactNode
+
+  const {
+    href: hrefProp,
+    as: asProp,
+    children: childrenProp,
+    prefetch: prefetchProp = null,
+    passHref,
+    replace,
+    shallow,
+    scroll,
+    onClick,
+    onMouseEnter: onMouseEnterProp,
+    onTouchStart: onTouchStartProp,
+    legacyBehavior = false,
+    onNavigate,
+    ref: forwardedRef,
+    ...restProps
+  } = props
+
+  children = childrenProp
 
-    const router = React.useContext(AppRouterContext)
-
-    const prefetchEnabled = prefetchProp !== false
-    /**
-     * 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 ? PrefetchKind.AUTO : PrefetchKind.FULL
-
-    if (process.env.NODE_ENV !== 'production') {
-      function createPropError(args: {
-        key: string
-        expected: string
-        actual: string
-      }) {
-        return new Error(
-          `Failed prop type: The prop \`${args.key}\` expects a ${args.expected} in \`<Link>\`, but got \`${args.actual}\` instead.` +
-            (typeof window !== 'undefined'
-              ? "\nOpen your browser's console to view the Component stack trace."
-              : '')
-        )
-      }
+  if (
+    legacyBehavior &&
+    (typeof children === 'string' || typeof children === 'number')
+  ) {
+    children = <a>{children}</a>
+  }
 
-      // TypeScript trick for type-guarding:
-      const requiredPropsGuard: Record<LinkPropsRequired, true> = {
-        href: true,
-      } as const
-      const requiredProps: LinkPropsRequired[] = Object.keys(
-        requiredPropsGuard
-      ) as LinkPropsRequired[]
-      requiredProps.forEach((key: LinkPropsRequired) => {
-        if (key === 'href') {
-          if (
-            props[key] == null ||
-            (typeof props[key] !== 'string' && typeof props[key] !== 'object')
-          ) {
-            throw createPropError({
-              key,
-              expected: '`string` or `object`',
-              actual: props[key] === null ? 'null' : typeof props[key],
-            })
-          }
-        } else {
-          // TypeScript trick for type-guarding:
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const _: never = key
-        }
-      })
+  const router = React.useContext(AppRouterContext)
 
-      // TypeScript trick for type-guarding:
-      const optionalPropsGuard: Record<LinkPropsOptional, true> = {
-        as: true,
-        replace: true,
-        scroll: true,
-        shallow: true,
-        passHref: true,
-        prefetch: true,
-        onClick: true,
-        onMouseEnter: true,
-        onTouchStart: true,
-        legacyBehavior: true,
-        onNavigate: true,
-      } as const
-      const optionalProps: LinkPropsOptional[] = Object.keys(
-        optionalPropsGuard
-      ) as LinkPropsOptional[]
-      optionalProps.forEach((key: LinkPropsOptional) => {
-        const valType = typeof props[key]
-
-        if (key === 'as') {
-          if (props[key] && valType !== 'string' && valType !== 'object') {
-            throw createPropError({
-              key,
-              expected: '`string` or `object`',
-              actual: valType,
-            })
-          }
-        } else if (
-          key === 'onClick' ||
-          key === 'onMouseEnter' ||
-          key === 'onTouchStart' ||
-          key === 'onNavigate'
-        ) {
-          if (props[key] && valType !== 'function') {
-            throw createPropError({
-              key,
-              expected: '`function`',
-              actual: valType,
-            })
-          }
-        } else if (
-          key === 'replace' ||
-          key === 'scroll' ||
-          key === 'shallow' ||
-          key === 'passHref' ||
-          key === 'prefetch' ||
-          key === 'legacyBehavior'
-        ) {
-          if (props[key] != null && valType !== 'boolean') {
-            throw createPropError({
-              key,
-              expected: '`boolean`',
-              actual: valType,
-            })
-          }
-        } else {
-          // TypeScript trick for type-guarding:
-          // eslint-disable-next-line @typescript-eslint/no-unused-vars
-          const _: never = key
-        }
-      })
+  const prefetchEnabled = prefetchProp !== false
+  /**
+   * 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 ? PrefetchKind.AUTO : PrefetchKind.FULL
+
+  if (process.env.NODE_ENV !== 'production') {
+    function createPropError(args: {
+      key: string
+      expected: string
+      actual: string
+    }) {
+      return new Error(
+        `Failed prop type: The prop \`${args.key}\` expects a ${args.expected} in \`<Link>\`, but got \`${args.actual}\` instead.` +
+          (typeof window !== 'undefined'
+            ? "\nOpen your browser's console to view the Component stack trace."
+            : '')
+      )
     }
 
-    if (process.env.NODE_ENV !== 'production') {
-      if (props.locale) {
-        warnOnce(
-          'The `locale` prop is not supported in `next/link` while using the `app` router. Read more about app router internalization: https://nextjs.org/docs/app/building-your-application/routing/internationalization'
-        )
-      }
-      if (!asProp) {
-        let href: string | undefined
-        if (typeof hrefProp === 'string') {
-          href = hrefProp
-        } else if (
-          typeof hrefProp === 'object' &&
-          typeof hrefProp.pathname === 'string'
+    // TypeScript trick for type-guarding:
+    const requiredPropsGuard: Record<LinkPropsRequired, true> = {
+      href: true,
+    } as const
+    const requiredProps: LinkPropsRequired[] = Object.keys(
+      requiredPropsGuard
+    ) as LinkPropsRequired[]
+    requiredProps.forEach((key: LinkPropsRequired) => {
+      if (key === 'href') {
+        if (
+          props[key] == null ||
+          (typeof props[key] !== 'string' && typeof props[key] !== 'object')
         ) {
-          href = hrefProp.pathname
+          throw createPropError({
+            key,
+            expected: '`string` or `object`',
+            actual: props[key] === null ? 'null' : typeof props[key],
+          })
+        }
+      } else {
+        // TypeScript trick for type-guarding:
+        // eslint-disable-next-line @typescript-eslint/no-unused-vars
+        const _: never = key
+      }
+    })
+
+    // TypeScript trick for type-guarding:
+    const optionalPropsGuard: Record<LinkPropsOptional, true> = {
+      as: true,
+      replace: true,
+      scroll: true,
+      shallow: true,
+      passHref: true,
+      prefetch: true,
+      onClick: true,
+      onMouseEnter: true,
+      onTouchStart: true,
+      legacyBehavior: true,
+      onNavigate: true,
+    } as const
+    const optionalProps: LinkPropsOptional[] = Object.keys(
+      optionalPropsGuard
+    ) as LinkPropsOptional[]
+    optionalProps.forEach((key: LinkPropsOptional) => {
+      const valType = typeof props[key]
+
+      if (key === 'as') {
+        if (props[key] && valType !== 'string' && valType !== 'object') {
+          throw createPropError({
+            key,
+            expected: '`string` or `object`',
+            actual: valType,
+          })
+        }
+      } else if (
+        key === 'onClick' ||
+        key === 'onMouseEnter' ||
+        key === 'onTouchStart' ||
+        key === 'onNavigate'
+      ) {
+        if (props[key] && valType !== 'function') {
+          throw createPropError({
+            key,
+            expected: '`function`',
+            actual: valType,
+          })
+        }
+      } else if (
+        key === 'replace' ||
+        key === 'scroll' ||
+        key === 'shallow' ||
+        key === 'passHref' ||
+        key === 'prefetch' ||
+        key === 'legacyBehavior'
+      ) {
+        if (props[key] != null && valType !== 'boolean') {
+          throw createPropError({
+            key,
+            expected: '`boolean`',
+            actual: valType,
+          })
         }
+      } else {
+        // TypeScript trick for type-guarding:
+        // eslint-disable-next-line @typescript-eslint/no-unused-vars
+        const _: never = key
+      }
+    })
+  }
 
-        if (href) {
-          const hasDynamicSegment = href
-            .split('/')
-            .some((segment) => segment.startsWith('[') && segment.endsWith(']'))
+  if (process.env.NODE_ENV !== 'production') {
+    if (props.locale) {
+      warnOnce(
+        'The `locale` prop is not supported in `next/link` while using the `app` router. Read more about app router internalization: https://nextjs.org/docs/app/building-your-application/routing/internationalization'
+      )
+    }
+    if (!asProp) {
+      let href: string | undefined
+      if (typeof hrefProp === 'string') {
+        href = hrefProp
+      } else if (
+        typeof hrefProp === 'object' &&
+        typeof hrefProp.pathname === 'string'
+      ) {
+        href = hrefProp.pathname
+      }
 
-          if (hasDynamicSegment) {
-            throw new Error(
-              `Dynamic href \`${href}\` found in <Link> while using the \`/app\` router, this is not supported. Read more: https://nextjs.org/docs/messages/app-dir-dynamic-href`
-            )
-          }
+      if (href) {
+        const hasDynamicSegment = href
+          .split('/')
+          .some((segment) => segment.startsWith('[') && segment.endsWith(']'))
+
+        if (hasDynamicSegment) {
+          throw new Error(
+            `Dynamic href \`${href}\` found in <Link> while using the \`/app\` router, this is not supported. Read more: https://nextjs.org/docs/messages/app-dir-dynamic-href`
+          )
         }
       }
     }
+  }
 
-    const { href, as } = React.useMemo(() => {
-      const resolvedHref = formatStringOrUrl(hrefProp)
-      return {
-        href: resolvedHref,
-        as: asProp ? formatStringOrUrl(asProp) : resolvedHref,
+  const { href, as } = React.useMemo(() => {
+    const resolvedHref = formatStringOrUrl(hrefProp)
+    return {
+      href: resolvedHref,
+      as: asProp ? formatStringOrUrl(asProp) : resolvedHref,
+    }
+  }, [hrefProp, asProp])
+
+  // This will return the first child, if multiple are provided it will throw an error
+  let child: any
+  if (legacyBehavior) {
+    if (process.env.NODE_ENV === 'development') {
+      if (onClick) {
+        console.warn(
+          `"onClick" was passed to <Link> with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onClick be set on the child of next/link`
+        )
       }
-    }, [hrefProp, asProp])
-
-    // This will return the first child, if multiple are provided it will throw an error
-    let child: any
-    if (legacyBehavior) {
-      if (process.env.NODE_ENV === 'development') {
-        if (onClick) {
-          console.warn(
-            `"onClick" was passed to <Link> with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onClick be set on the child of next/link`
-          )
-        }
-        if (onMouseEnterProp) {
-          console.warn(
-            `"onMouseEnter" was passed to <Link> with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onMouseEnter be set on the child of next/link`
-          )
-        }
-        try {
-          child = React.Children.only(children)
-        } catch (err) {
-          if (!children) {
-            throw new Error(
-              `No children were passed to <Link> with \`href\` of \`${hrefProp}\` but one child is required https://nextjs.org/docs/messages/link-no-children`
-            )
-          }
+      if (onMouseEnterProp) {
+        console.warn(
+          `"onMouseEnter" was passed to <Link> with \`href\` of \`${hrefProp}\` but "legacyBehavior" was set. The legacy behavior requires onMouseEnter be set on the child of next/link`
+        )
+      }
+      try {
+        child = React.Children.only(children)
+      } catch (err) {
+        if (!children) {
           throw new Error(
-            `Multiple children were passed to <Link> with \`href\` of \`${hrefProp}\` but only one child is supported https://nextjs.org/docs/messages/link-multiple-children` +
-              (typeof window !== 'undefined'
-                ? " \nOpen your browser's console to view the Component stack trace."
-                : '')
+            `No children were passed to <Link> with \`href\` of \`${hrefProp}\` but one child is required https://nextjs.org/docs/messages/link-no-children`
           )
         }
-      } else {
-        child = React.Children.only(children)
+        throw new Error(
+          `Multiple children were passed to <Link> with \`href\` of \`${hrefProp}\` but only one child is supported https://nextjs.org/docs/messages/link-multiple-children` +
+            (typeof window !== 'undefined'
+              ? " \nOpen your browser's console to view the Component stack trace."
+              : '')
+        )
       }
     } else {
-      if (process.env.NODE_ENV === 'development') {
-        if ((children as any)?.type === 'a') {
+      child = React.Children.only(children)
+    }
+  } else {
+    if (process.env.NODE_ENV === 'development') {
+      if ((children as any)?.type === 'a') {
+        throw new Error(
+          'Invalid <Link> with <a> child. Please remove <a> or use <Link legacyBehavior>.\nLearn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor'
+        )
+      }
+    }
+  }
+
+  const childRef: any = legacyBehavior
+    ? child && typeof child === 'object' && child.ref
+    : forwardedRef
+
+  // Use a callback ref to attach an IntersectionObserver to the anchor tag on
+  // mount. In the future we will also use this to keep track of all the
+  // currently mounted <Link> instances, e.g. so we can re-prefetch them after
+  // a revalidation or refresh.
+  const observeLinkVisibilityOnMount = React.useCallback(
+    (element: HTMLAnchorElement | SVGAElement) => {
+      if (prefetchEnabled && router !== null) {
+        mountLinkInstance(element, href, router, appPrefetchKind)
+      }
+      return () => {
+        unmountLinkInstance(element)
+      }
+    },
+    [prefetchEnabled, href, router, appPrefetchKind]
+  )
+
+  const mergedRef = useMergedRef(observeLinkVisibilityOnMount, childRef)
+
+  const childProps: {
+    onTouchStart?: React.TouchEventHandler<HTMLAnchorElement>
+    onMouseEnter: React.MouseEventHandler<HTMLAnchorElement>
+    onClick: React.MouseEventHandler<HTMLAnchorElement>
+    href?: string
+    ref?: any
+  } = {
+    ref: mergedRef,
+    onClick(e) {
+      if (process.env.NODE_ENV !== 'production') {
+        if (!e) {
           throw new Error(
-            'Invalid <Link> with <a> child. Please remove <a> or use <Link legacyBehavior>.\nLearn more: https://nextjs.org/docs/messages/invalid-new-link-with-extra-anchor'
+            `Component rendered inside next/link has to pass click event to "onClick" prop.`
           )
         }
       }
-    }
 
-    const childRef: any = legacyBehavior
-      ? child && typeof child === 'object' && child.ref
-      : forwardedRef
-
-    // Use a callback ref to attach an IntersectionObserver to the anchor tag on
-    // mount. In the future we will also use this to keep track of all the
-    // currently mounted <Link> instances, e.g. so we can re-prefetch them after
-    // a revalidation or refresh.
-    const observeLinkVisibilityOnMount = React.useCallback(
-      (element: HTMLAnchorElement | SVGAElement) => {
-        if (prefetchEnabled && router !== null) {
-          mountLinkInstance(element, href, router, appPrefetchKind)
-        }
-        return () => {
-          unmountLinkInstance(element)
-        }
-      },
-      [prefetchEnabled, href, router, appPrefetchKind]
-    )
-
-    const mergedRef = useMergedRef(observeLinkVisibilityOnMount, childRef)
-
-    const childProps: {
-      onTouchStart?: React.TouchEventHandler<HTMLAnchorElement>
-      onMouseEnter: React.MouseEventHandler<HTMLAnchorElement>
-      onClick: React.MouseEventHandler<HTMLAnchorElement>
-      href?: string
-      ref?: any
-    } = {
-      ref: mergedRef,
-      onClick(e) {
-        if (process.env.NODE_ENV !== 'production') {
-          if (!e) {
-            throw new Error(
-              `Component rendered inside next/link has to pass click event to "onClick" prop.`
-            )
-          }
-        }
+      if (!legacyBehavior && typeof onClick === 'function') {
+        onClick(e)
+      }
 
-        if (!legacyBehavior && typeof onClick === 'function') {
-          onClick(e)
-        }
+      if (
+        legacyBehavior &&
+        child.props &&
+        typeof child.props.onClick === 'function'
+      ) {
+        child.props.onClick(e)
+      }
 
-        if (
-          legacyBehavior &&
-          child.props &&
-          typeof child.props.onClick === 'function'
-        ) {
-          child.props.onClick(e)
-        }
+      if (!router) {
+        return
+      }
 
-        if (!router) {
-          return
-        }
+      if (e.defaultPrevented) {
+        return
+      }
 
-        if (e.defaultPrevented) {
-          return
-        }
+      linkClicked(e, router, href, as, replace, shallow, scroll, onNavigate)
+    },
+    onMouseEnter(e) {
+      if (!legacyBehavior && typeof onMouseEnterProp === 'function') {
+        onMouseEnterProp(e)
+      }
 
-        linkClicked(e, router, href, as, replace, shallow, scroll, onNavigate)
-      },
-      onMouseEnter(e) {
-        if (!legacyBehavior && typeof onMouseEnterProp === 'function') {
-          onMouseEnterProp(e)
-        }
+      if (
+        legacyBehavior &&
+        child.props &&
+        typeof child.props.onMouseEnter === 'function'
+      ) {
+        child.props.onMouseEnter(e)
+      }
 
-        if (
-          legacyBehavior &&
-          child.props &&
-          typeof child.props.onMouseEnter === 'function'
-        ) {
-          child.props.onMouseEnter(e)
-        }
+      if (!router) {
+        return
+      }
 
-        if (!router) {
-          return
-        }
+      if (!prefetchEnabled || process.env.NODE_ENV === 'development') {
+        return
+      }
 
-        if (!prefetchEnabled || process.env.NODE_ENV === 'development') {
-          return
-        }
+      onNavigationIntent(e.currentTarget as HTMLAnchorElement | SVGAElement)
+    },
+    onTouchStart: process.env.__NEXT_LINK_NO_TOUCH_START
+      ? undefined
+      : function onTouchStart(e) {
+          if (!legacyBehavior && typeof onTouchStartProp === 'function') {
+            onTouchStartProp(e)
+          }
 
-        onNavigationIntent(e.currentTarget as HTMLAnchorElement | SVGAElement)
-      },
-      onTouchStart: process.env.__NEXT_LINK_NO_TOUCH_START
-        ? undefined
-        : function onTouchStart(e) {
-            if (!legacyBehavior && typeof onTouchStartProp === 'function') {
-              onTouchStartProp(e)
-            }
-
-            if (
-              legacyBehavior &&
-              child.props &&
-              typeof child.props.onTouchStart === 'function'
-            ) {
-              child.props.onTouchStart(e)
-            }
-
-            if (!router) {
-              return
-            }
-
-            if (!prefetchEnabled) {
-              return
-            }
-
-            onNavigationIntent(
-              e.currentTarget as HTMLAnchorElement | SVGAElement
-            )
-          },
-    }
+          if (
+            legacyBehavior &&
+            child.props &&
+            typeof child.props.onTouchStart === 'function'
+          ) {
+            child.props.onTouchStart(e)
+          }
 
-    // If child is an <a> tag and doesn't have a href attribute, or if the 'passHref' property is
-    // defined, we specify the current 'href', so that repetition is not needed by the user.
-    // If the url is absolute, we can bypass the logic to prepend the basePath.
-    if (isAbsoluteUrl(as)) {
-      childProps.href = as
-    } else if (
-      !legacyBehavior ||
-      passHref ||
-      (child.type === 'a' && !('href' in child.props))
-    ) {
-      childProps.href = addBasePath(as)
-    }
+          if (!router) {
+            return
+          }
+
+          if (!prefetchEnabled) {
+            return
+          }
 
-    return legacyBehavior ? (
-      React.cloneElement(child, childProps)
-    ) : (
-      <a {...restProps} {...childProps}>
-        {children}
-      </a>
-    )
+          onNavigationIntent(e.currentTarget as HTMLAnchorElement | SVGAElement)
+        },
+  }
+
+  // If child is an <a> tag and doesn't have a href attribute, or if the 'passHref' property is
+  // defined, we specify the current 'href', so that repetition is not needed by the user.
+  // If the url is absolute, we can bypass the logic to prepend the basePath.
+  if (isAbsoluteUrl(as)) {
+    childProps.href = as
+  } else if (
+    !legacyBehavior ||
+    passHref ||
+    (child.type === 'a' && !('href' in child.props))
+  ) {
+    childProps.href = addBasePath(as)
   }
-)
 
-export default Link
+  return legacyBehavior ? (
+    React.cloneElement(child, childProps)
+  ) : (
+    <a {...restProps} {...childProps}>
+      {children}
+    </a>
+  )
+}