perf(ui): one tooltip provider for the app, not one per tooltip - #194
Merged
Conversation
added 2 commits
August 14, 2026 01:40
rendering a thousand rows at once Studio shipped a FlexSearch index, a worker handler, a `searchContent` function and an `@modelId query` parser — and nothing in `app/` ever called any of it. The palette's own help promised "search models, entries, vocabulary" and "@modelId to search within a model"; neither searched an entry. This wires the engine up rather than writing one. ## Two bugs in the engine, found on the way - **The model filter was applied after the limit.** The worker asked the index for `limit` hits and then dropped the ones from other models, so a search scoped to `articles` competed for those slots against every other model — and could come back empty while matching. - **Locale was stored and never filtered.** A Turkish list got English hits. Both are now decided before the cut, in `app/utils/search-results.ts`, where the order of operations can be stated and tested. Duplicate ids are dropped too: FlexSearch returns one set per indexed field, and the same entry was eating several slots. ## The list A search box that queries the index, so a match on page twenty is found without paging there. Results intersect with what this locale actually holds — the index is rebuilt per sync and can briefly name an entry the payload no longer has. Rendering is paged at 50. Every row is a `<details>` plus a stateful Radix dropdown plus three buttons; at 1000 articles that is the cost of opening a model, not the data. Virtualisation was the other option and was not taken: a new dependency, and rows change height when they expand. Paging leaves the row component untouched. "No matches" and "the index is not ready" are now different states. `searchContent` resolves to `[]` when the worker is absent, and reporting that as no results is a lie. ## The palette Entry hits are filled into their own ref and appended, because `buildResults` is synchronous and search is not. They go last, and the selection resets on the synchronous results rather than the final list, so an async hit landing does not yank the cursor from under someone already arrowing down. `SearchResult` carries no title, so each hit is read back and titled through the same resolver the list uses — without it the palette would list `f3a81c09d24e`. Selecting one navigates with `?entry=`, and the list pulls that entry to the front and opens it. Opening the model alone would have handed someone a thousand rows to find it in, which is what they searched to avoid. Also: the dictionary filter's placeholder said "Filter keys..." while the filter has always searched keys AND values.
T08b, the half of T08 held back until the list stopped rendering every row. #193 pages at 50, so this is now safe to land. `AtomsTooltip` carried its own `TooltipProvider`, which a paged list turns into 150 of them — 50 rows times three action icons. The count is the smaller problem. Radix's `skipDelayDuration` only applies within a single provider, so with one provider per tooltip it never applied at all: moving from the edit icon to the delete icon re-paid the full open delay every time. Scanning a row's icons is precisely what the tooltips were added for. Hoisted, the second and third open instantly. `TooltipRoot` throws without a provider and Radix exports no way to ask whether one is present, so the app root sets a flag. The flag and the provider live in one component — `AtomsTooltipScope` — because a flag provided without the provider it describes makes every tooltip in the app throw, and binding them together means a later edit cannot separate them. Outside that scope the atom still supplies its own, so a component mounted alone in a test keeps working. Delays move to `app/utils/tooltip-timing.ts`. There were three: 200ms in `InfoTooltip`, 300ms in `ContentStatsBar`, and Radix's own 700ms default. Which one you got depended on which component you hovered. Also verified, rather than assumed: `.reveal-on-hover` from #189 does what it claimed. In the built CSS the base hide sits at byte 10298 in the components layer, its `@media (hover: none)` override immediately after at 10332, and `group-hover/entry:opacity-60` at 184921 in utilities — so touch turns the icons on, hover still reveals them, and neither depends on how Tailwind sorts variants.
ABB65
force-pushed
the
feat/tooltip-provider-hoist
branch
from
August 13, 2026 22:43
152d64b to
a38ce56
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes T08b — the half of T08 the plan held back until the list stopped rendering every row. Stacked on #193, which pages at 50, so this is now safe to land.
What was actually wrong
AtomsTooltipcarried its ownTooltipProvider. A paged list turns that into 150 providers — 50 rows × three action icons.The count is the smaller problem. Radix's
skipDelayDurationonly applies within a single provider, so with one provider per tooltip it never applied at all: moving from the edit icon to the delete icon re-paid the full open delay every time. Scanning a row's icons is precisely what the tooltips were added for.Hoisted, the second and third open instantly.
Why there is a flag, and why it lives with the provider
TooltipRootthrows without a provider, and Radix exports no way to ask whether one is present —injectTooltipProviderContextis not in the package's public surface. So the app root sets a flag the atom can read.The flag and the provider are one component,
AtomsTooltipScope. A flag provided without the provider it describes makes every tooltip in the app throw; binding them together means a later edit cannot separate them. My first attempt did separate them, and the test caught it — that test now mounts the real scope and asserts two tooltips, one provider.Outside the scope the atom still supplies its own, so a component mounted alone in a test keeps working.
Delays
Moved to
app/utils/tooltip-timing.ts. There were three: 200ms inInfoTooltip, 300ms inContentStatsBar, and Radix's own 700ms default for anything that used neither. Which delay you got depended on which component you happened to hover.Verified — including a claim I shipped unverified in #189
.reveal-on-hover(the touch-discoverability fix) rested on a cascade argument I reasoned about but did not check. I built the app and read the CSS:.reveal-on-hover{opacity:0}@media(hover:none){.reveal-on-hover{opacity:1}}.group-hover\/entry\:opacity-60So touch turns the icons on, hover still reveals them, and neither depends on how Tailwind sorts variants. The claim holds; now it is checked rather than argued.
The build also confirms the whole stack (#191 → #192 → #193 → this) compiles.
Gates:
pnpm lint0 errors ·pnpm typecheckclean ·pnpm test145 files / 1243 tests.Not verified — needs a browser