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
This commit is contained in:
kmmiio99o 2025-12-22 16:32:23 +01:00
parent b6de8f1bd3
commit bd9df78849
No known key found for this signature in database
GPG key ID: 13A107AB06ACF9FA
4 changed files with 339 additions and 25 deletions

View file

@ -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<any>]> = [
["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.

View file

@ -14,18 +14,49 @@ export interface Asset {
// Cache common usage
const _nameToAssetCache = {} as Record<string, Asset>;
// 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<number>();
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

View file

@ -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<T extends object>(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<T extends object = {}>(
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<T extends object = {}>(
});
const handler = () => backend.set(proxy);
Observable.observe(proxy, handler);
getObservableRuntime().observe(proxy, handler);
cb(proxy);
};

268
src/lib/utils/timings.ts Normal file
View file

@ -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<string, Stat>();
/**
* 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<T>(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<T>(name: string, fn: () => Promise<T>): Promise<T> {
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<T extends (...a: any[]) => 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 = "<unnamed>";
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<T>(name: string, fn: () => Promise<T>): Promise<T> {
const t0 = now();
try {
return await fn();
} finally {
record(name, Math.max(0, now() - t0));
}
}
export function withTimingSync<T>(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;