Skip to content

feat(require-profiler-plugin): add package rollup, require chains and duplicate detection - #454

Merged
V3RON merged 9 commits into
claude/require-profiler-refactor-7nu8nbfrom
claude/require-profiler-analysis-views
Aug 31, 2026
Merged

feat(require-profiler-plugin): add package rollup, require chains and duplicate detection#454
V3RON merged 9 commits into
claude/require-profiler-refactor-7nu8nbfrom
claude/require-profiler-analysis-views

Conversation

@V3RON

@V3RON V3RON commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

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.

claude added 6 commits August 23, 2026 21:21
Modules are individual files, but the decisions they inform are made per
package — dropping a dependency, replacing it, deferring it. Eighty-seven
lodash files at four milliseconds each are invisible in a file-sorted table;
one row reading "lodash, 340ms across 87 modules" is actionable.

Adds a Modules/Packages switch to the top-modules tab, backed by a package
rollup over the require tree. Package names come from the last `node_modules`
segment in a module's path, so a nested copy resolves to the copy actually
evaluated, and segment-wise matching keeps directories that merely contain the
text "node_modules" from being mistaken for one.

Each package reports two separately well-defined numbers rather than one
misleading total. `selfTime` sums its modules' own evaluation time, which is
exact because self times partition total time. `inclusiveTime` sums `value`
only over entry nodes — members with no same-package ancestor at any depth —
so a package nested inside itself, or entered at several points in the tree,
is counted once rather than compounded by its own descendants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
Every profiling session ends on the same question: what pulled this in?
Until now the only way to answer it was to hunt for the module in the flame
graph and read its ancestors off the stack visually.

Selecting a module now shows the require chain from the chain root down to it
in the detail pane, each ancestor clickable so the chain can be walked back
toward the root. When a module is evaluated at more than one point in the
chain, the shallowest occurrence is shown and the rest are noted.

The lookup is breadth-first with parent pointers rather than a depth-first
walk carrying an ancestor array: breadth-first 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
…ll locations

Shipping two copies of a dependency costs evaluation time and bundle bytes
twice over, and with a stateful library it can break outright — two module
registries, two singletons. The module paths already carry enough to spot it.

The packages view now warns when a package was evaluated from more than one
install location, listing each package and its roots. Detection groups the
distinct install roots per package name, so a path repeated across many
modules cannot manufacture a false positive, and application code is never
reported since it is not an installed package.

The detector takes a plain iterable of paths rather than a require tree, so
the same grouping can later run over the full bundle registry without
reimplementing path parsing at a second call site.

Its wording is deliberately narrow: this view only sees modules that were
actually evaluated, so it claims duplication in this chain rather than
duplication in the bundle, and it does not assert a saving it has not
measured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
The profiler could only ever show what did run. The more useful question for
startup work is the inverse: what shipped in the bundle and was never needed?
Those modules are the code-splitting and lazy-loading candidates, and until
now the panel had no way to name them.

Metro's module registry already knows. A new Coverage tab reads it on demand
and reports how much of the bundle was evaluated, then ranks packages by how
many of their modules have not run yet. Duplicate install locations are
detected here too, over the whole registry rather than only the evaluated
set, so a second copy that never ran is still surfaced.

The registry is read on demand rather than with the require chains: it is far
larger than a chain tree, and no panel load should pay for it unasked.

The wording is careful on purpose. The registry reports its state at the
moment it is read, not at profiling time, so a module evaluated after the
snapshot will read as evaluated on the next load. The tab therefore says
"not yet evaluated" rather than "unused", shows the capture time, and notes
that the total counts modules currently defined — with RAM bundles or lazy
segments it can grow later. Telling someone a module is safe to delete on
evidence that cannot support it is the one way this feature could do harm.

The reader lives inside the polyfill's `__DEV__` guard like the rest, with a
test asserting it is undefined when `__DEV__` is false, so it is stripped
from release bundles along with everything else here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
Covers the package rollup, require chains, duplicate detection and bundle
coverage in the plugin README and the website page, and adds the version
plan.

The coverage wording carries the same caveat as the panel: the module
registry is read on demand and reflects that moment rather than profiling
time, so it reports what has not been evaluated yet rather than what is
unused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
The repo-wide format check covers website/, but checks:affected scopes
formatting to affected packages and skips it, so the reflow this page needed
was missed locally and only surfaced in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E7tcjuP5YpJL8hrGk5SgvS
@V3RON
V3RON force-pushed the claude/require-profiler-analysis-views branch from aca3383 to ecd3d5f Compare August 23, 2026 19:23
The Coverage tab read Metro's module registry and ranked packages by how
many of their modules had not been evaluated yet. The read works, but the
number it produces cannot support the decision the tab invited.

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 the app is used
before the tab is opened: two people profiling the same commit get
different numbers and different rankings, with nothing to say which is
right. The caveat copy admitted the snapshot was a moment in time, but a
disclaimer does not make the ranking reproducible.

It also could not separate the signal worth having. Metro does not tree
shake, so genuinely dead imports do ride along in the bundle - but from a
single registry read they are indistinguishable from a module that is
merely lazy and has not been reached yet.

And the call to action does not exist here. `import()` compiles to
`asyncRequire`, which pulls from the same bundle unless `__loadBundleAsync`
is installed. In a stock app, moving a package behind a dynamic import on
this evidence removes no bytes and no startup time; the deferral it would
buy is what the row already reports. On a profiler whose unit is
milliseconds, the top rows were the modules costing zero.

The evaluated-set duplicate detection stays: two live copies of a stateful
library is a real finding measured off real evaluations. Registry-wide
duplicate detection goes with the rest, along with the `getBundleModules`
polyfill reader, its wire messages and its types.
@V3RON V3RON changed the title feat(require-profiler-plugin): add package rollup, require chains, duplicate detection and bundle coverage feat(require-profiler-plugin): add package rollup, require chains and duplicate detection Aug 24, 2026
A deeply required module produces a chain long enough to push the module's
own details - path, self time, total time - off the bottom of the details
pane, so selecting a module hid the numbers you selected it for.

The chain now scrolls within a fixed height. `Column`'s `scroll` prop makes
the region keyboard-focusable, so it stays reachable without a mouse.
@vercel

vercel Bot commented Aug 24, 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 24, 2026 5:48pm

Request Review

@V3RON
V3RON merged commit 8753017 into main Aug 31, 2026
4 checks passed
@V3RON
V3RON deleted the claude/require-profiler-analysis-views branch August 31, 2026 10:23
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.

2 participants