feat(require-profiler-plugin): add package rollup, require chains and duplicate detection - #454
Merged
V3RON merged 9 commits intoAug 31, 2026
Conversation
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
force-pushed
the
claude/require-profiler-analysis-views
branch
from
August 23, 2026 19:23
aca3383 to
ecd3d5f
Compare
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.
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
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
The profiler could show what ran and how long it took. These three views answer the questions you actually act on afterwards.
lodash — 340ms across 87 modulesis actionable where 87 four-millisecond rows are not.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
valueacross a package's modules would double-count, because a parent'svaluealready 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) andinclusiveTime(Σ ofvalueover 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 → lodashmust reportinclusiveTime: 100, not 140.On package-name parsing. The last
node_modulessegment wins, so a nested copy resolves to the inner package — the copy actually evaluated. Matching is on whole path segments, so a directory likemy_node_modules_helpernever 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.
occurrencescounts 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()andisInitializedare real and correct — but the number cannot support the decision the tab invited:inlineRequires: trueby 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.import()compiles toasyncRequire, which callsrequire.importAllagainst the same bundle unless__loadBundleAsyncis 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.Removed with it: the
getBundleModulespolyfill 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.
findDuplicatePackagestakes 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:allclean 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, themy_node_modules_helperfalse positive, trailingnode_modules/, empty string, leading./); the double-counting guard; a package entered at two points; a module evaluated twice counting once towardmoduleCount; 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.