apps/mobile/src/features/development/render-counter.ts
// Load before React to wrap commits. Skip bailed-out child === alternate.child subtrees; PerformedWork marks rendered fibers.
interface Fiber {
readonly tag: number;
readonly type: unknown;
readonly flags: number;
readonly alternate: Fiber | null;
readonly child: Fiber | null;
readonly sibling: Fiber | null;
readonly memoizedProps: Record<string, unknown> | null;
readonly memoizedState: HookSlot | null;
}
interface HookSlot {
readonly memoizedState: unknown;
/** Non-null only for useState and useReducer, the hooks that can schedule a render. */
readonly queue: unknown;
readonly next: HookSlot | null;
}
interface DevToolsHook {
onCommitFiberRoot?: (id: unknown, root: { current: Fiber }, ...rest: unknown[]) => unknown;
__t3RenderCounter?: true;
}
interface RenderCounterState {
on: boolean;
commits: number;
counts: Map<string, number>;
causes: Map<string, number>;
}
export interface RenderCounterReport {
readonly commits: number;
readonly totalRenders: number;
readonly components: ReadonlyArray<{ key: string; count: number }>;
readonly causes: ReadonlyArray<{ key: string; count: number }>;
}
const COMPONENT_TAGS = new Set([0, 1, 11, 14, 15]);
const PERFORMED_WORK = 1;
function nameOf(fiber: Fiber): string | null {
const type = fiber.type as {
displayName?: string;
name?: string;
type?: { displayName?: string; name?: string };
render?: { displayName?: string; name?: string };
} | null;
if (!type || typeof type === "string") return null;
const inner = type.type ?? type.render ?? type;
const raw = type.displayName || inner.displayName || inner.name || "";
return raw.replace(/^(Forget\(|Memo\()+|\)+$/g, "") || null;
}
function isEffect(value: unknown): boolean {
return (
typeof value === "object" &&
value !== null &&
"create" in value &&
"deps" in value &&
"tag" in value
);
}
function changedStateSlot(alternate: Fiber, node: Fiber): number {
let a = alternate.memoizedState;
let b = node.memoizedState;
for (let index = 0; a && b && index < 800; index++) {
const before = a.memoizedState;
const after = b.memoizedState;
// Only state updates cause renders; memo, callback and ref changes are consequences.
if (b.queue !== null && !isEffect(after) && !Object.is(before, after)) return index;
a = a.next;
b = b.next;
}
return -1;
}
function causeOf(node: Fiber, parentName: string): string {
const alternate = node.alternate;
if (!alternate) return "mount";
const slot = changedStateSlot(alternate, node);
if (slot >= 0) return `state#${slot}`;
if (alternate.memoizedProps !== node.memoizedProps) {
const props = node.memoizedProps ?? {};
const keys = Object.keys(props).filter(
(key) => !Object.is(alternate.memoizedProps?.[key], props[key]),
);
return keys.length ? `props:${keys.slice(0, 4).join(",")}` : `parent-same-props<-${parentName}`;
}
return "context";
}
function increment(map: Map<string, number>, key: string) {
map.set(key, (map.get(key) ?? 0) + 1);
}
function install(state: RenderCounterState) {
const hook = (globalThis as { __REACT_DEVTOOLS_GLOBAL_HOOK__?: DevToolsHook })
.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook || typeof hook.onCommitFiberRoot !== "function") {
setTimeout(() => install(state), 50);
return;
}
if (hook.__t3RenderCounter) return;
hook.__t3RenderCounter = true;
const original = hook.onCommitFiberRoot.bind(hook);
const visit = (fiber: Fiber | null, parentName: string) => {
let node = fiber;
while (node) {
const alternate = node.alternate;
const name = COMPONENT_TAGS.has(node.tag) ? nameOf(node) : null;
const rendered = name !== null && (node.flags & PERFORMED_WORK) === PERFORMED_WORK;
if (rendered) {
increment(state.counts, name);
increment(state.causes, `${name} ${causeOf(node, parentName)}`);
}
const skipped = alternate !== null && node.child === alternate.child && !rendered;
if (node.child && !skipped) visit(node.child, rendered ? name : parentName);
node = node.sibling;
}
};
hook.onCommitFiberRoot = (id, root, ...rest) => {
if (state.on) {
state.commits += 1;
visit(root.current.child, "root");
}
return original(id, root, ...rest);
};
}
const state: RenderCounterState = { on: false, commits: 0, counts: new Map(), causes: new Map() };
const sorted = (map: Map<string, number>) =>
[...map].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count);
install(state);
Object.assign(globalThis, {
__t3RenderCounterOpen(url: string) {
const { Linking } = require("react-native") as typeof import("react-native");
void Linking.openURL(url);
return true;
},
/** Restarts the JS app on Home; deferred so the inspector call can reply first. */
__t3RenderCounterReload() {
const { DevSettings } = require("react-native") as typeof import("react-native");
setTimeout(() => DevSettings.reload(), 100);
return true;
},
__t3RenderCounterStart() {
state.commits = 0;
state.counts = new Map();
state.causes = new Map();
state.on = true;
return true;
},
__t3RenderCounterStop(): RenderCounterReport {
state.on = false;
const components = sorted(state.counts);
return {
commits: state.commits,
totalRenders: components.reduce((total, entry) => total + entry.count, 0),
components,
causes: sorted(state.causes),
};
},
});