mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
31d49f0dfc
commit
bbfc4df357
2 changed files with 65 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue