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:
parent
b6de8f1bd3
commit
bd9df78849
4 changed files with 339 additions and 25 deletions
37
src/index.ts
37
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<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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
268
src/lib/utils/timings.ts
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue