react-render-detective 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +299 -0
  2. package/README.md +2 -2
  3. package/dist/babel.cjs +2 -2
  4. package/dist/babel.d.cts +11 -0
  5. package/dist/babel.d.ts +11 -0
  6. package/dist/babel.js +1 -1
  7. package/dist/{chunk-QOGTEVBP.js → chunk-3TZNM7JO.js} +103 -4
  8. package/dist/chunk-3TZNM7JO.js.map +1 -0
  9. package/dist/{chunk-HOTY7J3X.js → chunk-D7M4UC3Y.js} +26 -2
  10. package/dist/chunk-D7M4UC3Y.js.map +1 -0
  11. package/dist/{chunk-PHYYA67T.cjs → chunk-EXDRRUUQ.cjs} +69 -2
  12. package/dist/chunk-EXDRRUUQ.cjs.map +1 -0
  13. package/dist/{chunk-DZ3BZ654.cjs → chunk-LCGXVRJR.cjs} +103 -4
  14. package/dist/chunk-LCGXVRJR.cjs.map +1 -0
  15. package/dist/{chunk-MCIQMYNR.js → chunk-QVHAC7AD.js} +69 -2
  16. package/dist/chunk-QVHAC7AD.js.map +1 -0
  17. package/dist/{chunk-JD4IJ4MN.cjs → chunk-RGT44QQI.cjs} +26 -2
  18. package/dist/chunk-RGT44QQI.cjs.map +1 -0
  19. package/dist/core.cjs +18 -18
  20. package/dist/core.d.cts +27 -3
  21. package/dist/core.d.ts +27 -3
  22. package/dist/core.js +1 -1
  23. package/dist/index.cjs +197 -295
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +35 -115
  26. package/dist/index.d.ts +35 -115
  27. package/dist/index.js +166 -258
  28. package/dist/index.js.map +1 -1
  29. package/dist/interactions.cjs +252 -0
  30. package/dist/interactions.cjs.map +1 -0
  31. package/dist/interactions.d.cts +110 -0
  32. package/dist/interactions.d.ts +110 -0
  33. package/dist/interactions.js +241 -0
  34. package/dist/interactions.js.map +1 -0
  35. package/dist/overlay.cjs +5 -5
  36. package/dist/overlay.js +2 -2
  37. package/dist/testing.d.cts +1 -1
  38. package/dist/testing.d.ts +1 -1
  39. package/dist/{types-gl13xwmN.d.cts → types-CRET8EhB.d.cts} +20 -2
  40. package/dist/{types-gl13xwmN.d.ts → types-CRET8EhB.d.ts} +20 -2
  41. package/dist/vite.cjs +2 -2
  42. package/dist/vite.js +1 -1
  43. package/package.json +14 -4
  44. package/dist/chunk-DZ3BZ654.cjs.map +0 -1
  45. package/dist/chunk-HOTY7J3X.js.map +0 -1
  46. package/dist/chunk-JD4IJ4MN.cjs.map +0 -1
  47. package/dist/chunk-MCIQMYNR.js.map +0 -1
  48. package/dist/chunk-PHYYA67T.cjs.map +0 -1
  49. package/dist/chunk-QOGTEVBP.js.map +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,299 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ ### Added — external-store attribution
