Browse Source

fix(UserPicture): show tooltip in production builds via callback ref

UncontrolledTooltip rendered as a child of `<span ref={rootRef}>` was
resolving `target.current` in `componentDidMount` before React had set
the parent host ref (bottom-up commit order), permanently failing to
attach hover listeners. The race is masked in dev by next/dynamic's
slower resolution but fires in production builds.

Switch the host ref to a state-backed callback ref and gate tooltip
mount on the element being committed. SeenUserInfo, LikeButtons,
BookmarkButtons (via UserPictureList) and other UserPicture call sites
no longer need changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Yuki Takei 1 day ago
parent
commit
0bff853525
1 changed files with 13 additions and 8 deletions
  1. 13 8
      packages/ui/src/components/UserPicture.tsx

+ 13 - 8
packages/ui/src/components/UserPicture.tsx

@@ -5,7 +5,7 @@ import {
   memo,
   type ReactNode,
   useCallback,
-  useRef,
+  useState,
 } from 'react';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
@@ -188,9 +188,14 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
     .filter(Boolean)
     .join(' ');
 
-  // ref is always called unconditionally to satisfy React hooks rules.
-  // Passed to the root element so UncontrolledTooltip can target it.
-  const rootRef = useRef<HTMLSpanElement>(null);
+  // Callback ref into state so the tooltip mounts AFTER the host span is in
+  // the DOM. reactstrap's UncontrolledTooltip resolves `target.current` once
+  // in componentDidMount; when the tooltip is a child of the target span,
+  // React's bottom-up commit order leaves the parent ref unset at that
+  // moment, so the tooltip permanently fails to attach listeners. The race
+  // is masked in dev by next/dynamic's slower resolution but fires in
+  // production builds.
+  const [rootEl, setRootEl] = useState<HTMLSpanElement | null>(null);
 
   const tooltipClassName = `${moduleTooltipClass} user-picture-tooltip-${size ?? 'md'}`;
 
@@ -200,10 +205,10 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
   const children = (
     <>
       {imgElement}
-      {showTooltip && (
+      {rootEl != null && showTooltip && (
         <UncontrolledTooltip
           placement="bottom"
-          target={rootRef}
+          target={rootEl}
           popperClassName={tooltipClassName}
           delay={0}
           fade={false}
@@ -223,7 +228,7 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
   if (username == null || noLink) {
     return (
       <UserPictureRootWithoutLink
-        ref={rootRef}
+        ref={setRootEl}
         displayName={displayName}
         size={size}
         onClick={onClick}
@@ -238,7 +243,7 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
 
   return (
     <UserPictureRootWithLink
-      ref={rootRef}
+      ref={setRootEl}
       displayName={displayName}
       size={size}
       username={username}