-
Notifications
You must be signed in to change notification settings - Fork 385
fix(clerk-js): Reduce unnecessary re-renders on CheckoutComplete #6765
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@clerk/clerk-js': patch | ||
--- | ||
|
||
Performance improvements for `<Checkout />`. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,25 +14,160 @@ import { useRouter } from '../../router'; | |
const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); | ||
const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt; | ||
|
||
const SuccessRing = ({ positionX, positionY }: { positionX: number; positionY: number }) => { | ||
const animationRef = useRef<number | null>(null); | ||
const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); | ||
|
||
const canHover = | ||
typeof window === 'undefined' ? true : window.matchMedia('(hover: hover) and (pointer: fine)').matches; | ||
|
||
useEffect(() => { | ||
if (!canHover) { | ||
return; | ||
} | ||
const animate = () => { | ||
setCurrentPosition(prev => { | ||
const amt = 0.15; | ||
const x = lerp(prev.x, positionX, amt); | ||
const y = lerp(prev.y, positionY, amt); | ||
return { x, y }; | ||
}); | ||
animationRef.current = requestAnimationFrame(animate); | ||
}; | ||
animationRef.current = requestAnimationFrame(animate); | ||
Comment on lines
+28
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Stop the rAF loop when the target is reached to avoid perpetual re-renders. The loop runs forever even when the point stabilizes. Add an epsilon and stop scheduling. Apply this diff: - const animate = () => {
- setCurrentPosition(prev => {
- const amt = 0.15;
- const x = lerp(prev.x, positionX, amt);
- const y = lerp(prev.y, positionY, amt);
- return { x, y };
- });
- animationRef.current = requestAnimationFrame(animate);
- };
+ const animate = () => {
+ let continueAnim = false;
+ setCurrentPosition(prev => {
+ const amt = 0.15;
+ const x = lerp(prev.x, positionX, amt);
+ const y = lerp(prev.y, positionY, amt);
+ continueAnim = Math.hypot(x - prev.x, y - prev.y) > 0.5;
+ return { x, y };
+ });
+ if (continueAnim) {
+ animationRef.current = requestAnimationFrame(animate);
+ } else {
+ animationRef.current = null;
+ }
+ };
🤖 Prompt for AI Agents
|
||
return () => { | ||
if (animationRef.current) { | ||
cancelAnimationFrame(animationRef.current); | ||
} | ||
}; | ||
}, [positionX, positionY, canHover]); | ||
|
||
// Generate unique IDs for SVG elements to avoid conflicts with multiple component instances | ||
const maskId1 = useId(); | ||
const maskId2 = useId(); | ||
const maskId3 = useId(); | ||
|
||
Comment on lines
+45
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Make all SVG IDs unique to prevent collisions when multiple instances render. Static IDs for the radialGradient, filter, and mask can collide. Generate unique IDs and update references. Apply this diff: @@
- // Generate unique IDs for SVG elements to avoid conflicts with multiple component instances
- const maskId1 = useId();
- const maskId2 = useId();
- const maskId3 = useId();
+ // Generate unique IDs per instance to avoid <defs> collisions
+ const maskId1 = useId();
+ const maskId2 = useId();
+ const maskId3 = useId();
+ const uid = useId();
+ const mainMaskId = `clerk-checkout-success-mask-${uid}`;
+ const radialGradientId = `clerk-checkout-success-gradient-${uid}`;
+ const blurFilterId = `clerk-checkout-success-blur-effect-${uid}`;
@@
- <radialGradient id='clerk-checkout-success-gradient'>
+ <radialGradient id={radialGradientId}>
@@
- <filter id='clerk-checkout-success-blur-effect'>
+ <filter id={blurFilterId}>
@@
- <mask id='clerk-checkout-success-mask'>
+ <mask id={mainMaskId}>
@@
- <g mask='url(#clerk-checkout-success-mask)'>
+ <g mask={`url(#${mainMaskId})`}>
@@
- fill='url(#clerk-checkout-success-gradient)'
- filter='url(#clerk-checkout-success-blur-effect)'
+ fill={`url(#${radialGradientId})`}
+ filter={`url(#${blurFilterId})`} Also applies to: 64-82, 117-117, 135-135, 150-151 🤖 Prompt for AI Agents
|
||
return ( | ||
<Box | ||
elementDescriptor={descriptors.checkoutSuccessRings} | ||
as='svg' | ||
// @ts-ignore - viewBox is a valid prop for svg | ||
viewBox='0 0 512 512' | ||
sx={{ | ||
position: 'absolute', | ||
inset: 0, | ||
pointerEvents: 'none', | ||
}} | ||
aria-hidden | ||
> | ||
<defs> | ||
<radialGradient id='clerk-checkout-success-gradient'> | ||
<stop | ||
offset='0%' | ||
style={{ | ||
stopColor: 'var(--ring-highlight)', | ||
}} | ||
/> | ||
<stop | ||
offset='100%' | ||
stopOpacity='0' | ||
style={{ | ||
stopColor: 'var(--ring-highlight)', | ||
}} | ||
/> | ||
</radialGradient> | ||
<filter id='clerk-checkout-success-blur-effect'> | ||
<feGaussianBlur stdDeviation='10' /> | ||
</filter> | ||
{[ | ||
{ r: 225, maskStart: 10, maskEnd: 90, id: maskId1 }, | ||
{ r: 162.5, maskStart: 15, maskEnd: 85, id: maskId2 }, | ||
{ r: 100, maskStart: 20, maskEnd: 80, id: maskId3 }, | ||
].map(({ maskStart, maskEnd, id }) => ( | ||
<linearGradient | ||
key={id} | ||
id={`gradient-${id}`} | ||
x1='0%' | ||
y1='0%' | ||
x2='0%' | ||
y2='100%' | ||
> | ||
<stop | ||
offset={`${maskStart + 5}%`} | ||
stopColor='white' | ||
stopOpacity='0' | ||
/> | ||
<stop | ||
offset={`${maskStart + 35}%`} | ||
stopColor='white' | ||
stopOpacity='1' | ||
/> | ||
<stop | ||
offset={`${maskEnd - 35}%`} | ||
stopColor='white' | ||
stopOpacity='1' | ||
/> | ||
<stop | ||
offset={`${maskEnd - 5}%`} | ||
stopColor='white' | ||
stopOpacity='0' | ||
/> | ||
</linearGradient> | ||
))} | ||
<mask id='clerk-checkout-success-mask'> | ||
{[ | ||
{ r: 225, id: maskId1 }, | ||
{ r: 162.5, id: maskId2 }, | ||
{ r: 100, id: maskId3 }, | ||
].map(({ r, id }) => ( | ||
<circle | ||
key={id} | ||
cx='256' | ||
cy='256' | ||
r={r} | ||
stroke={`url(#gradient-${id})`} | ||
fill='none' | ||
strokeWidth='1' | ||
/> | ||
))} | ||
</mask> | ||
</defs> | ||
<g mask='url(#clerk-checkout-success-mask)'> | ||
<rect | ||
width='512' | ||
height='512' | ||
style={{ | ||
fill: 'var(--ring-fill)', | ||
}} | ||
/> | ||
{canHover && ( | ||
<rect | ||
id='movingGradientHighlight' | ||
width='256' | ||
height='256' | ||
x={currentPosition.x - 128} | ||
y={currentPosition.y - 128} | ||
fill='url(#clerk-checkout-success-gradient)' | ||
filter='url(#clerk-checkout-success-blur-effect)' | ||
/> | ||
)} | ||
</g> | ||
</Box> | ||
); | ||
}; | ||
|
||
export const CheckoutComplete = () => { | ||
const router = useRouter(); | ||
const { setIsOpen } = useDrawerContext(); | ||
const { newSubscriptionRedirectUrl } = useCheckoutContext(); | ||
const { checkout } = useCheckout(); | ||
const { totals, paymentSource, planPeriodStart, freeTrialEndsAt } = checkout; | ||
const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); | ||
const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); | ||
|
||
// Generate unique IDs for SVG elements to avoid conflicts with multiple component instances | ||
const maskId1 = useId(); | ||
const maskId2 = useId(); | ||
const maskId3 = useId(); | ||
|
||
const prefersReducedMotion = usePrefersReducedMotion(); | ||
const { animations: layoutAnimations } = useAppearance().parsedLayout; | ||
const isMotionSafe = !prefersReducedMotion && layoutAnimations === true; | ||
|
||
const animationRef = useRef<number | null>(null); | ||
const checkoutSuccessRootRef = useRef<HTMLSpanElement>(null); | ||
const canHover = | ||
typeof window === 'undefined' ? true : window.matchMedia('(hover: hover) and (pointer: fine)').matches; | ||
|
@@ -59,27 +194,6 @@ export const CheckoutComplete = () => { | |
} | ||
}; | ||
|
||
useEffect(() => { | ||
if (!canHover) { | ||
return; | ||
} | ||
const animate = () => { | ||
setCurrentPosition(prev => { | ||
const amt = 0.15; | ||
const x = lerp(prev.x, mousePosition.x, amt); | ||
const y = lerp(prev.y, mousePosition.y, amt); | ||
return { x, y }; | ||
}); | ||
animationRef.current = requestAnimationFrame(animate); | ||
}; | ||
animationRef.current = requestAnimationFrame(animate); | ||
return () => { | ||
if (animationRef.current) { | ||
cancelAnimationFrame(animationRef.current); | ||
} | ||
}; | ||
}, [mousePosition, canHover]); | ||
|
||
const handleClose = () => { | ||
if (newSubscriptionRedirectUrl) { | ||
void router.navigate(newSubscriptionRedirectUrl); | ||
|
@@ -135,111 +249,10 @@ export const CheckoutComplete = () => { | |
ref={checkoutSuccessRootRef} | ||
onMouseMove={handleMouseMove} | ||
> | ||
<Box | ||
elementDescriptor={descriptors.checkoutSuccessRings} | ||
as='svg' | ||
// @ts-ignore - viewBox is a valid prop for svg | ||
viewBox='0 0 512 512' | ||
sx={{ | ||
position: 'absolute', | ||
inset: 0, | ||
pointerEvents: 'none', | ||
}} | ||
aria-hidden | ||
> | ||
<defs> | ||
<radialGradient id='clerk-checkout-success-gradient'> | ||
<stop | ||
offset='0%' | ||
style={{ | ||
stopColor: 'var(--ring-highlight)', | ||
}} | ||
/> | ||
<stop | ||
offset='100%' | ||
stopOpacity='0' | ||
style={{ | ||
stopColor: 'var(--ring-highlight)', | ||
}} | ||
/> | ||
</radialGradient> | ||
<filter id='clerk-checkout-success-blur-effect'> | ||
<feGaussianBlur stdDeviation='10' /> | ||
</filter> | ||
{[ | ||
{ r: 225, maskStart: 10, maskEnd: 90, id: maskId1 }, | ||
{ r: 162.5, maskStart: 15, maskEnd: 85, id: maskId2 }, | ||
{ r: 100, maskStart: 20, maskEnd: 80, id: maskId3 }, | ||
].map(({ maskStart, maskEnd, id }) => ( | ||
<linearGradient | ||
key={id} | ||
id={`gradient-${id}`} | ||
x1='0%' | ||
y1='0%' | ||
x2='0%' | ||
y2='100%' | ||
> | ||
<stop | ||
offset={`${maskStart + 5}%`} | ||
stopColor='white' | ||
stopOpacity='0' | ||
/> | ||
<stop | ||
offset={`${maskStart + 35}%`} | ||
stopColor='white' | ||
stopOpacity='1' | ||
/> | ||
<stop | ||
offset={`${maskEnd - 35}%`} | ||
stopColor='white' | ||
stopOpacity='1' | ||
/> | ||
<stop | ||
offset={`${maskEnd - 5}%`} | ||
stopColor='white' | ||
stopOpacity='0' | ||
/> | ||
</linearGradient> | ||
))} | ||
<mask id='clerk-checkout-success-mask'> | ||
{[ | ||
{ r: 225, id: maskId1 }, | ||
{ r: 162.5, id: maskId2 }, | ||
{ r: 100, id: maskId3 }, | ||
].map(({ r, id }) => ( | ||
<circle | ||
key={id} | ||
cx='256' | ||
cy='256' | ||
r={r} | ||
stroke={`url(#gradient-${id})`} | ||
fill='none' | ||
strokeWidth='1' | ||
/> | ||
))} | ||
</mask> | ||
</defs> | ||
<g mask='url(#clerk-checkout-success-mask)'> | ||
<rect | ||
width='512' | ||
height='512' | ||
style={{ | ||
fill: 'var(--ring-fill)', | ||
}} | ||
/> | ||
{canHover && ( | ||
<rect | ||
id='movingGradientHighlight' | ||
width='256' | ||
height='256' | ||
x={currentPosition.x - 128} | ||
y={currentPosition.y - 128} | ||
fill='url(#clerk-checkout-success-gradient)' | ||
filter='url(#clerk-checkout-success-blur-effect)' | ||
/> | ||
)} | ||
</g> | ||
</Box> | ||
<SuccessRing | ||
positionX={mousePosition.x} | ||
positionY={mousePosition.y} | ||
/> | ||
<Box | ||
elementDescriptor={descriptors.checkoutSuccessBadge} | ||
sx={t => ({ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
Honor reduced-motion and gate the animation accordingly.
SuccessRing ignores prefers-reduced-motion and still runs rAF. Gate both the effect and highlight.
Apply this diff:
Also applies to: 24-44, 143-153
🤖 Prompt for AI Agents