6
+
7
+ The gap that mattered most. A Redux or Zustand render could only ever be reported as *"state or an
8
+ external store"*: the tool proved the render started inside the component but could not say which
9
+ value caused it. On a real 167k-line application that covered **84% of one component's renders**,
10
+ which is where the product stopped being useful — and most React apps are store-driven apps.
11
+
12
+ `useSelector` is a public hook from a public package, so this needs no React internals. The Babel
13
+ plugin rewrites the call site, so nothing in your code changes:
14
+
15
+ - selectors are named from the selector itself where possible — `state => state.flights.results`
16
+ becomes `flights.results`, which reads better than a file and line — and fall back to the call
17
+ site otherwise
18
+ - the diagnosis reports `store` at **high** confidence, naming the selector and its location
19
+ - **the case worth having**: a selector that builds a new array or object on every call. Because
20
+ `useSelector` compares with `Object.is`, the component then re-renders on *every* store update
21
+ regardless of what changed. Invisible in a profiler, obvious here — reported as avoidable, with
22
+ `createSelector` / `shallowEqual` / derive-outside as the suggested fixes.
23
+ - every argument passes through to the real hook untouched, react-redux's equality function
24
+ included, because changing those would change your app's behaviour
25
+ - `storeHooks` points it at any other store; `trackStores: false` turns it off
26
+ - `createTrackedSelectorHook` covers custom hooks and bundlers the plugin cannot reach
27
+
28
+ Attribution follows the same ownership rule as `useTrackedState`: a selector called in an
29
+ uninstrumented descendant is not blamed on its instrumented ancestor.
30
+
31
+ ### Fixed — `explain()` no longer calls a store render undetermined
32
+
33
+ `explain()` and `printOpportunities()` had no branch for the new `store` reason, so a component
34
+ whose renders were entirely store-driven was reported as *"Cause could not be determined
35
+ reliably"* — the exact opposite of the truth, since `store` is the highest-confidence diagnosis the
36
+ engine produces. Found by checking rather than assuming, immediately after building the feature it
37
+ broke.
38
+
39
+ Selector churn now ranks **above** prop churn in the headline, because a selector rebuilding its
40
+ value re-renders the component on every store update and is usually the larger cause.
41
+
42
+ ### Changed — BREAKING: interaction tracking moved to its own entry point
43
+
44
+ ```diff
45
+ - rrd.printInteractions();
46
+ + import { startInteractionTracking, printInteractions } from "react-render-detective/interactions";
47
+ + startInteractionTracking();
48
+ ```
49
+
50
+ `init()` no longer starts it. Store attribution pushed the root entry to 17.02 KB — 20 bytes over a
51
+ budget that had already been raised three releases running — and 0.5.0 committed to splitting rather
52
+ than raising a fourth time. The root entry is now **15.21 KB**, and consumers who do not use INP
53
+ attribution no longer pay for it.
54
+
55
+ `clear()` and `reset()` still work correctly across the split: the interactions module registers
56
+ lifecycle hooks with the detective rather than the root entry knowing it exists.
57
+
58
+ ## 0.5.0
59
+
60
+ Everything here came out of pointing the tool at a real 167k-line application for the first time.
61
+ Nothing in the test suite or the bundled example could have surfaced any of it.
62
+
63
+ ### Changed — console output reports commits, not renders
64
+
65
+ A twenty-row list mounting produced roughly 200 lines of `FareTile #1 mount 0.1ms`, which Chrome
66
+ then collapsed into `2×` markers. It buried the two lines that mattered, and printing it slowed the
67
+ app being measured.
68
+
69
+ - one aggregated line per batch, coalesced over 400ms
70
+ - **a batch with nothing actionable prints nothing at all** — cheap, fully explained renders are
71
+ normal behaviour, and reporting them teaches people to ignore the console
72
+ - per component: count, total cost, and the reason behind the *actionable* renders rather than the
73
+ most frequent one, so twelve mounts plus twelve avoidable updates is not labelled `mount`
74
+ - `verbose` keeps the per-render detail for when `include` has narrowed the scope
75
+
76
+ Measured on the same app afterwards: an entire search-and-sort session printed **two** lines.
77
+
78
+ If you relied on a line per render, that is now `mode: "verbose"`.
79
+
80
+ ### Added — colour
81
+
82
+ Red for a component that was rebuilt, amber for slow or avoidable, green for renders that were
83
+ justified, grey for context. Colour never carries meaning on its own: every line keeps its glyph
84
+ and its words, so the output survives being pasted into an issue or read with a different palette.
85
+ The `%c` directive count and style-argument count are matched by construction, since a mismatch is
86
+ the usual way this browser API breaks.
87
+
88
+ ### Fixed — hot reload is no longer diagnosed as a `key` problem
89
+
90
+ Editing the package hot-reloaded the demo, React Fast Refresh rebuilt the tree, twenty rows
91
+ remounted, and the tool announced *"rebuilt — check the `key` given to TableRow"*. The remount was
92
+ real; the diagnosis was nonsense. This tool only ever runs in development, so hot reload is the
93
+ single most common cause of remounts it will ever see — it would have said that to every user
94
+ several times an hour.
95
+
96
+ Remounts of three or more distinct components inside 1.5s are now reported as a whole-tree rebuild
97
+ at low confidence, with nothing to fix. A real key problem affects one component; a reload affects
98
+ many at once. The inline-definition signal still wins, because a component declared in a render
99
+ body is a bug either way.
100
+
101
+ ### Added — the changelog is published and enforced
102
+
103
+ Shipped in the tarball, rendered to
104
+ [a page](https://shubambhasin.github.io/react-render-detective/changelog.html) at deploy time so it
105
+ cannot drift, and required by `prepublishOnly` and the release workflow — a release with no entry
106
+ is now impossible rather than merely discouraged. `npm version` inserts the heading and syncs the
107
+ version strings that npm does not know about.
108
+
109
+ ### Added — a way to test locally before publishing
110
+
111
+ `npm run use-local -- ../path/to/app` installs the working tree into a real application from a
112
+ packed tarball, behind the same guard the publish path uses. Testing 0.4.0 in an app previously
113
+ meant publishing it first, which is backwards. A tarball rather than `npm link`, so the app keeps
114
+ one React and the exports map gets exercised too.
115
+
116
+ ### Note on bundle size
117
+
118
+ `index` moved 16 → 17 KB and that is the last raise. The next release that would exceed it splits
119
+ interaction tracking behind `react-render-detective/interactions` instead — a breaking change, and
120
+ therefore a scheduled one. See [docs/BUNDLE-SIZE.md](docs/BUNDLE-SIZE.md).
121
+
122
+ ## 0.4.0
123
+
124
+ ### Added — triage, interactions, and a regression gate
125
+
126
+ **`printOpportunities()` — where to spend your next hour.** Components ranked by estimated
127
+ recoverable time rather than render count, because a component rendering 2 000 times for 0.01ms is
128
+ not the problem and one rendering 40 times for 12ms might be. Remounts are charged at the cost of a
129
+ mount. Built on the diagnostic engine, so a ranking and a diagnosis can never disagree.
130
+
131
+ **Interaction and INP attribution.** The browser reports how long an interaction took; the render
132
+ events say which components spent it. Captured automatically through the Event Timing API for
133
+ anything over one frame, with `measureInteraction(label, fn)` for the two cases the automatic path
134
+ cannot see — Safari before 16.4, and synthetic input, which never produces those entries.
135
+
136
+ A manual measurement waits for the next frame, and a hidden or throttled tab can stretch that to
137
+ hundreds of milliseconds of idling. Measuring in a real browser showed exactly that — a 12ms click
138
+ reported as 922ms — so the summary now separates handler time from render time and says plainly
139
+ when the window was mostly the page waiting, rather than presenting idle time as your problem.
140
+
141
+ **`react-render-detective/testing` — a render regression gate.** Snapshot renders, remounts and
142
+ avoidable renders for a scripted interaction, commit the baseline, and fail the pull request when
143
+ it regresses. Assertion-library agnostic: `compareProfiles` returns data, `assertNoRenderRegressions`
144
+ throws. Improvements never fail the build but are reported with a nudge to re-baseline.
145
+
146
+ ### Fixed
147
+
148
+ - `measureInteraction` relied on `requestAnimationFrame`, which does not fire in a hidden tab and
149
+ does not exist in jsdom — an interaction would simply never have been recorded. A timer now races
150
+ it, whichever fires first.
151
+
152
+ ## 0.3.0
153
+
154
+ ### Added — remount detection
155
+
156
+ A remount is not a render: React throws the instance away, along with its DOM and all of its state,
157
+ and builds a new one. It costs more than any re-render, and the two most common causes are silent.
158
+
159
+ - Components rebuilt rather than re-rendered are now counted (`remountCount`) and reported, with the
160
+ cause named: a component **declared inside another component's render body**, or a **changing
161
+ `key`**.
162
+ - The inline-definition case is answered *statically by the build plugin*, which passes
163
+ `declaredInRender`. A first attempt inferred it at runtime by timing repeated definitions, and it
164
+ broke as soon as a user clicked more than five seconds apart — the compiler already knows the
165
+ answer, so it tells the runtime instead of the runtime guessing.
166
+ - StrictMode's simulated unmount/remount is excluded: it reuses the same fiber, whereas a real
167
+ remount always produces a new instance. Without that, every component in a StrictMode app would be
168
+ flagged.
169
+ - Ordinary mounting is not flagged. A growing list mounts components; nothing is reported until a
170
+ component has actually been rebuilt repeatedly.
171
+ - Surfaced in `explain()` (it outranks every render explanation), `printStats()` and the overlay.
172
+
173
+ ### Fixed
174
+
175
+ - The build plugin skipped the entire body of any component it instrumented, so components declared
176
+ **inside** another component — the exact case remount detection exists for — were never seen.
177
+ `path.skip()` replaced with a processed-node guard.
178
+ - The Vite plugin assumed Babel 8's ESM shape and would have failed for anyone on Babel 7.
179
+
180
+ ## 0.2.0
181
+
182
+ ### Added — automatic instrumentation
183
+
184
+ A build-time transform that instruments every component in development, shipped as a Babel plugin
185
+ (`react-render-detective/babel`) and a Vite plugin (`react-render-detective/vite`) that share one
186
+ implementation.
187
+
188
+ Wrapping components by hand only ever finds problems you already suspected. This turns the tool
189
+ from a probe into a scanner, and it is also the only way to get **source locations** — React
190
+ removed `_debugSource` in 19, so there is no runtime alternative. Diagnoses now read
191
+ `TableRow src/App.tsx:85:7`, and fix instructions name the file and line of the component that
192
+ passes the unstable prop.
193
+
194
+ - Instruments uppercase-named functions returning JSX, including arrow components and the function
195
+ inside `memo()` / `forwardRef()` — *inside*, so `memo` still compares props first.
196
+ - Function declarations are instrumented by reassignment rather than rewritten to `const`, since
197
+ components are routinely used above their definition and a `const` would produce a temporal dead
198
+ zone error.
199
+ - Leaves alone: hooks, lowercase functions, functions that never return JSX, JSX from a nested
200
+ closure, hand-wrapped components, `node_modules`, and the detective's own runtime.
201
+ - `clientOnly` skips files without a `"use client"` directive, for the Next.js app router.
202
+ - `@babel/core` is an **optional** peer dependency and never reaches the browser; the runtime keeps
203
+ its zero-dependency guarantee, and the size gate now fails if build-time code leaks into a
204
+ runtime entry.
205
+
206
+ ### Fixed
207
+
208
+ - `withRenderDetective` returns an already-wrapped component unchanged instead of nesting. Found by
209
+ running the plugin against this repo's own example: it instrumented the detective's runtime, the
210
+ wrapper rendered itself, and the app died with a stack overflow before first paint. The plugin
211
+ now refuses to touch its own runtime, and the HOC refuses to wrap a wrapper.
212
+
213
+ ## 0.1.3
214
+
215
+ **Use this version.** It is the first release published by CI from a clean checkout, and the first
216
+ whose metadata is correct.
217
+
218
+ `0.1.0`, `0.1.1` and `0.1.2` were all published from a local working tree, and none of them should
219
+ be used:
220
+
221
+ | version | problem |
222
+ | --- | --- |
223
+ | `0.1.0` | predates four render-attribution defects found by running the demo in a real browser |
224
+ | `0.1.1` | `package.json` declares ~200 transitive dev packages as runtime dependencies; fails to install on Linux |
225
+ | `0.1.2` | same defect, 122 dependencies — published from the polluted tree before the guard existed |
226
+
227
+ The cause was npm silently rewriting `package.json` while reifying a `node_modules` tree left out
228
+ of sync by an earlier `--no-save` install. It happened three times, from three different npm
229
+ commands, which is why releases no longer come from a local tree at all. See
230
+ [docs/RELEASING.md](docs/RELEASING.md).
231
+
232
+ Code is unchanged from `0.1.2`; only the release path and metadata differ.
233
+
234
+ ## 0.1.2
235
+
236
+ Withdrawn — 122 spurious runtime dependencies. `0.1.1` shipped a broken `package.json`: a stray
237
+ `npm install --package-lock-only` had written a `dependencies` block into it listing ~200
238
+ transitive dev packages as runtime dependencies of this package. Installing `0.1.1` therefore
239
+ pulls in the whole dev toolchain, and fails outright on Linux because the darwin-only `fsevents`
240
+ is among them. `0.1.2` is byte-identical in behaviour and declares what it actually needs: no
241
+ dependencies, and `react` as its only peer.
242
+
243
+ `0.1.0` was published before the four defects below were found, and should also be avoided.
244
+
245
+ Releases now come from CI on a tag, never from a local tree, and a publish guard
246
+ (`scripts/verify-package.mjs`, wired to `prepublishOnly`) checks the packed tarball and refuses to
247
+ publish a manifest that differs from the committed one. See [docs/RELEASING.md](docs/RELEASING.md).
248
+
249
+ ## 0.1.1
250
+
251
+ Withdrawn — see above. Contents are otherwise the same as `0.1.2`.
252
+
253
+ ### Added
254
+
255
+ - Render tracking via `withRenderDetective`, `<RenderDetective>` and `useRenderDiagnostics`.
256
+ - Prop comparison that separates a real value change from a **reference-only** change, using
257
+ `Object.is` semantics and a bounded shallow compare.
258
+ - Parent attribution: each commit is attributed to props, parent propagation, tracked context,
259
+ named state, or an origin at/below the component — never counted twice up the ancestor chain.
260
+ - Profiler-based timings with self duration derived by subtracting instrumented descendants, and
261
+ labelled as the upper bound it is.
262
+ - Confidence (`high` / `medium` / `low`) and printed limitations on every diagnosis.
263
+ - StrictMode awareness: renders counted per commit, extra invocations reported as development
264
+ replays and excluded from statistics.
265
+ - `explain()` / `explainStructured()` — aggregated causality for one component.
266
+ - Console reporter (concise and verbose) and a plain-DOM overlay rendered outside the React tree.
267
+ - `useTrackedState`, `useTrackedContextValue`, `useTrackedEffect` for what the runtime cannot see.
268
+ - Filtering (`include` / `exclude`), per-instance sampling, bounded ring buffer, bounded inspection.
269
+ - Zero runtime dependencies, ESM + CJS + types, `sideEffects: false`, three entry points.
270
+ - Bundle-size budgets in CI and an instrumentation-overhead benchmark suite.
271
+
272
+ ### Fixed before release
273
+
274
+ Found by running the demo dashboard in a real browser, which the jsdom test suite had not covered:
275
+
276
+ - `init()` recorded nothing under a Vite dev server. Dev detection asked "is this development?" and
277
+ answered *no* whenever `process` was absent — which is the case in a browser — so the documented
278
+ quick-start turned the tool on and then stayed silent. It now asks the opposite question and only
279
+ disables itself when it can positively see a production build, and says so if it does.
280
+ - StrictMode's effect double-invoke wiped the recorded props on mount, so the first update of every
281
+ component reported **every** prop as newly added. Registry detach no longer clears props.
282
+ - `explain()` called reference-only prop changes "genuinely new values", and diluted every share by
283
+ counting mounts in the denominator — a prop responsible for 100% of a list row's updates read as
284
+ 33%. Shares are now measured over updates, and the wording checks what is actually in the bucket.
285
+ - `explain()` said an unstable prop was "created in" the nearest instrumented ancestor. That is
286
+ where it *arrives from*; it now says so.
287
+ - The example's Vite alias matched by prefix, rewriting `react-render-detective/overlay` to
288
+ `src/index.ts/overlay` and breaking the demo on first run.
289
+
290
+ ### Verified
291
+
292
+ - React 18 and React 19, 74 tests each.
293
+ - Packed tarball installed into a clean project: ESM, CJS and TypeScript consumers all resolve.
294
+
295
+ ### Known limitations
296
+
297
+ See [docs/FEASIBILITY.md](docs/FEASIBILITY.md). Briefly: no source locations (needs a build-time
298
+ transform), no automatic context-subscription map, no effect dependency analysis — each is
299
+ impossible without private React internals, which this package does not use.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  [Guide](docs/GUIDE.md) · [API](docs/API.md) · [Feasibility report](docs/FEASIBILITY.md) ·
12
12
  [Benchmarks](docs/BENCHMARKS.md)
13
13
 
14
- > **Status: 0.4.0, early release.** 76 tests pass on React 18 and 19; benchmarks and bundle budgets
14
+ > **Status: 0.6.0, early release.** 76 tests pass on React 18 and 19; benchmarks and bundle budgets
15
15
  > are green; the packed package is verified in a clean install for ESM, CJS and TypeScript
16
16
  > consumers; and the demo dashboard has been driven end to end in Chrome, which found four real
17
17
  > defects the jsdom suite had missed (see the [changelog](CHANGELOG.md)). Not yet exercised:
@@ -111,7 +111,7 @@ It answers **causality**, not counts:
111
111
  | Question | Answer |
112
112
  | --- | --- |
113
113
  | What rendered? | component, render number, mount vs update |
114
- | Why? | props · parent · context · state · external store — with the evidence |
114
+ | Why? | props · parent · context · state · **which store selector** — with the evidence |
115
115
  | What changed? | per prop: value change vs *reference-only* change |
116
116
  | Where from? | the nearest instrumented ancestor, and whether it re-rendered |
117
117
  | How expensive? | subtree duration, and self duration with descendants subtracted |
package/dist/babel.cjs CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
2
 
3
- var chunkPHYYA67T_cjs = require('./chunk-PHYYA67T.cjs');
3
+ var chunkEXDRRUUQ_cjs = require('./chunk-EXDRRUUQ.cjs');
4
4
 
5
5
 
6
6
 
7
- module.exports = chunkPHYYA67T_cjs.renderDetectiveBabelPlugin;
7
+ module.exports = chunkEXDRRUUQ_cjs.renderDetectiveBabelPlugin;
8
8
  //# sourceMappingURL=babel.cjs.map
9
9
  //# sourceMappingURL=babel.cjs.map
package/dist/babel.d.cts CHANGED
@@ -29,6 +29,17 @@ interface RenderDetectivePluginOptions {
29
29
  importSource?: string;
30
30
  /** Root used to make source locations relative. Defaults to `process.cwd()`. */
31
31
  root?: string;
32
+ /**
33
+ * Attribute external-store renders by wrapping store hooks at their call site.
34
+ * Defaults to `true`. Without it, a Redux-driven render can only be reported
35
+ * as "state or an external store".
36
+ */
37
+ trackStores?: boolean;
38
+ /**
39
+ * Which hooks to wrap, as `package` → hook names. Defaults to react-redux's
40
+ * `useSelector`. Add your own store's hook here.
41
+ */
42
+ storeHooks?: Record<string, string[]>;
32
43
  }
33
44
  declare function renderDetectiveBabelPlugin(api: {
34
45
  types: typeof types;
package/dist/babel.d.ts CHANGED
@@ -29,6 +29,17 @@ interface RenderDetectivePluginOptions {
29
29
  importSource?: string;
30
30
  /** Root used to make source locations relative. Defaults to `process.cwd()`. */
31
31
  root?: string;
32
+ /**
33
+ * Attribute external-store renders by wrapping store hooks at their call site.
34
+ * Defaults to `true`. Without it, a Redux-driven render can only be reported
35
+ * as "state or an external store".
36
+ */
37
+ trackStores?: boolean;
38
+ /**
39
+ * Which hooks to wrap, as `package` → hook names. Defaults to react-redux's
40
+ * `useSelector`. Add your own store's hook here.
41
+ */
42
+ storeHooks?: Record<string, string[]>;
32
43
  }
33
44
  declare function renderDetectiveBabelPlugin(api: {
34
45
  types: typeof types;
package/dist/babel.js CHANGED
@@ -1,3 +1,3 @@
1
- export { renderDetectiveBabelPlugin as default } from './chunk-MCIQMYNR.js';
1
+ export { renderDetectiveBabelPlugin as default } from './chunk-QVHAC7AD.js';
2
2
  //# sourceMappingURL=babel.js.map
3
3
  //# sourceMappingURL=babel.js.map
@@ -324,6 +324,20 @@ function diagnose(input, thresholds) {
324
324
  function classify(input) {
325
325
  const { changedProps, contextChanges, parentRendered, parentUnknown, parentName } = input;
326
326
  if (input.phase === "mount") {
327
+ if (input.remounts >= 2 && input.treeReloadSuspected && !input.inlineDefinitionSuspected) {
328
+ return make(
329
+ "mount",
330
+ "low",
331
+ `${input.componentName} was rebuilt along with several other components \u2014 most likely a hot reload or a route change.`,
332
+ [
333
+ `${input.componentName} has been remounted ${input.remounts}\xD7 in total.`,
334
+ "Several unrelated components remounted at the same moment, which is what a whole-tree rebuild looks like \u2014 React Fast Refresh replacing a module, or a route unmounting.",
335
+ "A per-component remount problem affects one component, not many at once."
336
+ ],
337
+ false,
338
+ "Nothing to fix if this followed an edit or a navigation. Reload the page and repeat the interaction to measure without it."
339
+ );
340
+ }
327
341
  if (input.remounts >= 2) {
328
342
  const inline = input.inlineDefinitionSuspected;
329
343
  return make(
@@ -341,6 +355,35 @@ function classify(input) {
341
355
  }
342
356
  return make("mount", "high", `${input.componentName} mounted.`, ["First render of this instance."], false);
343
357
  }
358
+ if (input.selectorChanges.length > 0) {
359
+ const changes = input.selectorChanges;
360
+ const unstable = changes.filter((c) => c.referenceOnly);
361
+ const names = changes.map((c) => c.name);
362
+ const evidence = changes.map(
363
+ (c) => `\`${c.name}\`${c.source ? ` (${c.source})` : ""}: ${formatInspected(c.previous)} \u2192 ${formatInspected(c.current)}` + (c.referenceOnly ? " \u2014 new reference, identical contents" : "")
364
+ );
365
+ if (unstable.length > 0) {
366
+ const worst = unstable[0];
367
+ evidence.push(
368
+ "useSelector compares with Object.is, so a selector that builds a new value each call re-renders the component on every store update \u2014 not only when its data changes."
369
+ );
370
+ return make(
371
+ "store",
372
+ "high",
373
+ `${input.componentName} rendered because ${unstable.map((c) => `\`${c.name}\``).join(", ")} returned a new reference with identical contents.`,
374
+ evidence,
375
+ true,
376
+ `Make \`${worst.name}\`${worst.source ? ` at ${worst.source}` : ""} return a stable value: select the raw slice and derive outside the selector, memoize it with createSelector, or pass an equality function such as shallowEqual.`
377
+ );
378
+ }
379
+ return make(
380
+ "store",
381
+ "high",
382
+ `${input.componentName} rendered because ${names.join(", ")} changed in the store.`,
383
+ [...evidence, "The values genuinely changed, so this render is doing real work."],
384
+ false
385
+ );
386
+ }
344
387
  if (input.trackedState.length > 0) {
345
388
  const names = input.trackedState.map((s) => s.name);
346
389
  const evidence = input.trackedState.map(
@@ -423,7 +466,7 @@ function classify(input) {
423
466
  [
424
467
  "No new props came from above: the wrapper did not re-render.",
425
468
  input.selfRenderProven ? "An instrumented child re-rendered from above in this commit, which proves this component produced it." : "No instrumented descendant rendered in this commit, so an uninstrumented descendant could in principle be the origin instead.",
426
- "React does not expose hook state without private internals, so the exact source is not observable. Use useTrackedState to name it."
469
+ "React does not expose hook state without private internals, so the exact source is not observable. Use useTrackedState to name local state, and the build plugin's store tracking to name selectors."
427
470
  ],
428
471
  false
429
472
  );
@@ -585,15 +628,19 @@ var RingBuffer = class {
585
628
 
586
629
  // src/core/store.ts
587
630
  var now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
631
+ var RELOAD_COMPONENT_THRESHOLD = 3;
632
+ var RELOAD_WINDOW_MS = 1500;
588
633
  var INLINE_DEFINITION_WINDOW_MS = 5e3;
589
634
  var timestamp = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
590
635
  var EMPTY_STATE = [];
636
+ var EMPTY_SELECTORS = [];
591
637
  var DURATION_SAMPLE = 200;
592
638
  var SWEEP_DELAY_MS = 250;
593
639
  var emptyReasons = () => ({
594
640
  mount: 0,
595
641
  props: 0,
596
642
  state: 0,
643
+ store: 0,
597
644
  parent: 0,
598
645
  context: 0,
599
646
  "state-or-external": 0,
@@ -604,6 +651,9 @@ var Detective = class {
604
651
  this.config = { ...defaultConfig };
605
652
  this.nodes = /* @__PURE__ */ new Map();
606
653
  this.listeners = /* @__PURE__ */ new Set();
654
+ /** Registered by optional entry points so they follow clear()/reset() without being imported here. */
655
+ this.clearHooks = /* @__PURE__ */ new Set();
656
+ this.resetHooks = /* @__PURE__ */ new Set();
607
657
  this.pending = [];
608
658
  this.contextChanges = [];
609
659
  /** Orders renders and context updates so they can be matched without timers. */
@@ -643,6 +693,12 @@ var Detective = class {
643
693
  get enabled() {
644
694
  return this.config.enabled;
645
695
  }
696
+ registerClearHook(hook) {
697
+ this.clearHooks.add(hook);
698
+ }
699
+ registerResetHook(hook) {
700
+ this.resetHooks.add(hook);
701
+ }
646
702
  subscribe(listener) {
647
703
  this.listeners.add(listener);
648
704
  return () => {
@@ -667,6 +723,7 @@ var Detective = class {
667
723
  sampled,
668
724
  attempts: 0,
669
725
  pendingState: [],
726
+ pendingSelectors: [],
670
727
  seenCommit: false,
671
728
  renderNumber: 0,
672
729
  lastCommitTime: -1,
@@ -697,7 +754,10 @@ var Detective = class {
697
754
  node.everAttached = true;
698
755
  const life = this.lifecycleFor(node.name);
699
756
  life.mounts++;
700
- if (life.unmounts > 0) life.remounts++;
757
+ if (life.unmounts > 0) {
758
+ life.remounts++;
759
+ life.lastRemountAt = timestamp();
760
+ }
701
761
  }
702
762
  detach(node) {
703
763
  this.nodes.delete(node.id);
@@ -748,6 +808,22 @@ var Detective = class {
748
808
  lifecycleOf(name) {
749
809
  return this.lifecycles.get(name) ?? { mounts: 0, unmounts: 0, remounts: 0 };
750
810
  }
811
+ /**
812
+ * Did a whole-tree rebuild just happen?
813
+ *
814
+ * Hot reload is the most common cause of remounts in development, and this
815
+ * tool only ever runs in development — so without this it would confidently
816
+ * blame a `key` every time React Fast Refresh replaced a module. A real key
817
+ * or inline-definition problem affects one component; a reload affects many
818
+ * at once.
819
+ */
820
+ reloadSuspectedAt(at) {
821
+ let names = 0;
822
+ for (const life of this.lifecycles.values()) {
823
+ if (life.lastRemountAt !== void 0 && Math.abs(at - life.lastRemountAt) <= RELOAD_WINDOW_MS) names++;
824
+ }
825
+ return names >= RELOAD_COMPONENT_THRESHOLD;
826
+ }
751
827
  /** Hot path. Must stay allocation-free and O(1). */
752
828
  recordAttempt(node, props) {
753
829
  node.attempts++;
@@ -756,6 +832,9 @@ var Detective = class {
756
832
  recordStateChange(node, change) {
757
833
  if (node.pendingState.length < 16) node.pendingState.push(change);
758
834
  }
835
+ recordSelectorChange(node, change) {
836
+ if (node.pendingSelectors.length < 16) node.pendingSelectors.push(change);
837
+ }
759
838
  /**
760
839
  * Hot path. Called from Profiler#onRender; only enqueues.
761
840
  *
@@ -770,12 +849,14 @@ var Detective = class {
770
849
  attempts: node.attempts,
771
850
  props: node.pendingProps,
772
851
  state: node.pendingState.length > 0 ? node.pendingState : EMPTY_STATE,
852
+ selectors: node.pendingSelectors.length > 0 ? node.pendingSelectors : EMPTY_SELECTORS,
773
853
  seq: ++this.seq
774
854
  });
775
855
  node.seenCommit = true;
776
856
  node.attempts = 0;
777
857
  node.pendingProps = void 0;
778
858
  if (node.pendingState.length > 0) node.pendingState = [];
859
+ if (node.pendingSelectors.length > 0) node.pendingSelectors = [];
779
860
  node.lastCommitTime = commit.commitTime;
780
861
  this.scheduleFlush();
781
862
  }
@@ -887,6 +968,7 @@ var Detective = class {
887
968
  const selfDuration = Math.max(0, rec.subtreeDuration - accounted);
888
969
  const relevantContexts = contexts.filter((c) => c.commitTime === rec.commitTime);
889
970
  const trackedState = rec.state;
971
+ const selectorChanges = rec.selectors;
890
972
  node.renderNumber++;
891
973
  const diagnosis = diagnose(
892
974
  {
@@ -903,8 +985,10 @@ var Detective = class {
903
985
  attempts: rec.attempts,
904
986
  committed: true,
905
987
  trackedState,
988
+ selectorChanges,
906
989
  remounts: this.lifecycleOf(node.name).remounts,
907
990
  inlineDefinitionSuspected: this.inlineDefinitionSuspected(node.name),
991
+ treeReloadSuspected: rec.phase === "mount" && this.reloadSuspectedAt(timestamp()),
908
992
  priorAvoidableRenders: node.stats.potentiallyAvoidableRenders
909
993
  },
910
994
  this.config.thresholds
@@ -937,6 +1021,7 @@ var Detective = class {
937
1021
  selfOriginated: propsReevaluated === false,
938
1022
  contextChanges: relevantContexts,
939
1023
  trackedState,
1024
+ selectorChanges,
940
1025
  committed: true,
941
1026
  attempts: Math.max(1, rec.attempts),
942
1027
  devReplay: rec.attempts > 1,
@@ -1011,6 +1096,12 @@ var Detective = class {
1011
1096
  };
1012
1097
  }
1013
1098
  clear() {
1099
+ for (const hook of this.clearHooks) {
1100
+ try {
1101
+ hook();
1102
+ } catch {
1103
+ }
1104
+ }
1014
1105
  this.events.clear();
1015
1106
  this.pending.length = 0;
1016
1107
  this.contextChanges.length = 0;
@@ -1032,7 +1123,15 @@ var Detective = class {
1032
1123
  }
1033
1124
  /** Full teardown — used by tests and by HMR disposal. */
1034
1125
  reset() {
1126
+ for (const hook of this.resetHooks) {
1127
+ try {
1128
+ hook();
1129
+ } catch {
1130
+ }
1131
+ }
1132
+ this.resetHooks.clear();
1035
1133
  this.clear();
1134
+ this.clearHooks.clear();
1036
1135
  this.nodes.clear();
1037
1136
  this.lifecycles.clear();
1038
1137
  this.definitions.clear();
@@ -1105,5 +1204,5 @@ function getDetective() {
1105
1204
  }
1106
1205
 
1107
1206
  export { Detective, RingBuffer, defaultConfig, detectDev, diagnose, diffProps, formatInspected, getDetective, inspect, isPlainObject, isReactElement, matches, mergeConfig, severityFor, shallowEqual, shouldInstrument, valueType };
1108
- //# sourceMappingURL=chunk-QOGTEVBP.js.map
1109
- //# sourceMappingURL=chunk-QOGTEVBP.js.map
1207
+ //# sourceMappingURL=chunk-3TZNM7JO.js.map
1208
+ //# sourceMappingURL=chunk-3TZNM7JO.js.map