react-render-detective

Changelog

Every release, what changed and why โ€” including the mistakes, because the reasoning is the part worth reading when you are deciding whether to upgrade.

0.7.0 #

Added โ€” hook tracking, for renders that start inside a component

state-or-external was where the tool ran out of answers, and on a real application it covered 84% of one component's renders. trackHooks: true in the build plugin records the values ordinary hooks return, so the diagnosis can name what changed:

``text FlightList rendered from inside itself; useInfiniteScroll changed for this render. Ruled out: useTranslation โ€” the value changes on every render, so it cannot explain why this one happened. ``

Reported as evidence, never as a cause. useSelector only re-renders when its value changes, so a selector change implies causation; no arbitrary hook offers that guarantee, and a hook returning a fresh object each render is a symptom of a render something else triggered. The reason stays state-or-external, the wording says "candidates, not proof", and a separate HookChange type keeps the distinction in the data model rather than only in prose.

The discrimination is the useful part: a value that changes on *every* render is ruled out explicitly, which often leaves one candidate standing.

React's own hooks are never wrapped โ€” useState returns a new tuple every render โ€” and hooks whose result is discarded are skipped. Off by default.

0.6.1 #

Four fixes, every one found by running the tool against a real application rather than a fixture. Released as a patch so it reaches existing installs: each one is a documented feature that did not actually work.

Fixed โ€” only the first tracked hook in a component was ever attributed

useTrackedState and trackSelector guarded against a descendant claiming its ancestor's node, keyed on useId. That was wrong: useId is unique per call site, not per component, so the first tracked hook in a component locked out every other one. A component with a tracked selector and two named state values reported only one of them, silently.

Found in a real application, where a single component had exactly that combination โ€” the case a fixture with one hook per component cannot produce.

The guard is removed. A hook cannot see which component called it, only the nearest instrumented ancestor, so state or a selector named in an uninstrumented descendant is attributed to that ancestor. That is now documented rather than half-prevented, and the build plugin makes it rare by instrumenting every component.

Changed โ€” values of ordinary size are now compared, which changes some conclusions

This began as a selector fix and is broader than that, so it is called out separately.

shallowEqual backs four diagnoses: prop changes, React element props, tracked context values, and store selectors. It was using the *inspection* limits โ€” 20 array elements, 20 object keys โ€” so anything larger was reported as "reference changed, contents unknown" and never flagged as avoidable.

Raising the comparison bounds to 1 000 elements and 100 keys means some renders that were previously described as an unknown-contents change are now correctly identified as reference-only, contents identical โ€” and therefore counted as potentially avoidable. Expect avoidable counts to rise in apps that pass lists or wide objects as props. That is the tool getting more accurate, not the app getting worse.

Re-benchmarked after the change: 9.1โ€“11.8 ยตs per instrumented component, unchanged. The comparison runs in the deferred flush, never on the render path.

Fixed โ€” the unstable-selector diagnosis failed on ordinary data

Verified against real react-redux for the first time, rather than a model of it, and it did not work. Three defects, all invisible at test-fixture scale:

entirely ordinary list โ€” was reported as "too large to compare", so the single most valuable diagnosis silently did nothing. Inspection builds snapshots and must stay small; comparison only runs Object.is over elements, costs microseconds, and happens in the deferred flush. They are now separate, and comparison reaches 1 000 array elements and 100 object keys.

flights.results` read as two different selectors.

now yields catalogue.products โ€” the slice it derives from, which is both recognisable and exactly the shape that produces the bug.

Added โ€” match store hooks by name, for stores that are not packages

storeHooks compares the module specifier exactly, which suits react-redux but cannot work for a Zustand-style store: export const useStore = create(โ€ฆ) lives in your own module, so its specifier differs in every file that imports it โ€” ../store, @/store, ./state/store. storeHookNames matches the hook name from any module, default imports included. Empty by default, because a bare name is a blunter instrument than a package.

Changed โ€” benchmarks re-measured

The published overhead figures dated from 0.1.0, six releases and a great deal of code ago. Re-run against the current build: โ‰ˆ8โ€“11 ยตs per instrumented component and โ‰ˆ6% at realistic instrumentation levels โ€” unchanged, because the added work happens off the render path.

One row came out *negative* (2% instrumentation measured faster than baseline), which is impossible. It is noise, and BENCHMARKS.md now says so rather than quoting a flattering number: anything under about 5% in that harness is below what it can resolve.

0.6.0 #

Added โ€” external-store attribution

The gap that mattered most. A Redux or Zustand render could only ever be reported as *"state or an external store"*: the tool proved the render started inside the component but could not say which value caused it. On a real 167k-line application that covered 84% of one component's renders, which is where the product stopped being useful โ€” and most React apps are store-driven apps.

useSelector is a public hook from a public package, so this needs no React internals. The Babel plugin rewrites the call site, so nothing in your code changes:

becomes flights.results, which reads better than a file and line โ€” and fall back to the call site otherwise

useSelector compares with Object.is, the component then re-renders on *every* store update regardless of what changed. Invisible in a profiler, obvious here โ€” reported as avoidable, with createSelector / shallowEqual / derive-outside as the suggested fixes.

included, because changing those would change your app's behaviour

Attribution follows the same ownership rule as useTrackedState: a selector called in an uninstrumented descendant is not blamed on its instrumented ancestor.

Fixed โ€” explain() no longer calls a store render undetermined

explain() and printOpportunities() had no branch for the new store reason, so a component whose renders were entirely store-driven was reported as *"Cause could not be determined reliably"* โ€” the exact opposite of the truth, since store is the highest-confidence diagnosis the engine produces. Found by checking rather than assuming, immediately after building the feature it broke.

Selector churn now ranks above prop churn in the headline, because a selector rebuilding its value re-renders the component on every store update and is usually the larger cause.

Changed โ€” BREAKING: interaction tracking moved to its own entry point

```diff

