Skip to content

perf(Typography): skip Tooltip render when text is not overflowing - #3424

Open
rivka-ungar wants to merge 1 commit into
masterfrom
perf/typography-skip-tooltip-when-no-overflow
Open

perf(Typography): skip Tooltip render when text is not overflowing#3424
rivka-ungar wants to merge 1 commit into
masterfrom
perf/typography-skip-tooltip-when-no-overflow

Conversation

@rivka-ungar

Copy link
Copy Markdown
Contributor

Motivation

Every Text and Heading element (which both go through Typography) unconditionally rendered a <Tooltip> wrapper even when the text was not overflowing, withoutTooltip=true, or ellipsis=false. Because Tooltip mounts Dialog internally — which carries ~49 hooks — a page with 50–100 text elements paid that cost on every render even though no tooltip was ever needed.

This is a follow-up to #3416 (Tooltip early-return optimisation) and applies the same principle at the Typography layer.

Changes

Typography.tsx

  • Extract the base DOM element into a variable (baseElement).
  • Guard: if overrideTooltipProps.content is falsy (no overflow detected, withoutTooltip=true, ellipsis=false, etc.) return the element directly — no Tooltip mount.
  • Otherwise wrap normally with <Tooltip>.

TypographyHooks.tsx

  • In useTooltipProps, the useIsOverflowing call now passes ref: ellipsis && !withoutTooltip ? ref : null. Previously a ResizeObserver was created whenever ellipsis=true, even when withoutTooltip=true made the result irrelevant. This eliminates that unnecessary observer.

Safety

  • All existing overflow-tooltip behaviour is fully preserved: when text overflows an ellipsised container the Tooltip still appears exactly as before.
  • The guard is on overrideTooltipProps.content, which is only truthy when useTooltipProps returns { ...tooltipProps, content: children } — i.e. when !withoutTooltip && ellipsis && isOverflowing.
  • All 20 existing snapshot tests pass unchanged.

Test plan

  • yarn workspace @vibe/typography test — all 20 tests green
  • Open Storybook for Text / Heading, verify ellipsis tooltip still appears when text is long enough to overflow
  • Verify no tooltip appears (and no Tooltip DOM node) on short non-overflowing text

🤖 Generated with Claude Code

Avoid mounting Tooltip (and its 49+ hooks via Dialog) on every Text/Heading
element by only rendering the wrapper when overflow is actually detected.
Additionally skip the ResizeObserver entirely when withoutTooltip=true.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rivka-ungar
rivka-ungar requested a review from a team as a code owner July 12, 2026 12:06
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

perf(typography): avoid Tooltip/ResizeObserver when ellipsis isn’t overflowing

✨ Enhancement 🕐 10-20 Minutes

Grey Divider

AI Description

• Skip mounting Tooltip/DIalog unless overflow-tooltip content is actually needed.
• Reuse a single base element and wrap it in Tooltip only on overflow.
• Avoid creating a ResizeObserver when withoutTooltip=true makes it irrelevant.
Diagram

graph TD
  A["Text/Heading"] --> B["Typography"] --> C["baseElement"]
  B --> D["useTooltipProps"] --> E["useIsOverflowing"] --> F["ResizeObserver"]
  B --> G["Tooltip"] --> H["Dialog"]
  D -. "no content" .-> C
  D -. "content present" .-> G

  subgraph Legend
    direction LR
    _cmp[Component] ~~~ _hook([Hook]) ~~~ _browser[[Browser API]]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Always render Tooltip but lazy-mount Dialog internally
  • ➕ Keeps Typography simpler (no conditional wrapper)
  • ➕ Centralizes performance optimization inside Tooltip implementation
  • ➖ Tooltip still needs to render and evaluate props for every Typography instance
  • ➖ Doesn’t avoid extra wrapper nodes when unused unless Tooltip also early-returns
2. Use native `title` attribute for overflow hint
  • ➕ Near-zero runtime cost; no Dialog/hooks
  • ➕ No ResizeObserver needed
  • ➖ Behavior/regressions vs current Tooltip UX (styling, accessibility, rich content)
  • ➖ Hard to match existing Tooltip placement and interactions
