From bd9df788499583676a9764b1dad9467eb4bbccea Mon Sep 17 00:00:00 2001 From: kmmiio99o <164557248+kmmiio99o@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:32:23 +0100 Subject: [PATCH] perf: add startup timings & asset/storage optimizations Add `src/lib/utils/timings.ts` and instrument critical inits in `src/index.ts` Cache asset iteration (`src/lib/api/assets/index.ts`) with short TTL + invalidation Lazy-require object-observer in `src/lib/api/storage/index.ts` to defer startup cost --- src/index.ts | 37 ++--- src/lib/api/assets/index.ts | 33 ++++- src/lib/api/storage/index.ts | 26 ++-- src/lib/utils/timings.ts | 268 +++++++++++++++++++++++++++++++++++ 4 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 src/lib/utils/timings.ts diff --git a/src/index.ts b/src/index.ts index 94ac935..5bd2fad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,30 +19,35 @@ import { getDebugInfo, initDebugger } from "@lib/api/debug"; // imported when needed (to avoid bundling it permanently) and removed after use. import * as lib from "./lib"; +import { timings } from "@lib/utils/timings"; /** * Start sequence split into critical (UI) and deferred (network/plugin) work. * The goal is to get the UI ready quickly and run heavy tasks after interactions. */ export default async () => { - // Critical initializers that are required for the app's UI and basic features. - const criticalInits = [ - initThemes(), - injectFluxInterceptor(), - patchSettings(), - patchLogHook(), - patchCommands(), - patchJsx(), - patchErrorBoundary(), - initVendettaObject(), - initFetchI18nStrings(), - initSettings(), - initFixes(), - initDebugger(), + // Wrap critical initializers as named functions so we can instrument each. + const criticalInitFns: Array<[string, () => Promise]> = [ + ["initThemes", () => initThemes()], + ["injectFluxInterceptor", () => injectFluxInterceptor()], + ["patchSettings", () => patchSettings()], + ["patchLogHook", () => patchLogHook()], + ["patchCommands", () => patchCommands()], + ["patchJsx", () => patchJsx()], + ["patchErrorBoundary", () => patchErrorBoundary()], + ["initVendettaObject", () => initVendettaObject()], + ["initFetchI18nStrings", () => initFetchI18nStrings()], + ["initSettings", () => initSettings()], + ["initFixes", () => initFixes()], + ["initDebugger", () => initDebugger()], ]; - // Run critical inits and collect unpatchers/cleanup handlers. - await Promise.all(criticalInits) + // Run critical inits with timing instrumentation and collect unpatchers/cleanup handlers. + await Promise.all( + criticalInitFns.map(([name, fn]) => + timings.measureAsync(`critical:${name}`, async () => fn()), + ), + ) .then((u) => u.forEach((f) => f && lib.unload.push(f))) .catch((e) => { // Log but don't abort — critical inits failing should be visible in logs. diff --git a/src/lib/api/assets/index.ts b/src/lib/api/assets/index.ts index a82ca6e..f127778 100644 --- a/src/lib/api/assets/index.ts +++ b/src/lib/api/assets/index.ts @@ -14,18 +14,49 @@ export interface Asset { // Cache common usage const _nameToAssetCache = {} as Record; +// Simple cached asset list with TTL to avoid iterating the flagsIndex too often. +// This is a small in-memory cache to improve repeated asset lookups during UI rendering. +let _cachedAssetList: Asset[] | null = null; +let _cachedAssetListTs = 0; +const ASSET_CACHE_TTL = 2500; // milliseconds + +/** + * Invalidate the internal asset caches so the next iterateAssets() call will + * re-run through the metro flags index and refresh the cache. + */ +export function invalidateAssetCache() { + _cachedAssetList = null; + _cachedAssetListTs = 0; + for (const k in _nameToAssetCache) delete _nameToAssetCache[k]; +} + export function* iterateAssets() { + // Return cached list when fresh + const now = Date.now(); + if (_cachedAssetList && now - _cachedAssetListTs < ASSET_CACHE_TTL) { + for (const asset of _cachedAssetList) yield asset; + return; + } + const { flagsIndex } = getMetroCache(); const yielded = new Set(); + const list: Asset[] = []; for (const id in flagsIndex) { if (flagsIndex[id] & ModuleFlags.ASSET) { const assetId = requireModule(Number(id)); if (typeof assetId !== "number" || yielded.has(assetId)) continue; - yield getAssetById(assetId); + const asset = getAssetById(assetId); + if (!asset) continue; + list.push(asset); yielded.add(assetId); + yield asset; } } + + // Update cache + _cachedAssetList = list; + _cachedAssetListTs = now; } // Apply additional properties for convenience diff --git a/src/lib/api/storage/index.ts b/src/lib/api/storage/index.ts index 5746429..9490e08 100644 --- a/src/lib/api/storage/index.ts +++ b/src/lib/api/storage/index.ts @@ -1,8 +1,18 @@ import { Emitter } from "@core/vendetta/Emitter"; -import { Observable, ObserverOptions } from "@gullerya/object-observer"; +import type { Observable as ObservableType, ObserverOptions } from "@gullerya/object-observer"; import { fileExists, readFile, removeFile, writeFile } from "@lib/api/native/fs"; import { debounce } from "es-toolkit"; +let _ObservableRuntime: any = null; +function getObservableRuntime() { + if (_ObservableRuntime) return _ObservableRuntime; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require("@gullerya/object-observer"); + // the module may export named members or the Observable directly + _ObservableRuntime = mod.Observable ?? mod; + return _ObservableRuntime; +} + const storageInitErrorSymbol = Symbol.for("bunny.storage.initError"); const storagePromiseSymbol = Symbol.for("bunny.storage.promise"); @@ -34,12 +44,12 @@ function createFileBackend(filePath: string) { }; } -export function useObservable(observables: Observable[], opts?: ObserverOptions) { +export function useObservable(observables: ObservableType[], opts?: ObserverOptions) { if (observables.some((o: any) => o?.[storageInitErrorSymbol])) throw new Error( "An error occured while initializing the storage", ); - if (observables.some(o => !Observable.isObservable(o))) { + if (observables.some(o => !getObservableRuntime().isObservable(o))) { throw new Error("Argument passed isn't an Observable"); } @@ -48,10 +58,10 @@ export function useObservable(observables: Observable[], opts?: ObserverOptions) React.useEffect(() => { const listener = () => forceUpdate(); - observables.forEach(o => Observable.observe(o, listener, opts)); + observables.forEach(o => getObservableRuntime().observe(o, listener, opts)); return () => { - observables.forEach(o => Observable.unobserve(o, listener)); + observables.forEach(o => getObservableRuntime().unobserve(o, listener)); }; }, []); } @@ -72,13 +82,13 @@ export function createStorageAndCallback( let emitter: Emitter; const callback = (data: any) => { - const proxy = new Proxy(Observable.from(data), { + const proxy = new Proxy(getObservableRuntime().from(data), { get(target, prop, receiver) { if (prop === Symbol.for("vendetta.storage.emitter")) { if (emitter) return emitter; emitter = new Emitter(); - Observable.observe(target, changes => { + getObservableRuntime().observe(target, changes => { for (const change of changes) { emitter.emit(change.type !== "delete" ? "SET" : "DEL", { path: change.path, @@ -95,7 +105,7 @@ export function createStorageAndCallback( }); const handler = () => backend.set(proxy); - Observable.observe(proxy, handler); + getObservableRuntime().observe(proxy, handler); cb(proxy); }; diff --git a/src/lib/utils/timings.ts b/src/lib/utils/timings.ts new file mode 100644 index 0000000..43b1c01 --- /dev/null +++ b/src/lib/utils/timings.ts @@ -0,0 +1,268 @@ +/** + * Lightweight timing utility for boot / init instrumentation. + * + * Usage examples: + * import { timings } from "@lib/utils/timings"; + * + * // measure synchronous function + * const res = timings.measureSync("initThemes", () => initThemes()); + * + * // measure async function + * await timings.measureAsync("loadPlugins", async () => { await loadPlugins(); }); + * + * // manual start/stop + * const stop = timings.start("heavyTask"); + * doHeavyWork(); + * stop(); + * + * // inspect / log results + * timings.logReport(); // prints a concise table to console + * + * Goals: + * - Minimal, zero-dep helper to identify slow initializers. + * - Safe for environments without `performance` (falls back to Date.now). + * - Small memory footprint and simple API. + */ + +type Stat = { + name: string; + calls: number; + totalMs: number; + lastMs: number; + minMs: number; + maxMs: number; +}; + +type ReportRow = { + name: string; + calls: number; + totalMs: number; + avgMs: number; + lastMs: number; + minMs: number; + maxMs: number; +}; + +const now = (() => { + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return () => performance.now(); + } + return () => Date.now(); +})(); + +/** + * Internal stats map: key -> Stat + */ +const _stats = new Map(); + +/** + * Start a manual timer for `name`. Returns a stop() function that records the + * elapsed time into the statistics and returns the duration. + * + * Example: + * const stop = start('db.init'); + * // ... work ... + * const ms = stop(); + */ +export function start(name: string) { + const t0 = now(); + let stopped = false; + + return function stop() { + if (stopped) return 0; + stopped = true; + const duration = Math.max(0, now() - t0); + record(name, duration); + return duration; + }; +} + +/** + * Measure a synchronous function and record its duration. + * Returns the function result. + */ +export function measureSync(name: string, fn: () => T): T { + const t0 = now(); + try { + return fn(); + } finally { + const duration = Math.max(0, now() - t0); + record(name, duration); + } +} + +/** + * Measure an async function (promise-returning) and record its duration. + * Returns the awaited result. + */ +export async function measureAsync(name: string, fn: () => Promise): Promise { + const t0 = now(); + try { + return await fn(); + } finally { + const duration = Math.max(0, now() - t0); + record(name, duration); + } +} + +/** + * Wrap a function (sync or async) preserving its return value and recording the timing. + * If fn returns a Promise it will await it and record the time. + */ +export function wrap any>(name: string, fn: T): T { + const wrapped = function (this: any, ...args: any[]) { + const t0 = now(); + try { + const res = (fn as any).apply(this, args); + if (res && typeof res.then === "function") { + // async + return res.finally(() => { + const duration = Math.max(0, now() - t0); + record(name, duration); + }); + } + // sync + return res; + } finally { + // for synchronous returns, ensure timing recorded in finally above won't double-record. + if (!(fn as any).constructor.name === "AsyncFunction") { + const duration = Math.max(0, now() - t0); + record(name, duration); + } + } + }; + + return (wrapped as unknown) as T; +} + +/** + * Record a measured duration (milliseconds) under `name`. + */ +export function record(name: string, durationMs: number) { + if (!name) name = ""; + const s = _stats.get(name); + if (!s) { + _stats.set(name, { + name, + calls: 1, + totalMs: durationMs, + lastMs: durationMs, + minMs: durationMs, + maxMs: durationMs, + }); + return; + } + + s.calls += 1; + s.lastMs = durationMs; + s.totalMs += durationMs; + if (durationMs < s.minMs) s.minMs = durationMs; + if (durationMs > s.maxMs) s.maxMs = durationMs; +} + +/** + * Produce a sorted report array (desc by total time) limited to `limit` rows. + */ +export function report(limit = 25): ReportRow[] { + const rows: ReportRow[] = []; + for (const [, s] of _stats) { + rows.push({ + name: s.name, + calls: s.calls, + totalMs: s.totalMs, + avgMs: s.calls > 0 ? s.totalMs / s.calls : 0, + lastMs: s.lastMs, + minMs: s.minMs, + maxMs: s.maxMs, + }); + } + + rows.sort((a, b) => b.totalMs - a.totalMs); + return rows.slice(0, limit); +} + +/** + * Console-log a human-readable report. + */ +export function logReport(limit = 25) { + const rows = report(limit); + if (rows.length === 0) { + console.info("[timings] no recorded timings"); + return; + } + + // Build a compact table-like output + console.groupCollapsed(`[timings] top ${rows.length} (by total ms)`); + try { + console.table( + rows.map((r) => ({ + name: r.name, + calls: r.calls, + totalMs: Number(r.totalMs.toFixed(2)), + avgMs: Number(r.avgMs.toFixed(2)), + lastMs: Number(r.lastMs.toFixed(2)), + minMs: Number(r.minMs.toFixed(2)), + maxMs: Number(r.maxMs.toFixed(2)), + })), + ); + } catch { + // Some environments don't support console.table + for (const r of rows) { + console.log( + `${r.name} — total ${r.totalMs.toFixed(2)}ms • avg ${r.avgMs.toFixed(2)}ms • last ${r.lastMs.toFixed( + 2, + )}ms • calls ${r.calls}`, + ); + } + } finally { + console.groupEnd(); + } +} + +/** + * Clear collected timing stats. + */ +export function clear() { + _stats.clear(); +} + +/** + * Convenience: record but return value for fluent usage. + * Example: + * const data = await timings.withTimingAsync('fetch', () => fetchJSON()); + */ +export async function withTimingAsync(name: string, fn: () => Promise): Promise { + const t0 = now(); + try { + return await fn(); + } finally { + record(name, Math.max(0, now() - t0)); + } +} + +export function withTimingSync(name: string, fn: () => T): T { + const t0 = now(); + try { + return fn(); + } finally { + record(name, Math.max(0, now() - t0)); + } +} + +/** + * Expose a small default export for convenience. + */ +export const timings = { + start, + measureSync, + measureAsync, + wrap, + record, + report, + logReport, + clear, + withTimingAsync, + withTimingSync, +}; + +export default timings; \ No newline at end of file