Skip to content
Open
203 changes: 203 additions & 0 deletions static/app/components/HoverScrollable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import {useLayoutEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';

import {Container} from '@sentry/scraps/layout';

import {OverflowText} from 'sentry/components/OverflowText';

interface HoverScrollableProps {
value: string;
className?: string;
edgeWidth?: number;
leftTrim?: boolean;
speed?: number;
}

export function HoverScrollable({
value,
className,
leftTrim = false,
speed = 0.1,
edgeWidth = 40,
}: HoverScrollableProps) {
const [isHovered, setIsHovered] = useState(false);

const wrapperRef = useRef<HTMLSpanElement>(null);
const containerRef = useRef<HTMLSpanElement>(null);
const contentRef = useRef<HTMLSpanElement>(null);
const animationRef = useRef<Animation | null>(null);
const direction = useRef<boolean | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
const targetRef = useRef(0);
const scrollWidthRef = useRef(0);

const showSlidingText = isHovered && isTruncated;

useLayoutEffect(() => {
const container = containerRef.current;
const content = contentRef.current;

if (!showSlidingText || !container || !content) {
return;
}

const maxTranslate = content.scrollWidth - container.clientWidth;
const truncated = maxTranslate > 0;

setIsTruncated(truncated);
Comment on lines +44 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: A render loop occurs because SlidingContainer uses box-sizing: content-box with padding, causing it to miscalculate text truncation and oscillate with OverflowText.
Severity: MEDIUM

Suggested Fix

Apply box-sizing: border-box; to the SlidingContainer component. This will ensure that its padding is included within its defined width, leading to accurate truncation calculations and preventing the render loop.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: static/app/components/HoverScrollable.tsx#L44-L45

Potential issue: An infinite render loop can occur when text overflows its container by
a small amount. `OverflowText` detects truncation and renders `SlidingContainer`.
However, `SlidingContainer` uses the default `box-sizing: content-box` and has
horizontal padding, making its `clientWidth` larger than its parent. For text that fits
within this inflated width, `SlidingContainer` reports that the text is not truncated,
switching back to `OverflowText`. This creates a flickering loop as the components
rapidly switch.


if (!truncated) {
animationRef.current?.cancel();
animationRef.current = null;
direction.current = null;
content.style.transform = '';
return;
}

// When the hover container appears or its text changes, snap left-trimmed
// content to the rightmost edge.
if (leftTrim) {
content.style.transform = `translateX(${-maxTranslate}px)`;
}
}, [leftTrim, showSlidingText, value]);

const handleMouseEnter = () => {
setIsHovered(true);
};

const handleMouseHover = (event: React.MouseEvent<HTMLSpanElement>) => {
const container = containerRef.current;
const content = contentRef.current;

if (!container || !content) {
return;
}

const rect = container.getBoundingClientRect();
const maxTranslate = content.scrollWidth - container.clientWidth;

if (maxTranslate <= 0) {
return;
}

if (scrollWidthRef.current !== content.scrollWidth) {
scrollWidthRef.current = content.scrollWidth;
direction.current = null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First mousemove pauses scroll

High Severity

On the first mousemove while hovering, scrollWidthRef starts at 0, so it always differs from content.scrollWidth, triggering animationRef.current?.pause(). A paused Web Animation does not resume when updatePlaybackRate runs, so edge-based scrolling stops working after the first pointer move.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 92f91e9. Configure here.

Comment on lines +91 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: On the first hover, the scroll animation is immediately paused and does not resume because scrollWidthRef is uninitialized, causing an incorrect state check.
Severity: MEDIUM

Suggested Fix

Initialize scrollWidthRef with the actual scrollWidth of the content inside the useLayoutEffect where the animation is created. This ensures the check on onMouseMove does not incorrectly pause the animation on the first interaction.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: static/app/components/HoverScrollable.tsx#L91-L94

Potential issue: On the very first mouse hover, the scroll animation is permanently
paused. This occurs because `scrollWidthRef` is initialized to `0`, causing the
condition `scrollWidthRef.current !== content.scrollWidth` to be true on the first
`onMouseMove` event. This triggers `animationRef.current?.pause()`. A subsequent call to
`updatePlaybackRate()` does not resume a paused animation, so the scroll feature appears
non-functional until the user moves the mouse out and back in.


const mouseX = event.clientX - rect.left;

let target: number | null = null;

// left
if (mouseX <= edgeWidth) {
target = 0;
if (direction.current === false) {
// already moving left, no need to restart animation
return;
}
direction.current = false;
}

// right
if (rect.width - mouseX <= edgeWidth) {
target = -maxTranslate;
if (direction.current === true) {
// already moving right, no need to restart animation
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
direction.current = true;
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

// middle
if (target === null) {
if (direction.current !== null) {
animationRef.current?.commitStyles();
animationRef.current?.cancel();
animationRef.current = null;
direction.current = null;
}
return;
}

// a new target
// animationRef.current?.play();

targetRef.current = target;

const currentX = getCurrentTranslateX(content);
const distance = Math.abs(target - currentX);
const duration = distance / speed;

animationRef.current?.cancel();
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

animationRef.current = content.animate(
[
{
transform: getComputedStyle(content).transform,
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
},
{
transform: `translateX(${target}px)`,
},
],
{
duration,
easing: 'linear',
fill: 'forwards',
}
);
};

const handleMouseLeave = () => {
animationRef.current?.cancel();
animationRef.current = null;
direction.current = null;

setIsHovered(false);
};

return (
<Container
ref={wrapperRef}
as="span"
className={className}
display="inline-block"
maxWidth="100%"
width="100%"
overflow="hidden"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showSlidingText ? (
<SlidingContainer ref={containerRef} onMouseMove={handleMouseHover}>
<SlidingText ref={contentRef}>{value}</SlidingText>
</SlidingContainer>
) : (
<OverflowText value={value} leftTrim={leftTrim} onTrimChange={setIsTruncated} />
)}
Comment thread
cursor[bot] marked this conversation as resolved.
</Container>
);
}

function getCurrentTranslateX(element: HTMLElement) {
const transform = window.getComputedStyle(element).transform;

if (transform === 'none') {
return 0;
}

return new DOMMatrixReadOnly(transform).m41;
}

const SlidingContainer = styled('span')`
display: inline-block;
width: 100%;
max-width: 100%;
overflow: hidden;
vertical-align: bottom;
`;

const SlidingText = styled('span')`
display: inline-block;
white-space: nowrap;
`;
52 changes: 52 additions & 0 deletions static/app/components/OverflowText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {useEffect, useRef} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';

interface OverflowTextProps {
value: string;
className?: string;
leftTrim?: boolean;
onTrimChange?: (trimmed: boolean) => void;
}

export function OverflowText({
value,
className,
leftTrim = false,
onTrimChange,
}: OverflowTextProps) {
const ref = useRef<HTMLSpanElement>(null);

useEffect(() => {
const el = ref.current;
if (!el) {
return;
}

const trimmed = el.scrollWidth > el.clientWidth;

onTrimChange?.(trimmed);
}, [value, onTrimChange]);
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<TruncatedSpan ref={ref} className={className} leftTrim={leftTrim}>
{leftTrim ? <span dir="ltr">{value}</span> : value}
</TruncatedSpan>
);
}

const TruncatedSpan = styled('span')<{leftTrim: boolean}>`
display: inline-block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
vertical-align: bottom;

${p =>
p.leftTrim &&
css`
direction: rtl;
text-align: left;
`}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
trimPackage,
} from 'sentry/components/events/interfaces/frame/utils';
import {AnnotatedText} from 'sentry/components/events/meta/annotatedText';
import {HoverScrollable} from 'sentry/components/HoverScrollable';
import {QuestionTooltip} from 'sentry/components/questionTooltip';
import {Truncate} from 'sentry/components/truncate';
import {SLOW_TOOLTIP_DELAY} from 'sentry/constants';
Expand Down Expand Up @@ -139,7 +140,7 @@ export function DefaultTitle({
meta={pathNameOrModule.meta}
/>
) : (
<Truncate value={pathNameOrModule.value} maxLength={100} leftTrim />
<HoverScrollable value={pathNameOrModule.value} leftTrim />
)}
</code>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,13 @@ const DefaultLineTitleWrapper = styled('div')<{isInAppFrame: boolean}>`
const LeftLineTitle = styled('div')`
display: flex;
align-items: center;
flex: 1 1 auto;
min-width: 0;

> div {
min-width: 0;
max-width: 100%;
}
`;

const RepeatedContent = styled(LeftLineTitle)`
Expand Down Expand Up @@ -418,6 +424,7 @@ const DefaultLine = styled('div')<{
min-height: 40px;
word-break: break-word;
padding: ${p => p.theme.space.sm} ${p => p.theme.space.lg};
gap: ${p => p.theme.space.sm} ${p => p.theme.space.lg};
font-size: ${p => p.theme.font.size.sm};
line-height: 16px;
cursor: ${p => (p.isExpandable ? 'pointer' : 'default')};
Expand Down
Loading