fix: enforce first-frame timeout and nav labels

This commit is contained in:
2026-09-02 08:22:40 +03:00
parent 5edddb8781
commit 1734003172
10 changed files with 217 additions and 40 deletions

View File

@@ -16,6 +16,7 @@ import type {
} from '@/scene/types';
const RESTORE_CUTOFF_MS = 5_000;
const FIRST_FRAME_CUTOFF_MS = 5_000;
const legalTransitions: Readonly<Record<SceneLifecycleState['kind'], readonly SceneLifecycleState['kind'][]>> = {
idle: ['loading', 'paused', 'static', 'disposed'],
@@ -70,6 +71,8 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
let contextLossCount = 0;
let restorationSucceeded = false;
let lostAtForRestore: number | null = null;
let firstFrameTimer: number | null = null;
let firstFrameGeneration: number | null = null;
const transition = (next: SceneLifecycleState): void => {
machine.transition(next);
@@ -78,9 +81,48 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
const stopRaf = (): void => rootResources.cancelAnimationFrame();
const clearFirstFrameWatchdog = (generation?: number): void => {
if (
firstFrameTimer === null
|| (generation !== undefined && firstFrameGeneration !== generation)
) return;
scheduler.clearTimeout(firstFrameTimer);
rootResources.releaseTimer(firstFrameTimer);
firstFrameTimer = null;
firstFrameGeneration = null;
};
const armFirstFrameWatchdog = (generation: number): void => {
clearFirstFrameWatchdog();
firstFrameGeneration = generation;
const timer = scheduler.setTimeout(() => {
rootResources.releaseTimer(timer);
if (firstFrameTimer !== timer || firstFrameGeneration !== generation) return;
firstFrameTimer = null;
firstFrameGeneration = null;
const lifecycle = machine.snapshot();
const generationIsCurrent = (
(lifecycle.kind === 'loading' && lifecycle.generation === generation)
|| (lifecycle.kind === 'running' && lifecycle.generation === generation)
|| (
lifecycle.kind === 'paused'
&& lifecycle.generation === generation
&& !lifecycle.resumeNeedsLoad
)
);
if (generationIsCurrent) enterStatic('first-frame-timeout');
}, FIRST_FRAME_CUTOFF_MS);
firstFrameTimer = rootResources.timer(timer, scheduler.clearTimeout);
};
const confirmFirstFrame = (generation: number): void => {
clearFirstFrameWatchdog(generation);
};
const disposeGeneration = (): void => {
stopRaf();
if (current) {
clearFirstFrameWatchdog(current.generation);
current.resources.dispose();
current = null;
}
@@ -91,6 +133,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
const invalidated = loading;
loading = null;
activeOperation = null;
clearFirstFrameWatchdog(invalidated.generation);
invalidated.abort.abort();
invalidated.resources.dispose();
};
@@ -123,9 +166,13 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
try {
const frameStartedAt = scheduler.now();
current.runtime.render(latestJourney, now);
const active = current;
const complete = active.runtime.render(latestJourney, now) !== false;
const frameEndedAt = scheduler.now();
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
if (complete && current === active) {
confirmFirstFrame(active.generation);
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
}
} catch {
enterStatic('render-failure');
return;
@@ -148,6 +195,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
transition(mode === 'restoring'
? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() }
: { kind: 'loading', generation });
if (mode === 'loading') armFirstFrameWatchdog(generation);
const context: SceneBuildContext = {
canvas: options.canvas,