Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
395 changes: 395 additions & 0 deletions packages/bippy/src/examples/scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,395 @@
import type { Fiber, FiberRoot } from '../types.js';
import {
instrument,
traverseRenderedFibers,
getDisplayName,
isCompositeFiber,
traverseProps,
traverseState,
traverseContexts,
getFiberId,
getTimings,
hasMemoCache,
} from '../core.js';

interface RenderInfo {
displayName: string;
fileName: string | null;
reasons: string[];
causedBy: RenderCause | null;
time: number | null;
isCompiled: boolean;
warnings: string[];
}

type StopFunction = () => void;

declare global {
// eslint-disable-next-line no-var
var scan: typeof scan | undefined;
// eslint-disable-next-line no-var
var stopScan: typeof stopScan | undefined;
// eslint-disable-next-line no-var
var copyScan: typeof copyScan | undefined;
// eslint-disable-next-line no-var
var scanLog: typeof console.log | undefined;
}

// HACK: replace globalThis.scanLog to customize logging (e.g. for Cursor debug mode)
globalThis.scanLog = globalThis.scanLog ?? console.log;

const isShallowEqual = (objA: unknown, objB: unknown): boolean => {
if (Object.is(objA, objB)) return true;
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is((objA as Record<string, unknown>)[key], (objB as Record<string, unknown>)[key])) {
return false;
}
}
return true;
};

const isDeepEqual = (objA: unknown, objB: unknown, depth = 0): boolean => {
if (depth > 5) return false;
if (Object.is(objA, objB)) return true;
if (typeof objA !== typeof objB) return false;
if (typeof objA === 'function' && typeof objB === 'function') {
return objA.toString() === objB.toString();
}
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
return false;
}
if (Array.isArray(objA) !== Array.isArray(objB)) return false;
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
if (!Object.prototype.hasOwnProperty.call(objB, key)) return false;
if (!isDeepEqual((objA as Record<string, unknown>)[key], (objB as Record<string, unknown>)[key], depth + 1)) {
return false;
}
}
return true;
};

interface UnstableInfo {
unstableProps: string[];
unstableFunctions: string[];
unstableState: number[];
}

const getUnstableInfo = (fiber: Fiber): UnstableInfo => {
const unstableProps: string[] = [];
const unstableFunctions: string[] = [];
const unstableState: number[] = [];

if (!fiber.alternate) {
return { unstableProps, unstableFunctions, unstableState };
}

traverseProps(fiber, (propName, nextValue, prevValue) => {
if (Object.is(nextValue, prevValue)) return;
if (propName === 'children') return;

if (typeof nextValue === 'function' && typeof prevValue === 'function') {
if (nextValue.toString() === prevValue.toString()) {
unstableFunctions.push(propName);
}
} else if (typeof nextValue === 'object' && nextValue !== null && typeof prevValue === 'object' && prevValue !== null) {
if (isShallowEqual(nextValue, prevValue)) {
unstableProps.push(`${propName} (shallow)`);
} else if (isDeepEqual(nextValue, prevValue)) {
unstableProps.push(`${propName} (deep)`);
}
}
});

let stateIndex = 0;
traverseState(fiber, (nextState, prevState) => {
const nextVal = nextState?.memoizedState;
const prevVal = prevState?.memoizedState;
if (!Object.is(nextVal, prevVal)) {
if (typeof nextVal === 'object' && nextVal !== null && typeof prevVal === 'object' && prevVal !== null) {
if (isShallowEqual(nextVal, prevVal) || isDeepEqual(nextVal, prevVal)) {
unstableState.push(stateIndex);
}
}
}
stateIndex++;
});

return { unstableProps, unstableFunctions, unstableState };
};

const getFileName = (fiber: Fiber): string | null => {
const debugSource = fiber._debugSource;
if (!debugSource?.fileName) {
return null;
}
const fullPath = debugSource.fileName;
const parts = fullPath.split('/');
return parts[parts.length - 1] || null;
};

