Skip to content

Conversation

@HrvojeHemen
Copy link
Contributor

Description

Tooltip is being rendered on each hover which is slowing down the chart significantly.
By adding an option to debounce by using renderDebounceDuration we can stop it from being rendered each hover, but instead render it once the user stops moving their mouse, after the delay passed.

Covered by unit tests and manual testing.
Backwards compatible by making the prop fully optional


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

const [expandedSeries, setExpandedSeries] = useState<ExpandedSeriesState>({});
const tooltip = useSelector(api.tooltipStore, (s) => s);
if (!tooltip.visible || tooltip.group.length === 0) {
const [debouncedTooltip, setDebouncedTooltip] = useState<ReactiveTooltipState | null>(null);
Copy link
Member

Choose a reason for hiding this comment

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

Can we e.g. move the code that subscribes to the tooltip data and does debouncing into a small utility hook above that component so to encapsulate the debouncing logic inside?

enabled?: boolean;
placement?: "middle" | "outside" | "target";
size?: "small" | "medium" | "large";
renderDebounceDuration?: number;
Copy link
Member

Choose a reason for hiding this comment

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

idea: I would rename this prop to just "debounce" as the "render" and "duration" parts can kind of be implied here. Additionally, I would consider making it then a union number | boolean. That is because while we cant add the debounce interval by default, ideally we still want the debounce interval to be consistent. This can be then achieved by using debounce: true to let the internal implementation use the internal constant for it.

import { ChartAPI } from "../chart-api";
import { ReactiveTooltipState } from "../chart-api/chart-extra-tooltip";

export function useDebouncedTooltip(api: ChartAPI, renderDebounceDuration: number = 500) {
Copy link
Member

Choose a reason for hiding this comment

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

Can we pass tooltip as a prop here instead of the api? In that case we can probably also make the argument type generic:

export function useDebounce<T>(value: T, debounceDuration = DEFAULT_DEBOUNCE_DURATION): null | T {
  // ...
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thats a great suggestion, I'll make it generic and reuseable

export function useDebouncedTooltip(api: ChartAPI, renderDebounceDuration: number = 500) {
const tooltip = useSelector(api.tooltipStore, (s) => s);
const [debouncedTooltip, setDebouncedTooltip] = useState<ReactiveTooltipState | null>(null);
const [shouldRender, setShouldRender] = useState(renderDebounceDuration <= 0);
Copy link
Member

Choose a reason for hiding this comment

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

Why do we need this to be a state? Do we expect the debounce duration to change during components lifetime?

if (!tooltip.visible || tooltip.group.length === 0) {
const debouncedTooltip = useDebouncedTooltip(api, renderDebounceDuration);

if (!debouncedTooltip?.visible || debouncedTooltip?.group?.length === 0) {
Copy link
Member

Choose a reason for hiding this comment

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

Can we change that to !debouncedTooltip || !debouncedTooltip?.visible || debouncedTooltip?.group?.length === 0? This should allow unnecessary conditions around the debouncedTooltip var below.

Copy link
Member

Choose a reason for hiding this comment

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

Or can those be already removed?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They need to stay with the !debouncedTooltip check at the front, this makes sure that if it's not debounced yet it doesn't render, and then if it is debounced it keeps the same behaviour

const [expandedSeries, setExpandedSeries] = useState<ExpandedSeriesState>({});
const tooltip = useSelector(api.tooltipStore, (s) => s);
if (!tooltip.visible || tooltip.group.length === 0) {
const debouncedTooltip = useDebouncedTooltip(api, renderDebounceDuration);
Copy link
Member

Choose a reason for hiding this comment

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

What is the point of defaulting the renderDebounceDuration to 500 inside the useDebouncedTooltip, when it defaults to 0 in this function anyways?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Left it from when I was debugging. Nice catch!

});

expect(getTooltipContentMock).toHaveBeenCalledTimes(2);
expect(getTooltipContentMock).toHaveBeenCalledTimes(1);
Copy link
Member

Choose a reason for hiding this comment

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

Why this change?

});
});

describe("debounce functionality", () => {
Copy link
Member

Choose a reason for hiding this comment

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

Very good tests 👍

}}
/>

<h2>Debounced Tooltip (500ms)</h2>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a chart here so that debounce can be tested on a page with it enabled and disabled

Copy link
Member

Choose a reason for hiding this comment

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

Note that this is a page for visual regression tests. I would prefer not having anything there that is not meant for visual testing and might add noise and/or flakiness. Since this is a core-only feature, what about having a separate small page in pages/03-core?


import { DebouncedCall } from "./utils";

export function useDebounce<T>(component: T, duration: number) {
Copy link
Member

Choose a reason for hiding this comment

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

I would suggest calling this function something like useDebouncedComponent or useDebouncedRender, since it is specific to rendering a component and not other types of calls

Copy link
Member

@pan-kot pan-kot Dec 4, 2025

Choose a reason for hiding this comment

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

I suggested (#129 (comment)) calling that useDebouncedValue because it can technically use any value as input, the component arg name is also misleading in that regard.

}}
/>

<h2>Debounced Tooltip (500ms)</h2>
Copy link
Member

Choose a reason for hiding this comment

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

Note that this is a page for visual regression tests. I would prefer not having anything there that is not meant for visual testing and might add noise and/or flakiness. Since this is a core-only feature, what about having a separate small page in pages/03-core?

@HrvojeHemen HrvojeHemen requested a review from a team as a code owner December 4, 2025 11:30
@HrvojeHemen HrvojeHemen requested review from ClaudioGSDB and removed request for a team December 4, 2025 11:30
const [expandedSeries, setExpandedSeries] = useState<ExpandedSeriesState>({});
const tooltip = useSelector(api.tooltipStore, (s) => s);
if (!tooltip.visible || tooltip.group.length === 0) {
const debouncedTooltip = useDebouncedComponent(tooltip, debounce === true ? DEFAULT_DEBOUNCE : debounce || 0);
Copy link
Member

Choose a reason for hiding this comment

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

The debouncedTooltip is not a component but a state. Should we rename the util to useDebouncedValue to avoid confusion? Likewise, inside the util we can have const [debouncedValue, setDebouncedValue] = useState<T | null>(null);

@pan-kot pan-kot enabled auto-merge December 4, 2025 16:49
@pan-kot pan-kot added this pull request to the merge queue Dec 4, 2025
Merged via the queue into cloudscape-design:main with commit 805cc3c Dec 4, 2025
42 of 44 checks passed
@HrvojeHemen HrvojeHemen deleted the debounce-tooltip-wip branch December 5, 2025 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants