Skip to content

feat(require-profiler-plugin): migrate the panel to @rozenite/ui - #453

Merged
V3RON merged 5 commits into
mainfrom
claude/require-profiler-refactor-7nu8nb
Aug 31, 2026
Merged

feat(require-profiler-plugin): migrate the panel to @rozenite/ui#453
V3RON merged 5 commits into
mainfrom
claude/require-profiler-refactor-7nu8nb

Conversation

@V3RON

@V3RON V3RON commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Description

Rebuilds the Require Profiler DevTools panel on @rozenite/ui and moves its flame graph into the design system.

  • @rozenite/ui gains a FlameGraph component. Percentage-based DOM frames with animated click-to-zoom, Escape to zoom out, selection, search highlighting, and a FlameGraph.Legend for the heat buckets. Coloured from the tone tokens, so it reads correctly in both themes. This replaces the third-party react-flame-graph dependency and the local react-flame-graph.d.ts shim.
  • The panel is rebuilt on PluginShell, Split, Sidebar, Tabs, SearchField, EmptyState, DescriptionList and VirtualizedDataTable. The 781-line stylesheet and the hand-rolled Header, InfoBar, Legend, Sidebar, LoadingState, EmptyState and OptionsModal are gone; globals.css is now a single @import '@rozenite/ui/styles.css'.
  • Chains are browsable instead of steppable. A sidebar lists every recorded chain with its duration and module count, replacing the prev/next arrows and the options modal. Because durations now travel with the chain list, the duration threshold applies to chains that have not been opened yet — previously an unloaded chain could never pass the filter.
  • A "Top modules" table ranking the chain's modules by self time, and module search that highlights matching frames in the graph and narrows the table.
  • Higher-resolution timings. The recorder now uses performance.now() where the runtime provides it, falling back to Date.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.
  • The Metro wrapper defends its own dev-only boundary. withRozeniteRequireProfiler takes an enabled option defaulting to process.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's enabled option: when it is false, withRozenite returns the config untouched, enhanceMetroConfig never runs, and the profiler wrapper is never reached. That covers the documented enabled: 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 dev flag to getPolyfills/getRunModuleStatement and the wrapper therefore cannot tell dev from production by itself: a config that sets enabled: true unconditionally (apps/playground/metro.config.js is exactly that shape), applying withRozeniteRequireProfiler standalone without withRozenite at all (the pattern its own JSDoc shows), and leaving enabled undefined, where the only guard is the isBundling() process.argv heuristic.

The __DEV__ guard is not just inert-in-production, it is removed: verified against the installed Metro 0.84.4 that polyfills go through metro-transform-worker's transformJS, that inlinePlugin is applied unconditionally there and substitutes __DEV__ with the literal false when dev: false, and that the constantFoldingPlugin pass then strips the dead if — independent of minify. The prelude that defines __DEV__ is emitted before polyfills, so the typeof check keeps it safe in a dev build too. When the body is skipped, the __patchSystrace() prepend is already a no-op because it guards with typeof __patchSystrace === "function".

On the flame graph rendering. It is deliberately pure DOM and CSS percentages — no canvas, no SVG, no ResizeObserver or getBoundingClientRect. Frames are absolutely positioned buttons with left/width in percent, which makes the graph responsive without measuring anything and makes every frame tabbable and activatable for free. Frames use native title tooltips rather than the Tooltip component: 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 left and width, with no new dependency. This works because of how the layout is keyed: ancestors keep their original depth and the focused subtree starts at ancestors.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 by motion-reduce. The tradeoff worth knowing: left/width are 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's tooltip as well as its name, 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 than Toolbar, because a tab list inside a toolbar would nest two composite keyboard-navigation roots; every control inside it is still a @rozenite/ui primitive.

On the wire format. RequireChainMeta now carries duration, moduleCount and startedAt, and tree nodes carry selfTime. moduleCount is tracked incrementally as nodes are pushed and duration is read off the root node's already-computed value, so neither read walks the tree. startedAt is a plain Date.now() wall-clock stamp, kept deliberately separate from the high-resolution clock; the clock resolved at beginEvent is stored on the stack entry so the matching endEvent measures 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 --check clean.
  • 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.
  • Builds for both packages.

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 unmatched focusedKey, 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 follows NODE_ENV; the input config is not mutated.
  • packages/require-profiler-plugin/src/__tests__/metro-setup.test.ts — evaluates setup.js in a sandbox and asserts that none of the globals are defined with __DEV__ === false and all are with __DEV__ === true, then drives the recorder end-to-end through nested JS_require_* events against a fake systrace and module registry. Timing is made deterministic with an injected performance.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.tsx adds Default, WithLegend, Highlighted and Empty.

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.

claude added 2 commits August 22, 2026 07:36
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
@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
rozenite Skipped Skipped Aug 23, 2026 7:16pm

Request Review

@V3RON V3RON changed the title feat(require-profiler-plugin): migrate the panel to @rozenite/ui feat(require-profiler-plugin): migrate the panel to @rozenite/ui and add analysis views Aug 22, 2026
@V3RON
V3RON force-pushed the claude/require-profiler-refactor-7nu8nb branch from aca3383 to 94c4c8e Compare August 22, 2026 16:23
@V3RON V3RON changed the title feat(require-profiler-plugin): migrate the panel to @rozenite/ui and add analysis views feat(require-profiler-plugin): migrate the panel to @rozenite/ui Aug 22, 2026
- 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).
t and others added 2 commits August 31, 2026 12:00
… @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
V3RON merged commit 05939d7 into main Aug 31, 2026
4 checks passed
@V3RON
V3RON deleted the claude/require-profiler-refactor-7nu8nb branch August 31, 2026 10:23
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(require-profiler-plugin): migrate the panel to @rozenite/ui

2 participants