interface ChangeInfo {
reasons: string[];
didPropsChange: boolean;
didStateChange: boolean;
didContextChange: boolean;
}

const getChangeInfo = (fiber: Fiber): ChangeInfo => {
const reasons: string[] = [];
let didPropsChange = false;
let didStateChange = false;
let didContextChange = false;

if (!fiber.alternate) {
return { reasons, didPropsChange, didStateChange, didContextChange };
}

const changedProps: string[] = [];
traverseProps(fiber, (propName, nextValue, prevValue) => {
if (!Object.is(nextValue, prevValue)) {
changedProps.push(propName);
}
});
if (changedProps.length > 0) {
didPropsChange = true;
reasons.push(`props: ${changedProps.join(', ')}`);
}

const changedStateIndices: number[] = [];
let stateIndex = 0;
traverseState(fiber, (nextState, prevState) => {
if (!Object.is(nextState?.memoizedState, prevState?.memoizedState)) {
changedStateIndices.push(stateIndex);
}
stateIndex++;
});
if (changedStateIndices.length > 0) {
didStateChange = true;
reasons.push(`state: [${changedStateIndices.join(', ')}]`);
}

traverseContexts(fiber, (nextContext, prevContext) => {
if (!Object.is(nextContext?.memoizedValue, prevContext?.memoizedValue)) {
didContextChange = true;
return true;
}
});
if (didContextChange) {
reasons.push('context');
}

return { reasons, didPropsChange, didStateChange, didContextChange };
};

interface RenderCause {
componentName: string;
prop: string | null;
}

const getChangedProps = (fiber: Fiber): string[] => {
const changedProps: string[] = [];
traverseProps(fiber, (propName, nextValue, prevValue) => {
if (!Object.is(nextValue, prevValue)) {
changedProps.push(propName);
}
});
return changedProps;
};

const findRenderCause = (
fiber: Fiber,
renderedFiberIds: Set<number>,
): RenderCause | null => {
let currentFiber = fiber.return;
let lastRenderedParent: Fiber | null = null;
let propFromParent: string | null = null;

const changedProps = getChangedProps(fiber);
if (changedProps.length > 0) {
propFromParent = changedProps[0];
}

while (currentFiber) {
if (!isCompositeFiber(currentFiber)) {
currentFiber = currentFiber.return;
continue;
}

const parentId = getFiberId(currentFiber);
if (!renderedFiberIds.has(parentId)) {
break;
}

lastRenderedParent = currentFiber;

const parentChangeInfo = getChangeInfo(currentFiber);
if (parentChangeInfo.didStateChange || parentChangeInfo.didContextChange) {
return {
componentName: getDisplayName(currentFiber.type) || 'Unknown',
prop: propFromParent,
};
}

currentFiber = currentFiber.return;
}

if (lastRenderedParent) {
return {
componentName: getDisplayName(lastRenderedParent.type) || 'Unknown',
prop: propFromParent,
};
}

return null;
};

const formatRenderInfo = (info: RenderInfo, phase: string): string => {
const compiledText = info.isCompiled ? ' [react-compiler]' : '';
const fileText = info.fileName ? ` (${info.fileName})` : '';
const reasonText = info.reasons.length > 0 ? ` { ${info.reasons.join(' | ')} }` : '';
let causedByText = '';
if (info.causedBy) {
const propText = info.causedBy.prop ? `.${info.causedBy.prop}` : '';
causedByText = ` ← ${info.causedBy.componentName}${propText}`;
}
const timeText = info.time !== null && info.time > 0 ? ` ${info.time.toFixed(2)}ms` : '';
const warningText = info.warnings.length > 0 ? ` ⚠️ ${info.warnings.join(', ')}` : '';
return `[${phase}] ${info.displayName}${compiledText}${fileText}${reasonText}${causedByText}${timeText}${warningText}`;
};

interface LogEntry {
message: string;
totalTime: number;
}

let logHistory: string[] = [];

