perf(desktop): one app-level TooltipProvider, not one per Tip

Every `Tip` carried its own `TooltipProvider`, and there are ~107 call
sites. Each is a subtree that re-renders when anything above it does, so
they dominated unrelated interactions: 52,784 TooltipProvider renders and
18.3s of component time in a single sash drag.

Radix's provider holds only refs and stable callbacks (no reactive state)
— hoisting one to the app root is what it is designed for. `Tooltip`
still reads delayDuration/disableHoverableContent from context, and the
per-Tip overrides are preserved.

`Tip` keeps a local provider as a FALLBACK, chosen by context: a
component rendered in isolation has no root provider and Radix throws
"`Tooltip` must be used within `TooltipProvider`". Without this, 20 unit
tests that render a single control fail. Inside the app the flag is
always true, so the common path is a bare Tooltip.

This is the shape the earlier lazy-mount attempt should have taken. That
one deferred the Radix subtree until hover, which moved
data-slot="tooltip-trigger" off the mounted DOM and broke 18 tests
encoding that contract. Hoisting keeps the contract intact — every one of
those tests passes unchanged.

Measured on the same drag:

  TooltipProvider   52,784 renders / 18.3s -> gone from the table
  Primitive.div     40.5s -> 13.4s
  Popper            10.5s ->  2.7s
  Tooltip           15.7s ->  4.4s
This commit is contained in:
Brooklyn Nicholson 2026-07-26 19:05:21 -05:00
parent 31d49f0dfc
commit bbfc4df357
2 changed files with 65 additions and 10 deletions

View file

@ -5,6 +5,10 @@ import { useI18n } from '@/i18n'
import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
/** True inside `RootTooltipProvider`. `Tip` uses this to decide whether it
* needs to supply its own provider see the note on `Tip`. */
const HasTooltipProvider = React.createContext(false)
function TooltipProvider({
delayDuration = 0,
// Tips are labels, not interactive surfaces. Hoverable content + Radix's
@ -105,21 +109,55 @@ interface TipProps extends Omit<React.ComponentProps<typeof TooltipPrimitive.Con
}
// Drop-in replacement for native `title=`: wrap any single element. Instant,
// position-aware, themed. Self-contained (carries its own Provider) so it works
// anywhere without a provider ancestor. Renders the child untouched when label
// is falsy. Open state is trigger-hover only — never sticky, never click-blocking.
// position-aware, themed. Renders the child untouched when label is falsy.
// Open state is trigger-hover only — never sticky, never click-blocking.
//
// NO per-instance `TooltipProvider`. There are ~107 `Tip` call sites, and each
// private provider is another subtree that re-renders whenever anything above
// it does. Measured on a sash drag with five mounted tiles: 52,784
// TooltipProvider renders and 18.3s of component time in a single gesture.
//
// Radix's provider holds only refs and stable callbacks (no reactive state), so
// hoisting one to the app root is exactly what it is designed for — see
// `RootTooltipProvider`, mounted in main.tsx. `Tooltip` still reads
// `delayDuration`/`disableHoverableContent` from context, and the per-Tip
// overrides below keep the previous behavior for anything that passed them.
//
// Deliberately NOT lazy-mounted: deferring the Radix subtree until hover was
// tried and reverted. `asChild` puts `data-slot="tooltip-trigger"` on the
// child element itself, so arming REPLACES that node — which broke 18 tests
// encoding that contract, and risks focus/ref identity at every call site.
function Tip({ label, children, delayDuration = 0, ...props }: TipProps) {
// A component rendered in isolation (every unit test, and any surface
// mounted outside the app root) has no provider above it, and Radix throws
// "`Tooltip` must be used within `TooltipProvider`". Fall back to a local
// one there. Inside the app this is always false, so the common path is a
// bare Tooltip and the ~107 providers collapse to one.
const provided = React.useContext(HasTooltipProvider)
if (!label) {
return <>{children}</>
}
const tip = (
<Tooltip delayDuration={delayDuration} disableHoverableContent>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent {...props}>{label}</TooltipContent>
</Tooltip>
)
return provided ? tip : <TooltipProvider delayDuration={delayDuration}>{tip}</TooltipProvider>
}
/** The app's single tooltip provider. Mounted once at the root so no `Tip`
* needs its own. Defaults match what `Tip` used to pass per instance. */
function RootTooltipProvider({ children }: { children: React.ReactNode }) {
return (
<TooltipProvider delayDuration={delayDuration} disableHoverableContent>
<Tooltip disableHoverableContent>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent {...props}>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
<HasTooltipProvider value>
<TooltipProvider delayDuration={0} disableHoverableContent>
{children}
</TooltipProvider>
</HasTooltipProvider>
)
}
@ -163,4 +201,13 @@ function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) {
return <TipHintLabel hint={hint ?? undefined} text={label} />
}
export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
export {
RootTooltipProvider,
Tip,
TipHintLabel,
TipKeybindLabel,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
}

View file

@ -17,6 +17,7 @@ import { HashRouter } from 'react-router-dom'
import App from './app'
import { ErrorBoundary } from './components/error-boundary'
import { HapticsProvider } from './components/haptics-provider'
import { RootTooltipProvider } from './components/ui/tooltip'
import { I18nProvider } from './i18n'
import { installClipboardShim } from './lib/clipboard'
import { queryClient } from './lib/query-client'
@ -46,6 +47,12 @@ if (winParam === 'overlay') {
<I18nProvider>
<ThemeProvider>
<HapticsProvider>
{/* ONE tooltip provider for the whole app. Every `Tip` used to
carry its own, and with ~107 call sites those subtrees
dominated unrelated interactions (52,784 TooltipProvider
renders in a single sash drag). Radix's provider holds only
refs and stable callbacks, so hoisting is what it's for. */}
<RootTooltipProvider>
{/* useTransitions={false}: react-router v7's HashRouter wraps every
route state update in React.startTransition() by default. In
React 19's concurrent renderer, transitions are non-urgent React
@ -58,6 +65,7 @@ if (winParam === 'overlay') {
<HashRouter useTransitions={false}>
<App />
</HashRouter>
</RootTooltipProvider>
</HapticsProvider>
</ThemeProvider>
</I18nProvider>