1// Load before React to wrap commits. Skip bailed-out child === alternate.child subtrees; PerformedWork marks rendered fibers.
2
3interface Fiber {
4 readonly tag: number;
5 readonly type: unknown;
6 readonly flags: number;
7 readonly alternate: Fiber | null;
8 readonly child: Fiber | null;
9 readonly sibling: Fiber | null;
10 readonly memoizedProps: Record<string, unknown> | null;
11 readonly memoizedState: HookSlot | null;
12}
13interface HookSlot {
14 readonly memoizedState: unknown;
15 /** Non-null only for useState and useReducer, the hooks that can schedule a render. */
16 readonly queue: unknown;
17 readonly next: HookSlot | null;
18}
19interface DevToolsHook {
20 onCommitFiberRoot?: (id: unknown, root: { current: Fiber }, ...rest: unknown[]) => unknown;
21 __t3RenderCounter?: true;
22}
23interface RenderCounterState {
24 on: boolean;
25 commits: number;
26 counts: Map<string, number>;
27 causes: Map<string, number>;
28}
29export interface RenderCounterReport {
30 readonly commits: number;
31 readonly totalRenders: number;
32 readonly components: ReadonlyArray<{ key: string; count: number }>;
33 readonly causes: ReadonlyArray<{ key: string; count: number }>;
34}
35
36const COMPONENT_TAGS = new Set([0, 1, 11, 14, 15]);
37const PERFORMED_WORK = 1;
38
39function nameOf(fiber: Fiber): string | null {
40 const type = fiber.type as {
41 displayName?: string;
42 name?: string;
43 type?: { displayName?: string; name?: string };
44 render?: { displayName?: string; name?: string };
45 } | null;
46 if (!type || typeof type === "string") return null;
47 const inner = type.type ?? type.render ?? type;
48 const raw = type.displayName || inner.displayName || inner.name || "";
49 return raw.replace(/^(Forget\(|Memo\()+|\)+$/g, "") || null;
50}
51
52function isEffect(value: unknown): boolean {
53 return (
54 typeof value === "object" &&
55 value !== null &&
56 "create" in value &&
57 "deps" in value &&
58 "tag" in value
59 );
60}
61
62function changedStateSlot(alternate: Fiber, node: Fiber): number {
63 let a = alternate.memoizedState;
64 let b = node.memoizedState;
65 for (let index = 0; a && b && index < 800; index++) {
66 const before = a.memoizedState;
67 const after = b.memoizedState;
68 // Only state updates cause renders; memo, callback and ref changes are consequences.
69 if (b.queue !== null && !isEffect(after) && !Object.is(before, after)) return index;
70 a = a.next;
71 b = b.next;
72 }
73 return -1;
74}
75
76function causeOf(node: Fiber, parentName: string): string {
77 const alternate = node.alternate;
78 if (!alternate) return "mount";
79 const slot = changedStateSlot(alternate, node);
80 if (slot >= 0) return `state#${slot}`;
81 if (alternate.memoizedProps !== node.memoizedProps) {
82 const props = node.memoizedProps ?? {};
83 const keys = Object.keys(props).filter(
84 (key) => !Object.is(alternate.memoizedProps?.[key], props[key]),
85 );
86 return keys.length ? `props:${keys.slice(0, 4).join(",")}` : `parent-same-props<-${parentName}`;
87 }
88 return "context";
89}
90
91function increment(map: Map<string, number>, key: string) {
92 map.set(key, (map.get(key) ?? 0) + 1);
93}
94
95function install(state: RenderCounterState) {
96 const hook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: DevToolsHook })
97 .__REACT_DEVTOOLS_GLOBAL_HOOK__;
98 if (!hook || typeof hook.onCommitFiberRoot !== "function") {
99 setTimeout(() => install(state), 50);
100 return;
101 }
102 if (hook.__t3RenderCounter) return;
103 hook.__t3RenderCounter = true;
104 const original = hook.onCommitFiberRoot.bind(hook);
105
106 const visit = (fiber: Fiber | null, parentName: string) => {
107 let node = fiber;
108 while (node) {
109 const alternate = node.alternate;
110 const name = COMPONENT_TAGS.has(node.tag) ? nameOf(node) : null;
111 const rendered = name !== null && (node.flags & PERFORMED_WORK) === PERFORMED_WORK;
112 if (rendered) {
113 increment(state.counts, name);
114 increment(state.causes, `${name} ${causeOf(node, parentName)}`);
115 }
116 const skipped = alternate !== null && node.child === alternate.child && !rendered;
117 if (node.child && !skipped) visit(node.child, rendered ? name : parentName);
118 node = node.sibling;
119 }
120 };
121
122 hook.onCommitFiberRoot = (id, root, ...rest) => {
123 if (state.on) {
124 state.commits += 1;
125 visit(root.current.child, "root");
126 }
127 return original(id, root, ...rest);
128 };
129}
130
131const state: RenderCounterState = { on: false, commits: 0, counts: new Map(), causes: new Map() };
132
133const sorted = (map: Map<string, number>) =>
134 [...map].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count);
135
136install(state);
137
138Object.assign(globalThis, {
139 __t3RenderCounterOpen(url: string) {
140 const { Linking } = require("react-native") as typeof import("react-native");
141 void Linking.openURL(url);
142 return true;
143 },
144 /** Restarts the JS app on Home; deferred so the inspector call can reply first. */
145 __t3RenderCounterReload() {
146 const { DevSettings } = require("react-native") as typeof import("react-native");
147 setTimeout(() => DevSettings.reload(), 100);
148 return true;
149 },
150 __t3RenderCounterStart() {
151 state.commits = 0;
152 state.counts = new Map();
153 state.causes = new Map();
154 state.on = true;
155 return true;
156 },
157 __t3RenderCounterStop(): RenderCounterReport {
158 state.on = false;
159 const components = sorted(state.counts);
160 return {
161 commits: state.commits,
162 totalRenders: components.reduce((total, entry) => total + entry.count, 0),
163 components,
164 causes: sorted(state.causes),
165 };
166 },
167});