diff --git a/packages/desktop/src/renderer/diagnostics.ts b/packages/desktop/src/renderer/diagnostics.ts new file mode 100644 index 0000000000..905d188f25 --- /dev/null +++ b/packages/desktop/src/renderer/diagnostics.ts @@ -0,0 +1,112 @@ +const resizeLoopWarning = "ResizeObserver loop completed with undelivered notifications." + +if (import.meta.env.DEV) { + installConsoleStacks() + installResizeObserverStacks() +} + +function installConsoleStacks() { + console.warn = tracedConsole(console.warn.bind(console), "Console warning") + console.error = tracedConsole(console.error.bind(console), "Console error") +} + +function installResizeObserverStacks() { + if (typeof ResizeObserver !== "function") return + + const NativeResizeObserver = ResizeObserver + type ResizeTrace = { + created: string + callbackAt?: number + targets: Set + } + const observers = new Set() + + class TracedResizeObserver extends NativeResizeObserver { + private readonly trace: ResizeTrace + + constructor(callback: ResizeObserverCallback) { + const trace: ResizeTrace = { + created: diagnosticStack("ResizeObserver created"), + targets: new Set(), + } + super((entries, observer) => { + trace.callbackAt = performance.now() + callback(entries, observer) + }) + this.trace = trace + observers.add(trace) + } + + override observe(target: Element, options?: ResizeObserverOptions) { + this.trace.targets.add(target) + super.observe(target, options) + } + + override unobserve(target: Element) { + this.trace.targets.delete(target) + super.unobserve(target) + } + + override disconnect() { + this.trace.targets.clear() + observers.delete(this.trace) + super.disconnect() + } + } + + globalThis.ResizeObserver = TracedResizeObserver + window.addEventListener("error", (event) => { + if (event.message !== resizeLoopWarning) return + const now = performance.now() + const active = [...observers] + .flatMap((observer) => { + if (observer.callbackAt === undefined || now - observer.callbackAt >= 100) return [] + return [{ ...observer, callbackAt: observer.callbackAt }] + }) + .sort((a, b) => b.callbackAt - a.callbackAt) + .slice(0, 5) + const detail = active.length + ? active + .map( + (observer, index) => + `Recent ResizeObserver ${index + 1}; targets: ${[...observer.targets].map(describeElement).join(", ") || "none"}\n${observer.created}`, + ) + .join("\n") + : "No ResizeObserver callback was recorded in the previous 100 ms." + console.warn(`[renderer diagnostics] ${resizeLoopWarning}\n${detail}`) + }) +} + +function tracedConsole(write: (...args: unknown[]) => void, label: string) { + const pending = new Map() + return (...args: unknown[]) => { + const stack = diagnosticStack(label) + const key = `${String(args[0])}\n${stack}` + const current = pending.get(key) + if (current) { + current.count += 1 + return + } + + const entry = { count: 0 } + pending.set(key, entry) + write(...args, stack) + window.setTimeout(() => { + pending.delete(key) + if (entry.count) write(`${String(args[0])} (repeated ${entry.count} times)`) + }, 1_000) + } +} + +function describeElement(element: Element) { + const id = element.id ? `#${element.id}` : "" + const classes = [...element.classList] + .slice(0, 3) + .map((name) => `.${name}`) + .join("") + return `${element.tagName.toLowerCase()}${id}${classes}` +} + +function diagnosticStack(label: string) { + return new Error(label).stack ?? label +} diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index c4abbd9a55..3a79dfe9a7 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -1,5 +1,6 @@ // @refresh reload +import "./diagnostics" import { ACCEPTED_FILE_EXTENSIONS, AppBaseProviders,