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.
- package/CHANGELOG.md +299 -0
- package/README.md +2 -2
- package/dist/babel.cjs +2 -2
- package/dist/babel.d.cts +11 -0
- package/dist/babel.d.ts +11 -0
- package/dist/babel.js +1 -1
- package/dist/{chunk-QOGTEVBP.js → chunk-3TZNM7JO.js} +103 -4
- package/dist/chunk-3TZNM7JO.js.map +1 -0
- package/dist/{chunk-HOTY7J3X.js → chunk-D7M4UC3Y.js} +26 -2
- package/dist/chunk-D7M4UC3Y.js.map +1 -0
- package/dist/{chunk-PHYYA67T.cjs → chunk-EXDRRUUQ.cjs} +69 -2
- package/dist/chunk-EXDRRUUQ.cjs.map +1 -0
- package/dist/{chunk-DZ3BZ654.cjs → chunk-LCGXVRJR.cjs} +103 -4
- package/dist/chunk-LCGXVRJR.cjs.map +1 -0
- package/dist/{chunk-MCIQMYNR.js → chunk-QVHAC7AD.js} +69 -2
- package/dist/chunk-QVHAC7AD.js.map +1 -0
- package/dist/{chunk-JD4IJ4MN.cjs → chunk-RGT44QQI.cjs} +26 -2
- package/dist/chunk-RGT44QQI.cjs.map +1 -0
- package/dist/core.cjs +18 -18
- package/dist/core.d.cts +27 -3
- package/dist/core.d.ts +27 -3
- package/dist/core.js +1 -1
- package/dist/index.cjs +197 -295
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +35 -115
- package/dist/index.d.ts +35 -115
- package/dist/index.js +166 -258
- package/dist/index.js.map +1 -1
- package/dist/interactions.cjs +252 -0
- package/dist/interactions.cjs.map +1 -0
- package/dist/interactions.d.cts +110 -0
- package/dist/interactions.d.ts +110 -0
- package/dist/interactions.js +241 -0
- package/dist/interactions.js.map +1 -0
- package/dist/overlay.cjs +5 -5
- package/dist/overlay.js +2 -2
- package/dist/testing.d.cts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/{types-gl13xwmN.d.cts → types-CRET8EhB.d.cts} +20 -2
- package/dist/{types-gl13xwmN.d.ts → types-CRET8EhB.d.ts} +20 -2
- package/dist/vite.cjs +2 -2
- package/dist/vite.js +1 -1
- package/package.json +14 -4
- package/dist/chunk-DZ3BZ654.cjs.map +0 -1
- package/dist/chunk-HOTY7J3X.js.map +0 -1
- package/dist/chunk-JD4IJ4MN.cjs.map +0 -1
- package/dist/chunk-MCIQMYNR.js.map +0 -1
- package/dist/chunk-PHYYA67T.cjs.map +0 -1
- 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.
|
|
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 ·
|
|
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
|
|
3
|
+
var chunkEXDRRUUQ_cjs = require('./chunk-EXDRRUUQ.cjs');
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
module.exports =
|
|
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
|
@@ -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
|
|
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)
|
|
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-
|
|
1109
|
-
//# sourceMappingURL=chunk-
|
|
1207
|
+
//# sourceMappingURL=chunk-3TZNM7JO.js.map
|
|
1208
|
+
//# sourceMappingURL=chunk-3TZNM7JO.js.map
|