const flushLogs = (entries: LogEntry[]): void => {
const grouped = new Map<string, { count: number; totalTime: number }>();
for (const entry of entries) {
const existing = grouped.get(entry.message);
if (existing) {
existing.count++;
existing.totalTime += entry.totalTime;
} else {
grouped.set(entry.message, { count: 1, totalTime: entry.totalTime });
}
}
for (const [message, { count, totalTime }] of grouped) {
const countSuffix = count > 1 ? ` x${count}` : '';
const aggregateTime = count > 1 && totalTime > 0 ? ` (total: ${totalTime.toFixed(2)}ms)` : '';
const fullMessage = `${message}${countSuffix}${aggregateTime}`;
logHistory.push(fullMessage);
globalThis.scanLog?.(fullMessage);
}
};

let currentStopFunction: StopFunction | null = null;

const scan = (): StopFunction => {
if (typeof globalThis === 'undefined') {
return () => {};
}

if (currentStopFunction) {
currentStopFunction();
}

logHistory = [];
let isActive = true;

const onCommitFiberRoot = (_rendererID: number, root: FiberRoot): void => {
if (!isActive) return;

const renderedFiberIds = new Set<number>();
const renderedFibers: Array<{ fiber: Fiber; phase: string }> = [];

traverseRenderedFibers(root, (fiber: Fiber, phase) => {
if (!isCompositeFiber(fiber)) return;
renderedFiberIds.add(getFiberId(fiber));
renderedFibers.push({ fiber, phase });
});

const logEntries: LogEntry[] = [];

for (const { fiber, phase } of renderedFibers) {
const displayName = getDisplayName(fiber.type) || 'Unknown';
const fileName = getFileName(fiber);
const { selfTime } = getTimings(fiber);
const isCompiled = hasMemoCache(fiber);

if (phase === 'unmount') {
const message = formatRenderInfo({ displayName, fileName, reasons: [], causedBy: null, time: null, isCompiled, warnings: [] }, phase);
logEntries.push({ message, totalTime: 0 });
continue;
}

const changeInfo = phase === 'update' ? getChangeInfo(fiber) : { reasons: [], didPropsChange: false, didStateChange: false, didContextChange: false };

let causedBy: RenderCause | null = null;
if (phase === 'update' && changeInfo.didPropsChange && !changeInfo.didStateChange && !changeInfo.didContextChange) {
causedBy = findRenderCause(fiber, renderedFiberIds);
}

const warnings: string[] = [];
if (phase === 'update') {
const unstableInfo = getUnstableInfo(fiber);
if (unstableInfo.unstableFunctions.length > 0) {
warnings.push(`unstable function: ${unstableInfo.unstableFunctions.join(', ')}`);
}
if (unstableInfo.unstableProps.length > 0) {
warnings.push(`unstable object: ${unstableInfo.unstableProps.join(', ')}`);
}
if (unstableInfo.unstableState.length > 0) {
warnings.push(`unstable state: [${unstableInfo.unstableState.join(', ')}]`);
}
}

const message = formatRenderInfo({ displayName, fileName, reasons: changeInfo.reasons, causedBy, time: selfTime, isCompiled, warnings }, phase);
logEntries.push({ message, totalTime: selfTime });
}

flushLogs(logEntries);
};

instrument({ onCommitFiberRoot });

const stop: StopFunction = () => {
isActive = false;
if (currentStopFunction === stop) {
currentStopFunction = null;
}
};

currentStopFunction = stop;

return stop;
};

const stopScan = (): void => {
if (currentStopFunction) {
currentStopFunction();
}
};

const copyScan = async (): Promise<void> => {
const text = logHistory.join('\n');
if (typeof navigator !== 'undefined' && navigator.clipboard) {
await navigator.clipboard.writeText(text);
}
};

if (typeof globalThis !== 'undefined') {
globalThis.scan = scan;
globalThis.stopScan = stopScan;
globalThis.copyScan = copyScan;
}
Loading