fix: Remove all links legacyBehavior (#25585)

* Remove all links legacyBehavior

* fix: resolve type errors in Button and Dropdown when using Link without legacyBehavior

- Button.tsx: Only pass ref to button element, not to Link (Link manages its own anchor)
- Dropdown.tsx: Strip ref from props when using Link to avoid type incompatibility

This fixes the type errors that were causing API V1 and V2 builds to fail.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: only pass disabled and type props to button element, not to Link

Link component doesn't accept disabled or type props, so these should only be passed when rendering a button element.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: strip ref from passThroughProps when rendering Link

The passThroughProps spread was including a ref property that's incompatible with Link's expected ref type. This strips the ref when rendering a Link component.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: use type assertion for React.createElement props to handle union types

The Button component uses a union type for props that can be either Link or button props. TypeScript can't narrow the union type properly when using React.createElement with a dynamic element type, so we use a type assertion to cast the props to the correct type.

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: render Link and button separately to avoid type conflicts

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* fix: preserve data-testid when rendering Button as Link

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Volnei Munhoz
2025-12-21 12:40:27 +00:00
committed by GitHub
co-authored by Volnei Munhoz Anik Dhabal Babu Devin AI
parent 067fa7e137
commit 19ac0139c1
8 changed files with 103 additions and 100 deletions
+5 -5
View File
@@ -1,5 +1,4 @@
import type { InferGetServerSidePropsType } from "next";
import Link from "next/link";
import { useState } from "react";
import { Toaster } from "sonner";
@@ -71,9 +70,10 @@ export default function MakeSetup({ inviteLink }: InferGetServerSidePropsType<ty
<div className="bg-default m-auto max-w-[43em] overflow-auto rounded pb-10 md:p-10">
<div className="md:flex md:flex-row">
<div className="invisible md:visible">
{/* eslint-disable @next/next/no-img-element */}
<img className="h-11" src="/api/app-store/make/icon.svg" alt="Make Logo" />
</div>
<div className="ml-2 ltr:mr-2 rtl:ml-2 md:ml-5">
<div className="ml-2 md:ml-5 ltr:mr-2 rtl:ml-2">
<div className="text-default">{t("setting_up_make")}</div>
<>
@@ -134,9 +134,9 @@ export default function MakeSetup({ inviteLink }: InferGetServerSidePropsType<ty
<li>{t("make_setup_instructions_5")}</li>
<li>{t("make_setup_instructions_6")}</li>
</ol>
<Link href="/apps/installed/automation?hl=make" passHref={true} legacyBehavior>
<Button color="secondary">{t("done")}</Button>
</Link>
<Button href="/apps/installed/automation?hl=make" color="secondary">
{t("done")}
</Button>
</div>
</div>
</div>
@@ -45,12 +45,10 @@ function Expired() {
<h2 className="text-emphasis text-center text-3xl font-extrabold">{t("request_is_expired")}</h2>
</div>
<p>{t("request_is_expired_instructions")}</p>
<Link href="/auth/forgot-password" passHref legacyBehavior>
<button
type="button"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("try_again")}
</button>
<Link
href="/auth/forgot-password"
className="flex w-full justify-center px-4 py-2 text-sm font-medium text-blue-600 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2">
{t("try_again")}
</Link>
</div>
</>
@@ -53,7 +53,6 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { EmailInput, TextArea } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { useCalcomTheme } from "@calcom/ui/styles";
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
@@ -801,15 +800,14 @@ export default function Success(props: PageProps) {
</span>
{/* Login button but redirect to here */}
<span className="text-default inline">
<span className="underline" data-testid="reschedule-link">
<Link
href={`/auth/login?callbackUrl=${encodeURIComponent(
`/booking/${bookingInfo?.uid}`
)}`}
legacyBehavior>
{t("login")}
</Link>
</span>
<Link
href={`/auth/login?callbackUrl=${encodeURIComponent(
`/booking/${bookingInfo?.uid}`
)}`}
className="underline"
data-testid="reschedule-link">
{t("login")}
</Link>
</span>
</div>
</>
@@ -839,17 +837,16 @@ export default function Success(props: PageProps) {
canReschedule &&
!isRescheduleDisabled && (
<span className="text-default inline">
<span className="underline" data-testid="reschedule-link">
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
currentUserEmail
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
: ""
}`}
legacyBehavior>
{t("reschedule")}
</Link>
</span>
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
currentUserEmail
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
: ""
}`}
className="underline"
data-testid="reschedule-link">
{t("reschedule")}
</Link>
{!isBookingInPast && canCancel && (
<span className="mx-2">{t("or_lowercase")}</span>
)}
@@ -1,5 +1,3 @@
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Icon } from "@calcom/ui/components/icon";
@@ -17,12 +15,10 @@ export default function AppNotInstalledMessage({ appName }: { appName: string })
<p className="text-subtle px-1 leading-normal">{t("visit_our_app_store")}</p>
<div className="mt-5">
<Link href={`/apps/${appName}`} passHref={true} legacyBehavior>
<Button type="button" color="secondary">
{t("go_to_app_store")}
<Icon name="arrow-up-right" className="ml-1" size={20} />
</Button>
</Link>
<Button href={`/apps/${appName}`} type="button" color="secondary">
{t("go_to_app_store")}
<Icon name="arrow-up-right" className="ml-1" size={20} />
</Button>
</div>
</div>
</div>
+34 -24
View File
@@ -239,25 +239,17 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
...passThroughProps
} = props;
// Buttons are **always** disabled if we're in a `loading` state
const disabled = props.disabled || loading;
// If pass an `href`-attr is passed it's `<a>`, otherwise it's a `<button />`
const disabled = props.disabled || loading || false;
// If pass an `href`-attr is passed it's Link, otherwise it's a `<button />`
const isLink = typeof props.href !== "undefined";
const elementType = isLink ? "a" : "button";
const element = React.createElement(
elementType,
{
...passThroughProps,
disabled,
type: !isLink ? type : undefined,
ref: forwardedRef,
className: classNames(buttonClasses({ color, size, loading, variant }), props.className),
// if we click a disabled button, we prevent going through the click handler
onClick: disabled
? (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
}
: props.onClick,
},
const buttonClassName = classNames(buttonClasses({ color, size, loading, variant }), props.className);
const handleClick = disabled
? (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
}
: props.onClick;
const buttonContent = (
<>
{CustomStartIcon ||
(StartIcon && (
@@ -334,18 +326,36 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
</>
);
return props.href ? (
<Link data-testid="link-component" passHref href={props.href} shallow={shallow && shallow} legacyBehavior>
{element}
</Link>
) : (
// Render Link or button separately to avoid type conflicts
// Link manages its own anchor element, so we don't pass ref to it
if (isLink) {
return (
<Link
{...(passThroughProps as Omit<JSX.IntrinsicElements["a"], "href" | "onClick" | "ref"> & LinkProps)}
shallow={shallow && shallow}
className={buttonClassName}
onClick={handleClick}>
{buttonContent}
</Link>
);
}
return (
<Wrapper
data-testid="wrapper"
tooltip={props.tooltip}
tooltipSide={tooltipSide}
tooltipOffset={tooltipOffset}
tooltipClassName={tooltipClassName}>
{element}
<button
{...(passThroughProps as Omit<JSX.IntrinsicElements["button"], "onClick" | "ref">)}
ref={forwardedRef as React.Ref<HTMLButtonElement>}
disabled={disabled}
type={type as "button" | "submit" | "reset"}
className={buttonClassName}
onClick={handleClick}>
{buttonContent}
</button>
</Wrapper>
);
});
+4 -9
View File
@@ -143,19 +143,14 @@ type ButtonOrLinkProps = ComponentProps<"button"> & ComponentProps<"a">;
export function ButtonOrLink({ href, ...props }: ButtonOrLinkProps) {
const isLink = typeof href !== "undefined";
const ButtonOrLink = isLink ? "a" : "button";
const content = <ButtonOrLink {...props} />;
if (isLink) {
return (
<Link href={href} legacyBehavior>
{content}
</Link>
);
// Strip ref from props when using Link (Link manages its own anchor element)
const { ref: _ref, ...linkProps } = props;
return <Link href={href} {...linkProps} />;
}
return content;
return <button {...props} />;
}
export const DropdownItem = (props: DropdownItemProps) => {
+33 -26
View File
@@ -26,32 +26,39 @@ function Stepper<T extends DefaultStep>(props: {
<ol role="list" className="ml-8 flex items-center space-x-5" ref={stepperRef}>
{steps.map((mapStep, index) => (
<li key={mapStep.title}>
<Link
href={props.disableSteps ? "#" : `${href}?step=${index + 1}`}
shallow
replace
legacyBehavior>
{index + 1 < props.step ? (
<a className="hover:bg-inverted block h-2.5 w-2.5 rounded-full bg-gray-600">
<span className="sr-only">{mapStep.title}</span>
</a>
) : index + 1 === props.step ? (
<a className="relative flex items-center justify-center" aria-current="step">
<span className="absolute flex h-5 w-5 p-px" aria-hidden="true">
<span className="bg-emphasis h-full w-full rounded-full" />
</span>
<span
className="relative block h-2.5 w-2.5 rounded-full bg-gray-600"
aria-hidden="true"
/>
<span className="sr-only">{mapStep.title}</span>
</a>
) : (
<a className="bg-emphasis block h-2.5 w-2.5 rounded-full hover:bg-gray-400">
<span className="sr-only">{mapStep.title}</span>
</a>
)}
</Link>
{index + 1 < props.step ? (
<Link
href={props.disableSteps ? "#" : `${href}?step=${index + 1}`}
shallow
replace
className="hover:bg-inverted block h-2.5 w-2.5 rounded-full bg-gray-600">
<span className="sr-only">{mapStep.title}</span>
</Link>
) : index + 1 === props.step ? (
<Link
href={props.disableSteps ? "#" : `${href}?step=${index + 1}`}
shallow
replace
className="relative flex items-center justify-center"
aria-current="step">
<span className="absolute flex h-5 w-5 p-px" aria-hidden="true">
<span className="bg-emphasis h-full w-full rounded-full" />
</span>
<span
className="relative block h-2.5 w-2.5 rounded-full bg-gray-600"
aria-hidden="true"
/>
<span className="sr-only">{mapStep.title}</span>
</Link>
) : (
<Link
href={props.disableSteps ? "#" : `${href}?step=${index + 1}`}
shallow
replace
className="bg-emphasis block h-2.5 w-2.5 rounded-full hover:bg-gray-400">
<span className="sr-only">{mapStep.title}</span>
</Link>
)}
</li>
))}
</ol>
+1 -1
View File
@@ -57,7 +57,7 @@ export function ListItem(props: ListItemProps) {
);
return href ? (
<Link passHref href={href} legacyBehavior>
<Link href={href}>
{element}
</Link>
) : (