feat(require-profiler-plugin): migrate the panel to @rozenite/ui - #453
Merged
Conversation
Rebuilds the Require Profiler panel on the shared design system and moves its flame graph into `@rozenite/ui` so other plugins can use it. - Add a `FlameGraph` component to `@rozenite/ui`: percentage-based DOM frames with zooming, selection, highlighting and a heat legend, coloured from the design tokens so it themes in both light and dark. Replaces the `react-flame-graph` dependency and its local type shim. - Rebuild the panel on `PluginShell`, `Split`, `Sidebar`, `Tabs`, `SearchField`, `EmptyState` and `VirtualizedDataTable`, and delete the bespoke stylesheet and hand-rolled chrome. - List every recorded chain with its duration and module count, so the duration threshold no longer has to load a chain before it can filter it out. - Add a top-modules table ranked by self time, module search that highlights matching frames, and a detail pane for the selected module. - Record timings with `performance.now()` where the runtime provides it, so fast modules no longer all report 0ms. - Give the Metro wrapper its own dev-only defaults underneath `withRozenite`'s `enabled` gate: an `enabled` option defaulting to production-off, and a `__DEV__` guard around the polyfill body that Metro strips from release bundles. Closes #452 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
Searching washed out non-matching frames with `opacity-30`, which faded their labels along with their backgrounds, and marked matches only by the absence of that dimming. A matching frame with no self time sits in the palest heat bucket, so it could end up reading as quieter than the dimmed frames around it — the encoding was effectively inverted for pure container frames. - Wash out non-matching frames by lowering the background alpha per heat bucket instead of the element's opacity, so labels stay readable and the graph keeps its shape and rough weighting. - Outline matching frames and emphasise their labels, so a match stands out on its own rather than by contrast with its neighbours. - Match the search term against each frame's tooltip as well as its name. The name is usually a basename, so path queries previously highlighted nothing in the graph while correctly narrowing an accompanying list view. - Transition frame position and width, which animates zooming: a frame that survives a focus change keeps its React key and row, so only its `left` and `width` change. Guarded by `motion-reduce`, and no new dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
V3RON
force-pushed
the
claude/require-profiler-refactor-7nu8nb
branch
from
August 22, 2026 16:23
aca3383 to
94c4c8e
Compare
- Truncate long chain/module paths from the start (canvas-measured, resize-aware) so the meaningful tail stays visible instead of node_modules/... or an overflowing table row. - Cap the module column width in the top-modules table so long paths no longer push the timing columns off-screen; full path is still shown in the module detail sidepanel. - Remove the flame graph legend and the redundant total/module count label from the toolbar. - Add a close button to the module detail sidepanel header. - Fix the duration filter select showing the raw value instead of its label. - Give the sticky table header a background so it doesn't blend with scrolled rows, and fix inactive/active tab styling (base-ui marks the active tab with data-active, not data-selected).
… @rozenite/metro This package's tsconfig now sets `customConditions: ["development"]`, so `@rozenite/metro` resolves to source and drags the `@rozenite/middleware` sources into this package's TypeScript program, where they compile against options this package does not set. `typecheck` then fails on eight errors in files the plugin does not own. Guard `withRozeniteRequireProfiler` directly instead, the way `packages/redux-devtools-plugin` already does. The disabled case now exercises the plugin's own `enabled: false` path; the `withRozenite` wiring it used to cover is covered by @rozenite/metro's own suite. Neither half is broken alone -- the condition landed here, the import landed on main -- so this only ever failed on the merge commit CI builds. Claude-Session: https://claude.ai/code/session_01AEzqE9P3sGFinTPJGK8CBi
V3RON
added a commit
that referenced
this pull request
Aug 31, 2026
… duplicate detection (#454) ## Description > **Stacked on #453.** Base is `claude/require-profiler-refactor-7nu8nb`, so the diff shown here is only the analysis views. GitHub will retarget this to `main` automatically once #453 merges. The profiler could show *what* ran and *how long* it took. These three views answer the questions you actually act on afterwards. - **Package rollup.** A "Group by" control switches the top-modules table between modules and npm packages — the granularity dependency decisions are actually made at. One row reading `lodash — 340ms across 87 modules` is actionable where 87 four-millisecond rows are not. - **Require chains.** Selecting a module shows the chain that pulled it in, root-first and clickable. This is the question every profiling session ends on, and previously it was answerable only by reading ancestors off the flame graph by eye. - **Duplicate detection.** Flags packages evaluated from more than one install location. A duplicated dependency costs evaluation time and bundle bytes twice, and with a stateful library two live copies can break behaviour outright. A fourth view, **bundle coverage**, was built and then removed — see the last commit and the note at the end of Context. ## Related Issue Follows up #452, which #453 closes. ## Context **On two numbers per package, not one.** Summing `value` across a package's modules would double-count, because a parent's `value` already contains its children's. So each package reports two separately well-defined figures: `selfTime` (Σ of member self times — exactly additive, since self times partition total time) and `inclusiveTime` (Σ of `value` over *entry* nodes only, where an entry is a member with no same-package ancestor at **any** depth, tracked with an open-ancestor count during the walk). A package nested inside itself, or entered at several points in the tree, is counted once rather than compounded by its own descendants. There is a test for exactly this: `lodash → src/glue.ts → lodash` must report `inclusiveTime: 100`, not 140. **On package-name parsing.** The **last** `node_modules` segment wins, so a nested copy resolves to the inner package — the copy actually evaluated. Matching is on whole path segments, so a directory like `my_node_modules_helper` never false-positives. Scoped packages take two segments. **On the require-chain lookup.** Breadth-first with parent pointers rather than a depth-first walk carrying an ancestor array: BFS order makes the shallowest occurrence the first one found and gives a stable tie-break between equally shallow matches, and the parent pointers reconstruct the chain without copying an array at every node. `occurrences` counts every evaluation of the module across the whole tree, not just those on the returned path. **On the bundle coverage tab, and why it is gone.** It read Metro's module registry on demand and ranked packages by how many of their modules had not been evaluated yet. The read itself works — `getModules()` and `isInitialized` are real and correct — but the number cannot support the decision the tab invited: - *It is not reproducible.* React Native sets `inlineRequires: true` by default, so a module is evaluated the first time its binding is **used** — during a render, on navigation, on a tap. Coverage therefore climbs the longer you use the app before opening the tab. Two people on the same commit get different numbers and different rankings, with nothing to say which is right. The tab's caveat copy admitted the snapshot was a moment in time, but a disclaimer does not make a ranking stable. - *It cannot isolate the signal worth having.* Metro does not tree shake, so genuinely dead imports really do ride along in the bundle. From a single registry read, those are indistinguishable from a module that is merely lazy and has not been reached yet. - *The call to action does not exist on this platform.* `import()` compiles to `asyncRequire`, which calls `require.importAll` against the **same** bundle unless `__loadBundleAsync` is installed (Re.Pack, or a lazy serializer). In a stock app, moving a package behind a dynamic import on this evidence removes zero bytes and zero startup time — the deferral it would buy is exactly what the row already reports. - *It is the wrong axis for this plugin.* A require profiler's unit is milliseconds, and an unevaluated module costs zero by definition. Its real cost is bytes, which the tab neither measured nor could measure — it counted modules as a proxy. The top rows of a startup-time tool were the things costing nothing. Removed with it: the `getBundleModules` polyfill reader, its two wire messages and types, and the registry-wide duplicate detection. The **evaluated-set** duplicate detection stays — two live copies of a stateful library is a real finding, measured off real evaluations. **On reuse.** `findDuplicatePackages` takes a plain iterable of module paths rather than a require tree, so any caller with paths to hand reuses the grouping logic without reimplementing path parsing. ## Testing From the repository root after `git fetch origin main`: - `pnpm checks:affected` — 90 tasks green; `pnpm format:all` clean across 1320 files. - `pnpm test:affected` — 51 tasks green. - `pnpm --filter @rozenite/require-profiler-plugin test` — 55 tests across 6 files. Coverage: - `src/ui/analysis/__tests__/packages.test.ts` — path parsing (plain, scoped, nested copy resolving to the inner package, the `my_node_modules_helper` false positive, trailing `node_modules/`, empty string, leading `./`); the double-counting guard; a package entered at two points; a module evaluated twice counting once toward `moduleCount`; ordering; duplicate detection including application code never being reported and repeated input paths not manufacturing a false positive. - `src/ui/analysis/__tests__/require-path.test.ts` — full root-first chain with correct depths, target as root, absent module, null root, shallowest-wins across differing depths, occurrences counted off the returned path, deterministic tie-break. Not done: none of this has been exercised against a running device — no simulator was available in this environment, so the require-chain breadcrumb and the duplicate alert are verified by types, unit tests and reasoning rather than seen. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: t <t@t>
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.
Description
Rebuilds the Require Profiler DevTools panel on
@rozenite/uiand moves its flame graph into the design system.@rozenite/uigains aFlameGraphcomponent. Percentage-based DOM frames with animated click-to-zoom,Escapeto zoom out, selection, search highlighting, and aFlameGraph.Legendfor the heat buckets. Coloured from the tone tokens, so it reads correctly in both themes. This replaces the third-partyreact-flame-graphdependency and the localreact-flame-graph.d.tsshim.PluginShell,Split,Sidebar,Tabs,SearchField,EmptyState,DescriptionListandVirtualizedDataTable. The 781-line stylesheet and the hand-rolledHeader,InfoBar,Legend,Sidebar,LoadingState,EmptyStateandOptionsModalare gone;globals.cssis now a single@import '@rozenite/ui/styles.css'.performance.now()where the runtime provides it, falling back toDate.now().Date.now()'s whole-millisecond granularity is why most modules reported 0ms and why the old panel needed a synthetic minimum-width hack to render anything at all.withRozeniteRequireProfilertakes anenabledoption defaulting toprocess.env.NODE_ENV !== 'production', and the polyfill body is wrapped in__DEV__.Four analysis views built on top of this — package rollup, require chains, duplicate detection and bundle coverage — are stacked in a follow-up PR so this one stays reviewable on its own.
Related Issue
Closes #452
Context
On the dev-only work. The existing gate is
withRozenite'senabledoption: when it isfalse,withRozenitereturns the config untouched,enhanceMetroConfignever runs, and the profiler wrapper is never reached. That covers the documentedenabled: process.env.WITH_ROZENITE === 'true'wiring and remains the gate to rely on — nothing here changes or second-guesses it.The two layers added underneath it cover the cases that sit outside that gate, since Metro passes no
devflag togetPolyfills/getRunModuleStatementand the wrapper therefore cannot tell dev from production by itself: a config that setsenabled: trueunconditionally (apps/playground/metro.config.jsis exactly that shape), applyingwithRozeniteRequireProfilerstandalone withoutwithRozeniteat all (the pattern its own JSDoc shows), and leavingenabledundefined, where the only guard is theisBundling()process.argvheuristic.The
__DEV__guard is not just inert-in-production, it is removed: verified against the installed Metro 0.84.4 that polyfills go throughmetro-transform-worker'stransformJS, thatinlinePluginis applied unconditionally there and substitutes__DEV__with the literalfalsewhendev: false, and that theconstantFoldingPluginpass then strips the deadif— independent ofminify. The prelude that defines__DEV__is emitted before polyfills, so thetypeofcheck keeps it safe in a dev build too. When the body is skipped, the__patchSystrace()prepend is already a no-op because it guards withtypeof __patchSystrace === "function".On the flame graph rendering. It is deliberately pure DOM and CSS percentages — no canvas, no SVG, no
ResizeObserverorgetBoundingClientRect. Frames are absolutely positioned buttons withleft/widthin percent, which makes the graph responsive without measuring anything and makes every frame tabbable and activatable for free. Frames use nativetitletooltips rather than theTooltipcomponent: a profile can hold thousands of frames and one base-ui popup root per frame would be far too heavy.Zooming is animated by transitioning
leftandwidth, with no new dependency. This works because of how the layout is keyed: ancestors keep their original depth and the focused subtree starts atancestors.length, which equals the focused node's own depth, so a frame that survives a focus change keeps both its React key and its row and only its position and width change. Frames that are culled or revealed mount and unmount without animation. It is guarded bymotion-reduce. The tradeoff worth knowing:left/widthare layout properties rather than compositor-friendly ones, so this animates on the main thread — fine at 150ms with sub-threshold frames already culled, but a pathological tree with thousands of visible frames would feel it. The GPU-friendly alternative (transform: scaleX) would distort the labels.On the search treatment. Non-matching frames lower their background alpha per heat bucket rather than the element's
opacity, which would fade the label with it. Matching frames are outlined and their labels emphasised, rather than being marked only by the absence of dimming: a matching frame with no self time sits in the palest bucket, so on its own "not washed out" can read as less prominent than the frames around it. The term is matched against each frame'stooltipas well as itsname, since the name is usually a basename and the panel's top-modules table filters on the full path.On the panel layout. No
PluginHeader, following the convention that the DevTools shell already shows the plugin name. The header bar holding the tab list is a plain element rather thanToolbar, because a tab list inside a toolbar would nest two composite keyboard-navigation roots; every control inside it is still a@rozenite/uiprimitive.On the wire format.
RequireChainMetanow carriesduration,moduleCountandstartedAt, and tree nodes carryselfTime.moduleCountis tracked incrementally as nodes are pushed anddurationis read off the root node's already-computed value, so neither read walks the tree.startedAtis a plainDate.now()wall-clock stamp, kept deliberately separate from the high-resolution clock; the clock resolved atbeginEventis stored on the stack entry so the matchingendEventmeasures with the same one and the two are never mixed.Testing
From the repository root after
git fetch origin main:pnpm checks:affected— 102 tasks, typecheck + lint green,oxfmt --checkclean.pnpm test:affected— 64 tasks green.pnpm --filter @rozenite/require-profiler-plugin test— 27 tests across 4 files.pnpm --filter @rozenite/ui test— 64 tests across 11 files, 24 of them new.New coverage:
packages/ui/src/flame-graph/flame-graph-utils.test.ts— self-value derivation and clamping, key stability and uniqueness, max-self-value search, heat bucket boundaries, three-level layout offsets and widths, zooming into a nested node (ancestors full width, subtree rescaled to 100), culling of sub-threshold frames along with their descendants, the all-zero-value even-distribution fallback, an unmatchedfocusedKey, and search matching against both name and tooltip.packages/require-profiler-plugin/src/__tests__/metro-config-transformer.test.ts— the polyfill and run-module hooks are added and still delegate to the pre-existing implementations when enabled; both are returned identity-equal and untouched when disabled; the default followsNODE_ENV; the input config is not mutated.packages/require-profiler-plugin/src/__tests__/metro-setup.test.ts— evaluatessetup.jsin a sandbox and asserts that none of the globals are defined with__DEV__ === falseand all are with__DEV__ === true, then drives the recorder end-to-end through nestedJS_require_*events against a fake systrace and module registry. Timing is made deterministic with an injectedperformance.now.packages/require-profiler-plugin/src/ui/__tests__/aggregations.test.ts— tree-to-frame mapping, module aggregation ordering and folding of repeated evaluations, chain filtering against the metadata durations, and duration formatting.Storybook:
apps/ui-storybook/src/stories/FlameGraph.stories.tsxaddsDefault,WithLegend,HighlightedandEmpty.Not done: the panel has not been exercised against a running device — no simulator was available in this environment, so the visual result is reasoned and tested rather than seen.