3. Defer overflow detection to interaction (hover/focus)
  • ➕ Avoids ResizeObserver during steady-state rendering
  • ➕ Only pays measurement cost when user might see tooltip
  • ➖ Tooltip may appear with a delay on first interaction
  • ➖ More complex event-driven measurement and caching logic

Recommendation: Current approach is the best tradeoff: it removes the largest fixed cost (Tooltip/Dialog mount) and eliminates unnecessary observation when withoutTooltip=true, while preserving existing UX semantics. The alternatives either shift complexity into Tooltip, change UX, or introduce interaction-time latency/complexity.

Files changed (2) +19 / -17

Enhancement (2) +19 / -17
Typography.tsxReturn base element directly when tooltip content is absent +18/-16

Return base element directly when tooltip content is absent

• Extracts the underlying element creation into a 'baseElement' variable. If 'overrideTooltipProps.content' is falsy (e.g., no overflow / 'withoutTooltip' / 'ellipsis=false'), Typography returns the element without mounting Tooltip; otherwise it wraps the same element in '<Tooltip>'.

packages/components/typography/src/Typography/Typography.tsx

TypographyHooks.tsxSkip overflow observation when tooltips are disabled +1/-1

Skip overflow observation when tooltips are disabled

• Updates 'useTooltipProps' to pass a null ref to 'useIsOverflowing' when 'withoutTooltip=true', preventing creation of a ResizeObserver in cases where overflow results would be ignored.

packages/components/typography/src/Typography/TypographyHooks.tsx

@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Stale overflow after re-enable 🐞 Bug ≡ Correctness
Description
useTooltipProps now passes null to useIsOverflowing when withoutTooltip=true, which stops
the ResizeObserver and can leave isOverflowing at its previous value. If withoutTooltip later
flips back to false after layout/content changes, Typography can briefly make the wrong tooltip
decision until the first ResizeObserver callback runs.
Code

packages/components/typography/src/Typography/TypographyHooks.tsx[35]

+    ref: ellipsis && !withoutTooltip ? ref : null,
Evidence
The PR change disables overflow observation whenever withoutTooltip is true. useIsOverflowing
keeps the previous isOverflowing value in state and, with no observer running, cannot update it
while disabled—so it can be stale when tooltips are re-enabled until the observer fires again.

packages/components/typography/src/Typography/TypographyHooks.tsx[25-41]
packages/hooks/src/useIsOverflowing/index.ts[36-54]
packages/hooks/src/useResizeObserver/index.ts[17-22]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`useTooltipProps` disables overflow observation by passing a `null` ref into `useIsOverflowing` when `withoutTooltip=true`. Because `useIsOverflowing` stores overflow in React state and only updates it via the ResizeObserver callback, disabling the observer can leave a stale `isOverflowing` value that is reused when tooltips are re-enabled.

### Issue Context
This is most visible when `withoutTooltip` is toggled at runtime (e.g., responsive UI, user preference toggles) and the element’s overflow state changes while observation is disabled. On re-enable, Typography may mount (or not mount) Tooltip for one render based on stale overflow state.

### Fix Focus Areas
- packages/components/typography/src/Typography/TypographyHooks.tsx[34-40]
- packages/hooks/src/useIsOverflowing/index.ts[36-53]

