From bbfc4df3577e115b46b3d4ebf3f87d50330db8a5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 19:05:21 -0500 Subject: [PATCH] perf(desktop): one app-level TooltipProvider, not one per Tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/desktop/src/components/ui/tooltip.tsx | 67 ++++++++++++++++++---- apps/desktop/src/main.tsx | 8 +++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/components/ui/tooltip.tsx b/apps/desktop/src/components/ui/tooltip.tsx index fe16e3b97cfc..226bd9472b3f 100644 --- a/apps/desktop/src/components/ui/tooltip.tsx +++ b/apps/desktop/src/components/ui/tooltip.tsx @@ -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{children} } + const tip = ( + + {children} + {label} + + ) + + return provided ? tip : {tip} +} + +/** 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 ( - - - {children} - {label} - - + + + {children} + + ) } @@ -163,4 +201,13 @@ function TipKeybindLabel({ actionId, text }: TipKeybindLabelProps) { return } -export { Tip, TipHintLabel, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +export { + RootTooltipProvider, + Tip, + TipHintLabel, + TipKeybindLabel, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 969b5c510d93..3d1c164b6dea 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -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') { + {/* 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. */} + {/* 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') { +