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

@@ -106,6 +106,82 @@ describe('scene controller lifecycle', () => {
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
});
it('enters static five seconds after a live-scene request that never completes its first frame', async () => {
const harness = createHarness({ deferredBuild: true });
const controller = createSceneController(harness.options);
const start = controller.start();
harness.scheduler.flushTimers(4_999);
expect(controller.snapshot().state.kind).toBe('loading');
harness.scheduler.flushTimers(5_000);
expect(controller.snapshot()).toMatchObject({
state: { kind: 'static', reason: 'first-frame-timeout' },
currentGeneration: null,
rafCount: 0,
});
expect(harness.buildContexts[0]?.signal.aborted).toBe(true);
harness.resolveBuild(0);
await start;
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
});
it('cancels the first-frame watchdog only after a complete update and render', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
await controller.start();
harness.scheduler.flushFrame(16);
harness.scheduler.flushTimers(5_001);
expect(controller.snapshot()).toMatchObject({
state: { kind: 'running', generation: 1 },
currentGeneration: 1,
rafCount: 1,
});
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
});
it('keeps the watchdog armed when a frame explicitly reports incomplete', async () => {
const harness = createHarness();
const onFrameRendered = vi.fn();
const controller = createSceneController({ ...harness.options, onFrameRendered });
await controller.start();
harness.generations[0]?.render.mockReturnValue(false);
harness.scheduler.flushFrame(16);
harness.scheduler.flushTimers(5_000);
expect(controller.snapshot()).toMatchObject({
state: { kind: 'static', reason: 'first-frame-timeout' },
currentGeneration: null,
rafCount: 0,
});
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
expect(onFrameRendered).not.toHaveBeenCalled();
});
it('replaces a torn-down generation watchdog with one deadline for its restart', async () => {
const harness = createHarness({ deferredBuild: true });
const controller = createSceneController(harness.options);
const first = controller.start();
harness.clock.now = 1_000;
controller.pause();
const second = controller.resume();
harness.scheduler.flushTimers(5_000);
expect(controller.snapshot()).toMatchObject({ state: { kind: 'loading', generation: 2 } });
harness.scheduler.flushTimers(6_000);
expect(controller.snapshot().state).toEqual({ kind: 'static', reason: 'first-frame-timeout' });
harness.resolveBuild(0);
harness.resolveBuild(1);
await Promise.all([first, second]);
expect(harness.generations[0]?.dispose).toHaveBeenCalledOnce();
expect(harness.generations[1]?.dispose).toHaveBeenCalledOnce();
});
it('retains the same complete generation and frame across a visitor pause', async () => {
const harness = createHarness();
const controller = createSceneController(harness.options);
@@ -440,7 +516,7 @@ function createHarness(options: { deferredBuild?: boolean } = {}) {
class FakeScheduler implements SceneScheduler {
private nextId = 1;
private frames = new Map<number, FrameRequestCallback>();
private timers = new Map<number, () => void>();
private timers = new Map<number, { callback: () => void; dueAt: number }>();
constructor(private readonly clock: { now: number }) {}
@@ -451,9 +527,9 @@ class FakeScheduler implements SceneScheduler {
return id;
};
cancelAnimationFrame = (id: number): void => { this.frames.delete(id); };
setTimeout = (callback: () => void): number => {
setTimeout = (callback: () => void, delay: number): number => {
const id = this.nextId++;
this.timers.set(id, callback);
this.timers.set(id, { callback, dueAt: this.clock.now + delay });
return id;
};
clearTimeout = (id: number): void => { this.timers.delete(id); };
@@ -464,6 +540,17 @@ class FakeScheduler implements SceneScheduler {
this.frames.clear();
for (const [, callback] of entries) callback(now);
}
flushTimers(now: number): void {
this.clock.now = now;
const due = [...this.timers.entries()]
.filter(([, timer]) => timer.dueAt <= now)
.sort((left, right) => left[1].dueAt - right[1].dueAt);
for (const [id, timer] of due) {
if (!this.timers.delete(id)) continue;
timer.callback();
}
}
}
function pageEvent(type: string, persisted: boolean): Event {