### Suggested fix approach
Update `useIsOverflowing` to explicitly handle a missing/disabled ref by resetting overflow to `false` when `ref` is falsy or when `ref.current` is not available, so re-enabling observation starts from a consistent state. This preserves the perf optimization while avoiding transient incorrect tooltip decisions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

) {
const isOverflowing = useIsOverflowing({
ref: ellipsis ? ref : null,
ref: ellipsis && !withoutTooltip ? ref : 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.

Remediation recommended

1. Stale overflow after re-enable 🐞 Bug ≡ Correctness

useTooltipProps now passes null to useIsOverflowing when withoutTooltip=true, which stops
the ResizeObserver and can leave isOverflowing at its previous value. If withoutTooltip later
flips back to false after layout/content changes, Typography can briefly make the wrong tooltip
decision until the first ResizeObserver callback runs.
Agent Prompt
### Issue description
`useTooltipProps` disables overflow observation by passing a `null` ref into `useIsOverflowing` when `withoutTooltip=true`. Because `useIsOverflowing` stores overflow in React state and only updates it via the ResizeObserver callback, disabling the observer can leave a stale `isOverflowing` value that is reused when tooltips are re-enabled.

### Issue Context
This is most visible when `withoutTooltip` is toggled at runtime (e.g., responsive UI, user preference toggles) and the element’s overflow state changes while observation is disabled. On re-enable, Typography may mount (or not mount) Tooltip for one render based on stale overflow state.

### Fix Focus Areas
- packages/components/typography/src/Typography/TypographyHooks.tsx[34-40]
- packages/hooks/src/useIsOverflowing/index.ts[36-53]

### Suggested fix approach
Update `useIsOverflowing` to explicitly handle a missing/disabled ref by resetting overflow to `false` when `ref` is falsy or when `ref.current` is not available, so re-enabling observation starts from a consistent state. This preserves the perf optimization while avoiding transient incorrect tooltip decisions.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@github-actions

Copy link
Copy Markdown
Contributor

📦 Bundle Size Analysis

✅ No bundle size changes detected.

Unchanged Components
Component Base PR Diff
@vibe/button 17.31KB 17.28KB -36B 🟢
@vibe/clickable 5.97KB 5.97KB +8B 🔺
@vibe/dialog 52.19KB 52.18KB -13B 🟢
@vibe/icon-button 66.17KB 66.1KB -81B 🟢
@vibe/icon 12.93KB 12.87KB -59B 🟢
@vibe/layer 2.97KB 2.96KB -10B 🟢
@vibe/layout 9.84KB 9.82KB -18B 🟢
@vibe/loader 5.67KB 5.65KB -28B 🟢
@vibe/tooltip 61.39KB 61.3KB -91B 🟢
@vibe/typography 63.46KB 63.42KB -37B 🟢
Accordion 6.32KB 6.3KB -18B 🟢
AccordionItem 66.43KB 66.51KB +84B 🔺
AlertBanner 70.9KB 70.88KB -14B 🟢
AlertBannerButton 18.81KB 18.82KB +9B 🔺
AlertBannerLink 15.23KB 15.26KB +34B 🔺
AlertBannerText 63.97KB 63.93KB -41B 🟢
AttentionBox 74.31KB 74.39KB +83B 🔺
Avatar 66.82KB 66.8KB -20B 🟢
AvatarGroup 93.36KB 93.31KB -44B 🟢
Badge 43.17KB 43.15KB -22B 🟢
BreadcrumbItem 64.72KB 64.7KB -26B 🟢
BreadcrumbMenu 68.57KB 68.64KB +76B 🔺
BreadcrumbMenuItem 77.15KB 77.13KB -22B 🟢
BreadcrumbsBar 5.69KB 5.69KB -1B 🟢
ButtonGroup 68.38KB 68.4KB +29B 🔺
Checkbox 66.9KB 66.91KB +12B 🔺
Chips 75.21KB 75.1KB -111B 🟢
ColorPicker 74.57KB 74.46KB -113B 🟢
ColorPickerContent 73.84KB 73.72KB -121B 🟢
Combobox 84.06KB 84.13KB +69B 🔺
Counter 42.23KB 42.24KB +13B 🔺
DatePicker 113.55KB 113.49KB -57B 🟢
Divider 5.44KB 5.45KB +16B 🔺
Dropdown 96.4KB 96.38KB -18B 🟢
EditableHeading 66.6KB 66.69KB +92B 🔺
EditableText 66.47KB 66.43KB -40B 🟢
EmptyState 70.5KB 70.47KB -32B 🟢
ExpandCollapse 66.29KB 66.23KB -58B 🟢
FormattedNumber 5.82KB 5.83KB +8B 🔺
GridKeyboardNavigationContext 4.66KB 4.65KB -16B 🟢
HiddenText 5.42KB 5.4KB -29B 🟢
Info 72.09KB 72.16KB +74B 🔺
Label 68.66KB 68.74KB +77B 🔺
Link 14.9KB 14.91KB +10B 🔺
List 72.97KB 72.92KB -57B 🟢
ListItem 65.52KB 65.52KB -3B 🟢
ListItemAvatar 66.94KB 66.91KB -32B 🟢
ListItemIcon 14.01KB 14KB -10B 🟢
ListTitle 65.01KB 65.05KB +41B 🔺
Menu 8.63KB 8.62KB -11B 🟢
MenuDivider 5.57KB 5.56KB -17B 🟢
MenuGridItem 7.21KB 7.19KB -12B 🟢
MenuItem 77.01KB 77.1KB +90B 🔺
MenuItemButton 70.11KB 70.19KB +84B 🔺
MenuTitle 65.41KB 65.33KB -76B 🟢
MenuButton 66.15KB 66.09KB -68B 🟢
Modal 79.2KB 79.19KB -16B 🟢
ModalContent 4.73KB 4.71KB -12B 🟢
ModalHeader 65.82KB 65.84KB +22B 🔺
ModalMedia 7.5KB 7.49KB -10B 🟢
ModalFooter 67.64KB 67.77KB +137B 🔺
ModalFooterWizard 68.71KB 68.66KB -48B 🟢
ModalBasicLayout 8.94KB 8.91KB -31B 🟢
ModalMediaLayout 8.09KB 8.08KB -10B 🟢
ModalSideBySideLayout 6.33KB 6.29KB -39B 🟢
MultiStepIndicator 52.84KB 52.94KB +102B 🔺
NumberField 72.87KB 72.92KB +52B 🔺
ProgressBar 7.35KB 7.34KB -10B 🟢
RadioButton 65.92KB 65.91KB -10B 🟢
Search 70.69KB 70.61KB -81B 🟢
Skeleton 6.01KB 6.02KB +7B 🔺
Slider 73.9KB 73.94KB +43B 🔺
SplitButton 66.51KB 66.51KB -2B 🟢
SplitButtonMenu 8.77KB 8.79KB +24B 🔺
Steps 71.38KB 71.36KB -18B 🟢
Table 7.28KB 7.24KB -32B 🟢
TableBody 66.82KB 66.73KB -85B 🟢
TableCell 65.27KB 65.19KB -76B 🟢
TableContainer 5.33KB 5.32KB -2B 🟢
TableHeader 5.65KB 5.64KB -4B 🟢
TableHeaderCell 72.25KB 72.24KB -18B 🟢
TableRow 5.56KB 5.55KB -14B 🟢
TableRowMenu 68.9KB 68.91KB +16B 🔺
TableVirtualizedBody 71.44KB 71.43KB -12B 🟢
Tab 64.02KB 63.98KB -40B 🟢
TabList 8.86KB 8.87KB +11B 🔺
TabPanel 5.3KB 5.29KB -4B 🟢
TabPanels 5.86KB 5.84KB -24B 🟢
TabsContext 5.47KB 5.51KB +33B 🔺
TextArea 66.35KB 66.4KB +51B 🔺
TextField 69.44KB 69.39KB -45B 🟢
TextWithHighlight 64.36KB 64.29KB -69B 🟢
ThemeProvider 4.37KB 4.36KB -8B 🟢
Tipseen 71.29KB 71.22KB -77B 🟢
TipseenContent 71.61KB 71.73KB +128B 🔺
TipseenMedia 71.37KB 71.35KB -18B 🟢
TipseenWizard 73.91KB 73.94KB +35B 🔺
Toast 74.07KB 74.11KB +35B 🔺
ToastButton 18.64KB 18.61KB -33B 🟢
ToastLink 15.11KB 15.08KB -29B 🟢
Toggle 66.67KB 66.67KB +6B 🔺
TransitionView 5.45KB 5.44KB -7B 🟢
VirtualizedGrid 12.55KB 12.55KB -2B 🟢
VirtualizedList 12.29KB 12.29KB +4B 🔺
List (Next) 8.18KB 8.15KB -35B 🟢
ListItem (Next) 70.02KB 69.91KB -115B 🟢
ListTitle (Next) 65.33KB 65.34KB +13B 🔺

📊 Summary:

  • Total Base Size: 4.76MB
  • Total PR Size: 4.76MB
  • Total Difference: 870B

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.

2 participants