Add `packages/app/src/constants/platform.ts` with canonical gates (isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc Platform.OS checks across 69 files to use shared imports. Fix sidebar hover crash on native (onPointerEnter → onHoverIn), fix tooltip to use breakpoint instead of platform detection, make sidebar action buttons always visible on native where hover doesn't work, and document the platform gating decision matrix in CLAUDE.md.
567 lines
17 KiB
TypeScript
567 lines
17 KiB
TypeScript
import { isWeb } from "@/constants/platform";
|
|
|
|
type ListenerStats = {
|
|
adds: number;
|
|
removes: number;
|
|
active: number;
|
|
};
|
|
|
|
type TimerStats = {
|
|
created: number;
|
|
fired: number;
|
|
cleared: number;
|
|
active: number;
|
|
};
|
|
|
|
type WebSocketStats = {
|
|
created: number;
|
|
opened: number;
|
|
closed: number;
|
|
errored: number;
|
|
active: number;
|
|
};
|
|
|
|
type ComponentStats = {
|
|
mounts: number;
|
|
unmounts: number;
|
|
renders: number;
|
|
scrollEvents: number;
|
|
nearBottomTransitions: number;
|
|
metricUpdates: number;
|
|
itemRenderCalls: number;
|
|
wheelAttach: number;
|
|
wheelDetach: number;
|
|
inputChanges: number;
|
|
keyPresses: number;
|
|
lastRenderAtMs: number;
|
|
};
|
|
|
|
type ScrollInvestigationStore = {
|
|
markRender: (componentId: string) => void;
|
|
markEvent: (
|
|
componentId: string,
|
|
event:
|
|
| "mount"
|
|
| "unmount"
|
|
| "scrollEvent"
|
|
| "nearBottomTransition"
|
|
| "metricUpdate"
|
|
| "itemRenderCall"
|
|
| "wheelAttach"
|
|
| "wheelDetach"
|
|
| "inputChange"
|
|
| "keyPress",
|
|
) => void;
|
|
snapshot: () => {
|
|
listeners: {
|
|
byType: Record<string, ListenerStats>;
|
|
byCallsite: Record<string, number>;
|
|
activeUniqueKeys: number;
|
|
activeByTypeAndTarget: Record<string, Record<string, number>>;
|
|
};
|
|
timers: {
|
|
timeout: TimerStats;
|
|
interval: TimerStats;
|
|
raf: TimerStats;
|
|
};
|
|
websockets: {
|
|
totals: WebSocketStats;
|
|
activeByUrl: Record<string, number>;
|
|
};
|
|
components: Record<string, ComponentStats>;
|
|
};
|
|
printSnapshot: (label?: string) => void;
|
|
_installedAtMs: number;
|
|
};
|
|
|
|
type ScrollInvestigationGlobal = typeof globalThis & {
|
|
__PASEO_SCROLL_JANK_INVESTIGATION__?: ScrollInvestigationStore;
|
|
__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__?: boolean;
|
|
};
|
|
|
|
const TRACKED_EVENT_TYPES = new Set([
|
|
"wheel",
|
|
"scroll",
|
|
"pointermove",
|
|
"pointerup",
|
|
"pointercancel",
|
|
]);
|
|
|
|
const SOURCE_LABEL = "[ScrollJankInvestigation]";
|
|
|
|
function shouldInstall(): boolean {
|
|
const runtime = globalThis as ScrollInvestigationGlobal;
|
|
const isDev = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
|
return isWeb && isDev && !runtime.__PASEO_SCROLL_JANK_INVESTIGATION_DISABLED__;
|
|
}
|
|
|
|
function normalizeCapture(options?: AddEventListenerOptions | boolean): boolean {
|
|
if (typeof options === "boolean") {
|
|
return options;
|
|
}
|
|
return Boolean(options?.capture);
|
|
}
|
|
|
|
function describeEventTarget(target: EventTarget): string {
|
|
const element = target as Element;
|
|
if (element && typeof element === "object" && "tagName" in element) {
|
|
const tagName = (element.tagName || "unknown").toLowerCase();
|
|
const testId = element.getAttribute?.("data-testid");
|
|
const role = element.getAttribute?.("role");
|
|
const id = (element as HTMLElement).id || null;
|
|
const className = (element as HTMLElement).className;
|
|
const classLabel =
|
|
typeof className === "string" && className.trim().length > 0
|
|
? className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
: null;
|
|
const connectivityLabel =
|
|
typeof (element as Node).isConnected === "boolean"
|
|
? (element as Node).isConnected
|
|
? "[connected]"
|
|
: "[detached]"
|
|
: null;
|
|
|
|
return [
|
|
tagName,
|
|
id ? `#${id}` : null,
|
|
testId ? `[data-testid=${testId}]` : null,
|
|
role ? `[role=${role}]` : null,
|
|
classLabel ? `.${classLabel}` : null,
|
|
connectivityLabel,
|
|
]
|
|
.filter(Boolean)
|
|
.join("");
|
|
}
|
|
|
|
const ctorName = (target as { constructor?: { name?: string } }).constructor?.name;
|
|
return ctorName || "unknown-target";
|
|
}
|
|
|
|
function inferCallsite(): string {
|
|
const stack = new Error().stack;
|
|
if (!stack) {
|
|
return "unknown";
|
|
}
|
|
const frames = stack.split("\n");
|
|
for (const raw of frames.slice(2)) {
|
|
const line = raw.trim();
|
|
if (!line) {
|
|
continue;
|
|
}
|
|
if (line.includes("scroll-jank-investigation")) {
|
|
continue;
|
|
}
|
|
if (line.includes("patchedAddEventListener")) {
|
|
continue;
|
|
}
|
|
return line;
|
|
}
|
|
return "unknown";
|
|
}
|
|
|
|
function ensureListenerStats(map: Map<string, ListenerStats>, type: string): ListenerStats {
|
|
const existing = map.get(type);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const next: ListenerStats = { adds: 0, removes: 0, active: 0 };
|
|
map.set(type, next);
|
|
return next;
|
|
}
|
|
|
|
function ensureComponentStats(
|
|
map: Map<string, ComponentStats>,
|
|
componentId: string,
|
|
): ComponentStats {
|
|
const existing = map.get(componentId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const next: ComponentStats = {
|
|
mounts: 0,
|
|
unmounts: 0,
|
|
renders: 0,
|
|
scrollEvents: 0,
|
|
nearBottomTransitions: 0,
|
|
metricUpdates: 0,
|
|
itemRenderCalls: 0,
|
|
wheelAttach: 0,
|
|
wheelDetach: 0,
|
|
inputChanges: 0,
|
|
keyPresses: 0,
|
|
lastRenderAtMs: 0,
|
|
};
|
|
map.set(componentId, next);
|
|
return next;
|
|
}
|
|
|
|
export function installScrollJankInvestigation(): void {
|
|
if (!shouldInstall()) {
|
|
return;
|
|
}
|
|
|
|
const runtime = globalThis as ScrollInvestigationGlobal;
|
|
if (runtime.__PASEO_SCROLL_JANK_INVESTIGATION__) {
|
|
return;
|
|
}
|
|
|
|
const targetIds = new WeakMap<object, number>();
|
|
const listenerIds = new WeakMap<object, number>();
|
|
const activeListenerKeys = new Set<string>();
|
|
const activeListenerMeta = new Map<string, { type: string; target: string }>();
|
|
const listenerStatsByType = new Map<string, ListenerStats>();
|
|
const listenerCallsiteCount = new Map<string, number>();
|
|
const componentStatsById = new Map<string, ComponentStats>();
|
|
const activeWsByUrl = new Map<string, number>();
|
|
const timeoutHandles = new Map<number, true>();
|
|
const intervalHandles = new Map<number, true>();
|
|
const rafHandles = new Map<number, true>();
|
|
let nextTargetId = 1;
|
|
let nextListenerId = 1;
|
|
|
|
const timerStats = {
|
|
timeout: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
|
|
interval: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
|
|
raf: { created: 0, fired: 0, cleared: 0, active: 0 } as TimerStats,
|
|
};
|
|
const websocketStats: WebSocketStats = {
|
|
created: 0,
|
|
opened: 0,
|
|
closed: 0,
|
|
errored: 0,
|
|
active: 0,
|
|
};
|
|
|
|
const eventTargetProto = EventTarget.prototype as EventTarget & {
|
|
addEventListener: EventTarget["addEventListener"];
|
|
removeEventListener: EventTarget["removeEventListener"];
|
|
};
|
|
const nativeAddEventListener = eventTargetProto.addEventListener;
|
|
const nativeRemoveEventListener = eventTargetProto.removeEventListener;
|
|
|
|
function getTargetId(target: EventTarget): string {
|
|
const targetObj = target as unknown as object;
|
|
const existing = targetIds.get(targetObj);
|
|
if (existing) {
|
|
return String(existing);
|
|
}
|
|
const next = nextTargetId++;
|
|
targetIds.set(targetObj, next);
|
|
return String(next);
|
|
}
|
|
|
|
function getListenerId(listener: EventListenerOrEventListenerObject): string {
|
|
const listenerObj = listener as unknown as object;
|
|
const existing = listenerIds.get(listenerObj);
|
|
if (existing) {
|
|
return String(existing);
|
|
}
|
|
const next = nextListenerId++;
|
|
listenerIds.set(listenerObj, next);
|
|
return String(next);
|
|
}
|
|
|
|
function toListenerKey(
|
|
target: EventTarget,
|
|
type: string,
|
|
listener: EventListenerOrEventListenerObject,
|
|
options?: AddEventListenerOptions | boolean,
|
|
): string {
|
|
return [
|
|
getTargetId(target),
|
|
type,
|
|
normalizeCapture(options) ? "capture" : "bubble",
|
|
getListenerId(listener),
|
|
].join("|");
|
|
}
|
|
|
|
eventTargetProto.addEventListener = function patchedAddEventListener(
|
|
this: EventTarget,
|
|
type: string,
|
|
listener: EventListenerOrEventListenerObject | null,
|
|
options?: AddEventListenerOptions | boolean,
|
|
): void {
|
|
nativeAddEventListener.call(this, type, listener as any, options as any);
|
|
if (!listener) {
|
|
return;
|
|
}
|
|
const stats = ensureListenerStats(listenerStatsByType, type);
|
|
stats.adds += 1;
|
|
|
|
const key = toListenerKey(this, type, listener, options);
|
|
if (!activeListenerKeys.has(key)) {
|
|
activeListenerKeys.add(key);
|
|
stats.active += 1;
|
|
activeListenerMeta.set(key, {
|
|
type,
|
|
target: describeEventTarget(this),
|
|
});
|
|
}
|
|
|
|
if (TRACKED_EVENT_TYPES.has(type)) {
|
|
const callsite = inferCallsite();
|
|
const metricKey = `${type} :: ${callsite}`;
|
|
listenerCallsiteCount.set(metricKey, (listenerCallsiteCount.get(metricKey) ?? 0) + 1);
|
|
}
|
|
};
|
|
|
|
eventTargetProto.removeEventListener = function patchedRemoveEventListener(
|
|
this: EventTarget,
|
|
type: string,
|
|
listener: EventListenerOrEventListenerObject | null,
|
|
options?: EventListenerOptions | boolean,
|
|
): void {
|
|
nativeRemoveEventListener.call(this, type, listener as any, options as any);
|
|
if (!listener) {
|
|
return;
|
|
}
|
|
const stats = ensureListenerStats(listenerStatsByType, type);
|
|
stats.removes += 1;
|
|
|
|
const key = toListenerKey(this, type, listener, options);
|
|
if (activeListenerKeys.delete(key)) {
|
|
stats.active = Math.max(0, stats.active - 1);
|
|
activeListenerMeta.delete(key);
|
|
}
|
|
};
|
|
|
|
const nativeSetTimeout = globalThis.setTimeout.bind(globalThis);
|
|
const nativeClearTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
const nativeSetInterval = globalThis.setInterval.bind(globalThis);
|
|
const nativeClearInterval = globalThis.clearInterval.bind(globalThis);
|
|
const nativeRequestAnimationFrame = globalThis.requestAnimationFrame.bind(globalThis);
|
|
const nativeCancelAnimationFrame = globalThis.cancelAnimationFrame.bind(globalThis);
|
|
|
|
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
|
timerStats.timeout.created += 1;
|
|
let timeoutId = -1;
|
|
const wrapped =
|
|
typeof handler === "function"
|
|
? (...handlerArgs: unknown[]) => {
|
|
if (timeoutHandles.delete(timeoutId)) {
|
|
timerStats.timeout.fired += 1;
|
|
timerStats.timeout.active = timeoutHandles.size;
|
|
}
|
|
return handler(...handlerArgs);
|
|
}
|
|
: handler;
|
|
|
|
timeoutId = nativeSetTimeout(wrapped, timeout, ...(args as any[])) as unknown as number;
|
|
timeoutHandles.set(timeoutId, true);
|
|
timerStats.timeout.active = timeoutHandles.size;
|
|
return timeoutId as unknown as ReturnType<typeof setTimeout>;
|
|
}) as unknown as typeof setTimeout;
|
|
|
|
globalThis.clearTimeout = ((timeoutId?: number) => {
|
|
if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) {
|
|
timerStats.timeout.cleared += 1;
|
|
timerStats.timeout.active = timeoutHandles.size;
|
|
}
|
|
return nativeClearTimeout(timeoutId);
|
|
}) as typeof clearTimeout;
|
|
|
|
globalThis.setInterval = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
|
|
timerStats.interval.created += 1;
|
|
let intervalId = -1;
|
|
const wrapped =
|
|
typeof handler === "function"
|
|
? (...handlerArgs: unknown[]) => {
|
|
timerStats.interval.fired += 1;
|
|
return handler(...handlerArgs);
|
|
}
|
|
: handler;
|
|
|
|
intervalId = nativeSetInterval(wrapped, timeout, ...(args as any[])) as unknown as number;
|
|
intervalHandles.set(intervalId, true);
|
|
timerStats.interval.active = intervalHandles.size;
|
|
return intervalId as unknown as ReturnType<typeof setInterval>;
|
|
}) as unknown as typeof setInterval;
|
|
|
|
globalThis.clearInterval = ((intervalId?: number) => {
|
|
if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) {
|
|
timerStats.interval.cleared += 1;
|
|
timerStats.interval.active = intervalHandles.size;
|
|
}
|
|
return nativeClearInterval(intervalId);
|
|
}) as typeof clearInterval;
|
|
|
|
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
|
timerStats.raf.created += 1;
|
|
let rafId = -1;
|
|
const wrapped = (timestamp: number) => {
|
|
if (rafHandles.delete(rafId)) {
|
|
timerStats.raf.fired += 1;
|
|
timerStats.raf.active = rafHandles.size;
|
|
}
|
|
callback(timestamp);
|
|
};
|
|
rafId = nativeRequestAnimationFrame(wrapped) as unknown as number;
|
|
rafHandles.set(rafId, true);
|
|
timerStats.raf.active = rafHandles.size;
|
|
return rafId as unknown as ReturnType<typeof requestAnimationFrame>;
|
|
}) as typeof requestAnimationFrame;
|
|
|
|
globalThis.cancelAnimationFrame = ((rafId: number) => {
|
|
if (rafHandles.delete(rafId)) {
|
|
timerStats.raf.cleared += 1;
|
|
timerStats.raf.active = rafHandles.size;
|
|
}
|
|
return nativeCancelAnimationFrame(rafId);
|
|
}) as typeof cancelAnimationFrame;
|
|
|
|
const NativeWebSocket = globalThis.WebSocket;
|
|
if (typeof NativeWebSocket === "function") {
|
|
class InstrumentedWebSocket extends NativeWebSocket {
|
|
constructor(url: string | URL, protocols?: string | string[]) {
|
|
if (protocols === undefined) {
|
|
super(url);
|
|
} else {
|
|
super(url, protocols);
|
|
}
|
|
const urlKey = String(url);
|
|
websocketStats.created += 1;
|
|
websocketStats.active += 1;
|
|
activeWsByUrl.set(urlKey, (activeWsByUrl.get(urlKey) ?? 0) + 1);
|
|
|
|
const handleOpen = () => {
|
|
websocketStats.opened += 1;
|
|
};
|
|
const handleError = () => {
|
|
websocketStats.errored += 1;
|
|
};
|
|
const handleClose = () => {
|
|
websocketStats.closed += 1;
|
|
websocketStats.active = Math.max(0, websocketStats.active - 1);
|
|
const current = activeWsByUrl.get(urlKey) ?? 0;
|
|
if (current <= 1) {
|
|
activeWsByUrl.delete(urlKey);
|
|
} else {
|
|
activeWsByUrl.set(urlKey, current - 1);
|
|
}
|
|
this.removeEventListener("open", handleOpen);
|
|
this.removeEventListener("error", handleError);
|
|
this.removeEventListener("close", handleClose);
|
|
};
|
|
|
|
this.addEventListener("open", handleOpen);
|
|
this.addEventListener("error", handleError);
|
|
this.addEventListener("close", handleClose);
|
|
}
|
|
}
|
|
globalThis.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
|
}
|
|
|
|
const store: ScrollInvestigationStore = {
|
|
markRender(componentId: string) {
|
|
const stats = ensureComponentStats(componentStatsById, componentId);
|
|
stats.renders += 1;
|
|
stats.lastRenderAtMs = performance.now();
|
|
},
|
|
markEvent(componentId: string, event) {
|
|
const stats = ensureComponentStats(componentStatsById, componentId);
|
|
switch (event) {
|
|
case "mount":
|
|
stats.mounts += 1;
|
|
return;
|
|
case "unmount":
|
|
stats.unmounts += 1;
|
|
return;
|
|
case "scrollEvent":
|
|
stats.scrollEvents += 1;
|
|
return;
|
|
case "nearBottomTransition":
|
|
stats.nearBottomTransitions += 1;
|
|
return;
|
|
case "metricUpdate":
|
|
stats.metricUpdates += 1;
|
|
return;
|
|
case "itemRenderCall":
|
|
stats.itemRenderCalls += 1;
|
|
return;
|
|
case "wheelAttach":
|
|
stats.wheelAttach += 1;
|
|
return;
|
|
case "wheelDetach":
|
|
stats.wheelDetach += 1;
|
|
return;
|
|
case "inputChange":
|
|
stats.inputChanges += 1;
|
|
return;
|
|
case "keyPress":
|
|
stats.keyPresses += 1;
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
},
|
|
snapshot() {
|
|
const activeByTypeAndTarget: Record<string, Record<string, number>> = {};
|
|
for (const { type, target } of activeListenerMeta.values()) {
|
|
const existingByType = activeByTypeAndTarget[type] ?? {};
|
|
existingByType[target] = (existingByType[target] ?? 0) + 1;
|
|
activeByTypeAndTarget[type] = existingByType;
|
|
}
|
|
return {
|
|
listeners: {
|
|
byType: Object.fromEntries(listenerStatsByType.entries()),
|
|
byCallsite: Object.fromEntries(listenerCallsiteCount.entries()),
|
|
activeUniqueKeys: activeListenerKeys.size,
|
|
activeByTypeAndTarget,
|
|
},
|
|
timers: {
|
|
timeout: { ...timerStats.timeout, active: timeoutHandles.size },
|
|
interval: { ...timerStats.interval, active: intervalHandles.size },
|
|
raf: { ...timerStats.raf, active: rafHandles.size },
|
|
},
|
|
websockets: {
|
|
totals: { ...websocketStats },
|
|
activeByUrl: Object.fromEntries(activeWsByUrl.entries()),
|
|
},
|
|
components: Object.fromEntries(componentStatsById.entries()),
|
|
};
|
|
},
|
|
printSnapshot(label?: string) {
|
|
console.log(`${SOURCE_LABEL} ${label ?? "snapshot"}`, this.snapshot());
|
|
},
|
|
_installedAtMs: Date.now(),
|
|
};
|
|
|
|
runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ = store;
|
|
console.log(
|
|
`${SOURCE_LABEL} installed`,
|
|
"Use window.__PASEO_SCROLL_JANK_INVESTIGATION__.snapshot()",
|
|
);
|
|
}
|
|
|
|
function getStore(): ScrollInvestigationStore | null {
|
|
const runtime = globalThis as ScrollInvestigationGlobal;
|
|
return runtime.__PASEO_SCROLL_JANK_INVESTIGATION__ ?? null;
|
|
}
|
|
|
|
export function markScrollInvestigationRender(componentId: string): void {
|
|
if (!shouldInstall()) {
|
|
return;
|
|
}
|
|
getStore()?.markRender(componentId);
|
|
}
|
|
|
|
export function markScrollInvestigationEvent(
|
|
componentId: string,
|
|
event:
|
|
| "mount"
|
|
| "unmount"
|
|
| "scrollEvent"
|
|
| "nearBottomTransition"
|
|
| "metricUpdate"
|
|
| "itemRenderCall"
|
|
| "wheelAttach"
|
|
| "wheelDetach"
|
|
| "inputChange"
|
|
| "keyPress",
|
|
): void {
|
|
if (!shouldInstall()) {
|
|
return;
|
|
}
|
|
getStore()?.markEvent(componentId, event);
|
|
}
|