+ import { startInteractionTracking, printInteractions } from "react-render-detective/interactions"; + startInteractionTracking(); ```

init() no longer starts it. Store attribution pushed the root entry to 17.02 KB โ€” 20 bytes over a budget that had already been raised three releases running โ€” and 0.5.0 committed to splitting rather than raising a fourth time. The root entry is now 15.21 KB, and consumers who do not use INP attribution no longer pay for it.

clear() and reset() still work correctly across the split: the interactions module registers lifecycle hooks with the detective rather than the root entry knowing it exists.

0.5.0 #

Everything here came out of pointing the tool at a real 167k-line application for the first time. Nothing in the test suite or the bundled example could have surfaced any of it.

Changed โ€” console output reports commits, not renders

A twenty-row list mounting produced roughly 200 lines of FareTile #1 mount 0.1ms, which Chrome then collapsed into 2ร— markers. It buried the two lines that mattered, and printing it slowed the app being measured.

normal behaviour, and reporting them teaches people to ignore the console

most frequent one, so twelve mounts plus twelve avoidable updates is not labelled mount

Measured on the same app afterwards: an entire search-and-sort session printed two lines.

If you relied on a line per render, that is now mode: "verbose".

Added โ€” colour

Red for a component that was rebuilt, amber for slow or avoidable, green for renders that were justified, grey for context. Colour never carries meaning on its own: every line keeps its glyph and its words, so the output survives being pasted into an issue or read with a different palette. The %c directive count and style-argument count are matched by construction, since a mismatch is the usual way this browser API breaks.

Fixed โ€” hot reload is no longer diagnosed as a key problem

Editing the package hot-reloaded the demo, React Fast Refresh rebuilt the tree, twenty rows remounted, and the tool announced *"rebuilt โ€” check the key given to TableRow"*. The remount was real; the diagnosis was nonsense. This tool only ever runs in development, so hot reload is the single most common cause of remounts it will ever see โ€” it would have said that to every user several times an hour.

Remounts of three or more distinct components inside 1.5s are now reported as a whole-tree rebuild at low confidence, with nothing to fix. A real key problem affects one component; a reload affects many at once. The inline-definition signal still wins, because a component declared in a render body is a bug either way.

Added โ€” the changelog is published and enforced

Shipped in the tarball, rendered to a page at deploy time so it cannot drift, and required by prepublishOnly and the release workflow โ€” a release with no entry is now impossible rather than merely discouraged. npm version inserts the heading and syncs the version strings that npm does not know about.

Added โ€” a way to test locally before publishing

npm run use-local -- ../path/to/app installs the working tree into a real application from a packed tarball, behind the same guard the publish path uses. Testing 0.4.0 in an app previously meant publishing it first, which is backwards. A tarball rather than npm link, so the app keeps one React and the exports map gets exercised too.

Note on bundle size

index moved 16 โ†’ 17 KB and that is the last raise. The next release that would exceed it splits interaction tracking behind react-render-detective/interactions instead โ€” a breaking change, and therefore a scheduled one. See docs/BUNDLE-SIZE.md.

0.4.0 #

Added โ€” triage, interactions, and a regression gate

printOpportunities() โ€” where to spend your next hour. Components ranked by estimated recoverable time rather than render count, because a component rendering 2 000 times for 0.01ms is not the problem and one rendering 40 times for 12ms might be. Remounts are charged at the cost of a mount. Built on the diagnostic engine, so a ranking and a diagnosis can never disagree.

Interaction and INP attribution. The browser reports how long an interaction took; the render events say which components spent it. Captured automatically through the Event Timing API for anything over one frame, with measureInteraction(label, fn) for the two cases the automatic path cannot see โ€” Safari before 16.4, and synthetic input, which never produces those entries.

A manual measurement waits for the next frame, and a hidden or throttled tab can stretch that to hundreds of milliseconds of idling. Measuring in a real browser showed exactly that โ€” a 12ms click reported as 922ms โ€” so the summary now separates handler time from render time and says plainly when the window was mostly the page waiting, rather than presenting idle time as your problem.

react-render-detective/testing โ€” a render regression gate. Snapshot renders, remounts and avoidable renders for a scripted interaction, commit the baseline, and fail the pull request when it regresses. Assertion-library agnostic: compareProfiles returns data, assertNoRenderRegressions throws. Improvements never fail the build but are reported with a nudge to re-baseline.

Fixed

does not exist in jsdom โ€” an interaction would simply never have been recorded. A timer now races it, whichever fires first.

0.3.0 #

Added โ€” remount detection

A remount is not a render: React throws the instance away, along with its DOM and all of its state, and builds a new one. It costs more than any re-render, and the two most common causes are silent.

cause named: a component declared inside another component's render body, or a changing key.

declaredInRender. A first attempt inferred it at runtime by timing repeated definitions, and it broke as soon as a user clicked more than five seconds apart โ€” the compiler already knows the answer, so it tells the runtime instead of the runtime guessing.

remount always produces a new instance. Without that, every component in a StrictMode app would be flagged.

component has actually been rebuilt repeatedly.

Fixed

inside another component โ€” the exact case remount detection exists for โ€” were never seen. path.skip() replaced with a processed-node guard.

0.2.0 #

Added โ€” automatic instrumentation

A build-time transform that instruments every component in development, shipped as a Babel plugin (react-render-detective/babel) and a Vite plugin (react-render-detective/vite) that share one implementation.

Wrapping components by hand only ever finds problems you already suspected. This turns the tool from a probe into a scanner, and it is also the only way to get source locations โ€” React removed _debugSource in 19, so there is no runtime alternative. Diagnoses now read TableRow src/App.tsx:85:7, and fix instructions name the file and line of the component that passes the unstable prop.

inside memo() / forwardRef() โ€” *inside*, so memo still compares props first.

components are routinely used above their definition and a const would produce a temporal dead zone error.

closure, hand-wrapped components, node_modules, and the detective's own runtime.

its zero-dependency guarantee, and the size gate now fails if build-time code leaks into a runtime entry.

Fixed

running the plugin against this repo's own example: it instrumented the detective's runtime, the wrapper rendered itself, and the app died with a stack overflow before first paint. The plugin now refuses to touch its own runtime, and the HOC refuses to wrap a wrapper.

0.1.3 #

Use this version. It is the first release published by CI from a clean checkout, and the first whose metadata is correct.

0.1.0, 0.1.1 and 0.1.2 were all published from a local working tree, and none of them should be used:

versionproblem
0.1.0predates four render-attribution defects found by running the demo in a real browser
0.1.1package.json declares ~200 transitive dev packages as runtime dependencies; fails to install on Linux
0.1.2same defect, 122 dependencies โ€” published from the polluted tree before the guard existed

The cause was npm silently rewriting package.json while reifying a node_modules tree left out of sync by an earlier --no-save install. It happened three times, from three different npm commands, which is why releases no longer come from a local tree at all. See docs/RELEASING.md.

Code is unchanged from 0.1.2; only the release path and metadata differ.

0.1.2 #

Withdrawn โ€” 122 spurious runtime dependencies. 0.1.1 shipped a broken package.json: a stray npm install --package-lock-only had written a dependencies block into it listing ~200 transitive dev packages as runtime dependencies of this package. Installing 0.1.1 therefore pulls in the whole dev toolchain, and fails outright on Linux because the darwin-only fsevents is among them. 0.1.2 is byte-identical in behaviour and declares what it actually needs: no dependencies, and react as its only peer.

0.1.0 was published before the four defects below were found, and should also be avoided.

Releases now come from CI on a tag, never from a local tree, and a publish guard (scripts/verify-package.mjs, wired to prepublishOnly) checks the packed tarball and refuses to publish a manifest that differs from the committed one. See docs/RELEASING.md.

0.1.1 #

Withdrawn โ€” see above. Contents are otherwise the same as 0.1.2.

Added

Object.is semantics and a bounded shallow compare.

named state, or an origin at/below the component โ€” never counted twice up the ancestor chain.

labelled as the upper bound it is.

replays and excluded from statistics.

Fixed before release

Found by running the demo dashboard in a real browser, which the jsdom test suite had not covered:

answered *no* whenever process was absent โ€” which is the case in a browser โ€” so the documented quick-start turned the tool on and then stayed silent. It now asks the opposite question and only disables itself when it can positively see a production build, and says so if it does.

component reported every prop as newly added. Registry detach no longer clears props.

counting mounts in the denominator โ€” a prop responsible for 100% of a list row's updates read as 33%. Shares are now measured over updates, and the wording checks what is actually in the bucket.

where it *arrives from*; it now says so.

src/index.ts/overlay and breaking the demo on first run.

Verified

Known limitations

See docs/FEASIBILITY.md. Briefly: no source locations (needs a build-time transform), no automatic context-subscription map, no effect dependency analysis โ€” each is impossible without private React internals, which this package does not use.