Перечень модалок (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>
111 lines
4.9 KiB
TypeScript
111 lines
4.9 KiB
TypeScript
/**
|
||
* Профиль перерисовок 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';
|