DFiP_Budget_planing/perf/e2e/lib/reactProfile.ts
Andrey Epifancev 627ea82321 perf: замеры открытия модалок
Перечень модалок (modals.json), measureOpen с main_thread_ms, профиль
перерисовок React (reactProfile), сценарий modals.spec.ts, подкоманда
stand.sh web-profiling (:8081), генератор modals-inventory.md.

Refs #202

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:53:55 +04:00

111 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Профиль перерисовок React через минимальный __REACT_DEVTOOLS_GLOBAL_HOOK__ (Task 7).
* Нужна профилирующая сборка (react-dom/profiling): только в ней fiber'ы несут actualDuration.
* Init-script REACT_HOOK_SCRIPT ставится ДО загрузки приложения (ctx.addInitScript).
* profileStart(page) / profileStop(page) -> {commits, top: [{component, renders, self_ms}] (топ-20 по self_ms), ...}.
*
* Правило «компонент отрисовался в коммите»: fiber.flags & PerformedWork (1) для function/class/forwardRef/
* simple-memo (теги 0, 1, 11, 15). Обход как у DevTools: в потомков идём, только если fiber новый (нет alternate)
* или fiber.child !== alternate.child (иначе поддерево пропущено bailout'ом, а его флаги устарели).
* self_ms = actualDuration - сумма actualDuration обойдённых детей.
*/
export const REACT_HOOK_SCRIPT = `(() => {
if (window.__REACT_DEVTOOLS_GLOBAL_HOOK__) return;
const COMPONENT_TAGS = { 0: 1, 1: 1, 11: 1, 15: 1 };
const st = {
active: false, renderers: 0, commitsTotal: 0, commits: 0, sawActualDuration: false,
stats: new Map(),
};
const nameOf = (f) => {
const t = f.type;
if (typeof t === 'function') return t.displayName || t.name || '(anonymous)';
if (t && typeof t === 'object') {
const r = t.render;
const n = t.displayName || (r && (r.displayName || r.name));
return n ? (f.tag === 11 ? 'ForwardRef(' + n + ')' : n) : '(anonymous)';
}
return '(anonymous)';
};
const visit = (root) => {
const stack = [root];
while (stack.length) {
const f = stack.pop();
const alt = f.alternate;
const descend = alt === null || alt === undefined || f.child !== alt.child;
if (COMPONENT_TAGS[f.tag] && (f.flags & 1) === 1) {
let kids = 0;
if (descend) for (let c = f.child; c; c = c.sibling) kids += c.actualDuration || 0;
const self = Math.max((f.actualDuration || 0) - kids, 0);
const name = nameOf(f);
const s = st.stats.get(name) || { renders: 0, self_ms: 0 };
s.renders += 1; s.self_ms += self;
st.stats.set(name, s);
}
if (descend) for (let c = f.child; c; c = c.sibling) stack.push(c);
}
};
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
supportsFiber: true,
isDisabled: false,
renderers: new Map(),
inject(r) { st.renderers += 1; this.renderers.set(st.renderers, r); return st.renderers; },
checkDCE() {},
onScheduleFiberRoot() {},
onCommitFiberUnmount() {},
onPostCommitFiberRoot() {},
onCommitFiberRoot(id, root) {
st.commitsTotal += 1;
// профилирующая сборка: у HostRoot есть числовой actualDuration (в prod-сборке поля нет)
if (!st.sawActualDuration) { const cur = root.current; if (cur && typeof cur.actualDuration === 'number' && cur.actualDuration >= 0) st.sawActualDuration = true; }
if (!st.active) return;
st.commits += 1;
try { visit(root.current); } catch (e) { st.error = String(e); }
},
};
window.__reactProfile = st;
})();`;
export interface RenderRow { component: string; renders: number; self_ms: number }
export interface ProfileResult {
commits: number;
total_renders: number;
total_self_ms: number;
top: RenderRow[];
error?: string;
}
export interface HookInfo { renderers: number; commits_total: number; saw_actual_duration: boolean }
export async function profileStart(page: Page_): Promise<void> {
await page.evaluate(() => {
const st = (window as any).__reactProfile;
if (!st) throw new Error('React hook is not installed (REACT_HOOK_SCRIPT)');
st.stats = new Map(); st.commits = 0; st.error = undefined; st.active = true;
});
}
export async function profileStop(page: Page_, topN = 20): Promise<ProfileResult> {
return page.evaluate((n) => {
const st = (window as any).__reactProfile;
st.active = false;
const rows = Array.from(st.stats.entries() as Iterable<[string, { renders: number; self_ms: number }]>)
.map(([component, v]) => ({ component, renders: v.renders, self_ms: v.self_ms }))
.sort((a, b) => b.self_ms - a.self_ms || b.renders - a.renders);
return {
commits: st.commits as number,
total_renders: rows.reduce((a, r) => a + r.renders, 0),
total_self_ms: rows.reduce((a, r) => a + r.self_ms, 0),
top: rows.slice(0, n),
...(st.error ? { error: st.error as string } : {}),
};
}, topN);
}
export async function hookInfo(page: Page_): Promise<HookInfo> {
return page.evaluate(() => {
const st = (window as any).__reactProfile;
return { renderers: st?.renderers ?? 0, commits_total: st?.commitsTotal ?? 0, saw_actual_duration: !!st?.sawActualDuration };
});
}
import type { Page as Page_ } from '@playwright/test';