diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts index f4fc2849a..c68509866 100644 --- a/src/Metrics/ScenarioMonitor.ts +++ b/src/Metrics/ScenarioMonitor.ts @@ -105,6 +105,23 @@ class ScenarioMonitor { }); ctx.timeoutId = window.setTimeout(() => { + const missingPhases = ctx.config.requiredPhases.filter((p) => !ctx.completed.has(p)); + + this.devLog( + `timeout: ${scenario} | missing=[${missingPhases.join(", ")}] | completed=[${Array.from(ctx.completed).join( + ", ", + )}] | documentHidden=${document.hidden} | hasExpectedFailure=${ctx.hasExpectedFailure}`, + ); + + traceMark(Action.MetricsScenario, { + event: "scenario_timeout", + scenario, + missingPhases: missingPhases.join(","), + completedPhases: Array.from(ctx.completed).join(","), + documentHidden: document.hidden, + hasExpectedFailure: ctx.hasExpectedFailure, + }); + // If an expected failure occurred (auth, firewall, etc.), emit healthy instead of unhealthy const healthy = ctx.hasExpectedFailure; this.emit(ctx, healthy, true); @@ -288,6 +305,7 @@ class ScenarioMonitor { scenario: ctx.scenario, healthy, timedOut, + documentHidden: document.hidden, platform, api, durationMs: finalSnapshot.durationMs, diff --git a/src/Metrics/useMetricPhases.ts b/src/Metrics/useMetricPhases.ts index 31c07a98d..41f96fcfa 100644 --- a/src/Metrics/useMetricPhases.ts +++ b/src/Metrics/useMetricPhases.ts @@ -1,24 +1,26 @@ import React from "react"; import MetricScenario from "./MetricEvents"; -import { useMetricScenario } from "./MetricScenarioProvider"; +import { scenarioMonitor } from "./ScenarioMonitor"; import { ApplicationMetricPhase, CommonMetricPhase } from "./ScenarioConfig"; /** * Hook to automatically complete the Interactive phase when the component becomes interactive. * Uses requestAnimationFrame to complete after the browser has painted. + * + * Calls scenarioMonitor directly (not via React context) so that the effect dependencies + * are only [scenario, enabled] — both stable primitives. This prevents re-renders from + * cancelling the pending rAF due to an unstable context function reference. */ export function useInteractive(scenario: MetricScenario, enabled = true) { - const { completePhase } = useMetricScenario(); - React.useEffect(() => { if (!enabled) { return undefined; } const id = requestAnimationFrame(() => { - completePhase(scenario, CommonMetricPhase.Interactive); + scenarioMonitor.completePhase(scenario, CommonMetricPhase.Interactive); }); return () => cancelAnimationFrame(id); - }, [scenario, completePhase, enabled]); + }, [scenario, enabled]); } /** @@ -26,18 +28,20 @@ export function useInteractive(scenario: MetricScenario, enabled = true) { * Tracks tree rendering and completes Interactive phase. * Only completes DatabaseTreeRendered if the database fetch was successful. * Note: Scenario must be started before databases are fetched (in refreshExplorer). + * + * Calls scenarioMonitor directly (not via React context) for the same stability reason + * as useInteractive — avoids effect re-runs from unstable context function references. */ export function useDatabaseLoadScenario(databaseTreeNodes: unknown[], fetchSucceeded: boolean) { - const { completePhase } = useMetricScenario(); const hasCompletedTreeRenderRef = React.useRef(false); // Track DatabaseTreeRendered phase (only if fetch succeeded) React.useEffect(() => { if (!hasCompletedTreeRenderRef.current && fetchSucceeded) { hasCompletedTreeRenderRef.current = true; - completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered); + scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered); } - }, [databaseTreeNodes, fetchSucceeded, completePhase]); + }, [databaseTreeNodes, fetchSucceeded]); // Track Interactive phase useInteractive(MetricScenario.DatabaseLoad);