Skip to content
Source
Sign inSign up

renderCounter.ts

108 lines3.6 KB

1// Counts commits and component renders via the DevTools hook, so it loads before react-dom.
2// Same walk as apps/mobile/src/features/development/render-counter.ts: bailed-out subtrees
3// (child === alternate.child) keep stale PerformedWork flags and are skipped.
4​
5interface Fiber {
6 readonly tag: number;
7 readonly type: unknown;
8 readonly flags: number;
9 readonly alternate: Fiber | null;
10 readonly child: Fiber | null;
11 readonly sibling: Fiber | null;
13​
14export interface RenderReport {
15 readonly commits: number;
16 readonly renders: number;
17 readonly components: Readonly<Record<string, number>>;
19​
20// Function, class, forwardRef, memo and simple memo components.
21const COMPONENT_TAGS = new Set([0, 1, 11, 14, 15]);
22const PERFORMED_WORK = 1;
23​
24function nameOf(fiber: Fiber): string | null {
25 const type = fiber.type as {
26 displayName?: string;
27 name?: string;
28 type?: { displayName?: string; name?: string };
29 render?: { displayName?: string; name?: string };
30 } | null;
31 if (!type || (typeof type !== "object" && typeof type !== "function")) return null;
32 const inner = type.type ?? type.render ?? type;
33 const raw = type.displayName || inner.displayName || inner.name || "";
34 return raw.replace(/^(Forget\(|Memo\()+|\)+$/g, "") || "Anonymous";
36​
37// The setup file and a test file can load this module as two instances. React reports to
38// whichever hook existed when react-dom initialized, so both share one hook and one state.
39const STATE = Symbol.for("t3code.renderCounter");
40const state = ((globalThis as { [STATE]?: CounterState })[STATE] ??= {
41 on: false,
42 commits: 0,
43 everCommitted: 0,
44 counts: new Map(),
45});
46​
47interface CounterState {
48 on: boolean;
49 commits: number;
50 everCommitted: number;
51 counts: Map<string, number>;
53​
54/** Adds the components that rendered under `fiber` and returns how many did. */
55function visit(fiber: Fiber | null): number {
56 let rendered = 0;
57 for (let node = fiber; node; node = node.sibling) {
58 const performed =
59 COMPONENT_TAGS.has(node.tag) && (node.flags & PERFORMED_WORK) === PERFORMED_WORK;
60 const name = performed ? nameOf(node) : null;
61 if (name) {
62 state.counts.set(name, (state.counts.get(name) ?? 0) + 1);
63 rendered += 1;
64 }
65 const skipped = node.alternate !== null && node.child === node.alternate.child && !performed;
66 if (node.child && !skipped) rendered += visit(node.child);
67 }
68 return rendered;
70​
71// React only reports commits to a hook that exists when react-dom initializes.
72if (!("__REACT_DEVTOOLS_GLOBAL_HOOK__" in globalThis))
73 Object.assign(globalThis, {
74 __REACT_DEVTOOLS_GLOBAL_HOOK__: {
75 supportsFiber: true,
76 renderers: new Map(),
77 inject: () => 1,
78 checkDCE: () => {},
79 onCommitFiberUnmount: () => {},
80 onPostCommitFiberRoot: () => {},
81 onCommitFiberRoot: (_id: unknown, root: { current: Fiber }) => {
82 state.everCommitted += 1;
83 // Commits that render no component (layout-only batches) vary with frame timing.
84 if (state.on && visit(root.current.child) > 0) state.commits += 1;
85 },
86 },
87 });
88​
89/** Every commit since load, counted or not, so a scenario can wait for the tree to go quiet. */
90export function commitsSoFar(): number {
91 return state.everCommitted;
93​
94export function startCounting(): void {
95 state.commits = 0;
96 state.counts = new Map();
97 state.on = true;
99​
100export function stopCounting(): RenderReport {
101 state.on = false;
102 const sorted = [...state.counts].sort(([a, x], [b, y]) => y - x || a.localeCompare(b));
103 return {
104 commits: state.commits,
105 renders: sorted.reduce((total, [, count]) => total + count, 0),
106 components: Object.fromEntries(sorted),
107 };