fix: enforce first-frame timeout and nav labels
This commit is contained in:
@@ -26,13 +26,12 @@ const compactLabels: Readonly<Record<SectionId, string>> = {
|
|||||||
<li v-for="item in items" :key="item.id">
|
<li v-for="item in items" :key="item.id">
|
||||||
<a
|
<a
|
||||||
:href="`#${item.id}`"
|
:href="`#${item.id}`"
|
||||||
:aria-label="item.label"
|
|
||||||
:data-compact-label="compactLabels[item.id]"
|
|
||||||
:aria-current="
|
:aria-current="
|
||||||
props.journey.activeSectionId === item.id ? 'page' : undefined
|
props.journey.activeSectionId === item.id ? 'page' : undefined
|
||||||
"
|
"
|
||||||
@click="props.onSectionLinkClick"
|
@click="props.onSectionLinkClick"
|
||||||
><span class="site-navigation__full-label" aria-hidden="true">{{ item.label }}</span></a
|
><span class="site-navigation__full-label">{{ item.label }}</span
|
||||||
|
><span class="site-navigation__compact-label">{{ compactLabels[item.id] }}</span></a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
|
|
||||||
const reconcile = async (): Promise<void> => {
|
const reconcile = async (): Promise<void> => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
|
const requestedQuality = sources.quality.value;
|
||||||
const canvas = sources.canvas.value;
|
const canvas = sources.canvas.value;
|
||||||
if (!allowed) {
|
if (!sources.policy.value.allowSceneRequest || requestedQuality === 'static') {
|
||||||
if (sources.policy.value.reason === 'visitor-pause' && controller !== null) {
|
if (sources.policy.value.reason === 'visitor-pause' && controller !== null) {
|
||||||
controller.pause();
|
controller.pause();
|
||||||
publish();
|
publish();
|
||||||
@@ -105,25 +105,19 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
error.value = false;
|
error.value = false;
|
||||||
state.value = 'loading';
|
state.value = 'loading';
|
||||||
try {
|
try {
|
||||||
if (TEST_BUILD && isTestFault('F1-scene-chunk')) {
|
|
||||||
recordTestFaultEvent('scene-chunk-rejected');
|
|
||||||
throw new Error('Injected scene chunk failure');
|
|
||||||
}
|
|
||||||
const { createProceduralSceneGeneration } = await import('@/scene/world/environment');
|
|
||||||
if (
|
|
||||||
abort.signal.aborted
|
|
||||||
|| !mounted
|
|
||||||
|| currentGeneration !== generation
|
|
||||||
|| !sources.policy.value.allowSceneRequest
|
|
||||||
|| sources.quality.value === 'static'
|
|
||||||
|| sources.canvas.value !== canvas
|
|
||||||
) return;
|
|
||||||
|
|
||||||
let next!: SceneController;
|
let next!: SceneController;
|
||||||
next = createSceneController({
|
next = createSceneController({
|
||||||
canvas,
|
canvas,
|
||||||
quality: sources.quality.value,
|
quality: requestedQuality,
|
||||||
build: createProceduralSceneGeneration,
|
build: async (context) => {
|
||||||
|
if (TEST_BUILD && isTestFault('F1-scene-chunk')) {
|
||||||
|
recordTestFaultEvent('scene-chunk-rejected');
|
||||||
|
throw new Error('Injected scene chunk failure');
|
||||||
|
}
|
||||||
|
const { createProceduralSceneGeneration } = await import('@/scene/world/environment');
|
||||||
|
if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||||
|
return createProceduralSceneGeneration(context);
|
||||||
|
},
|
||||||
motionAllowed: () => sources.policy.value.allowSceneRequest,
|
motionAllowed: () => sources.policy.value.allowSceneRequest,
|
||||||
onFrameRendered: sources.sampleFrame,
|
onFrameRendered: sources.sampleFrame,
|
||||||
onStateChange: (nextState) => {
|
onStateChange: (nextState) => {
|
||||||
@@ -145,16 +139,6 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
const kind = next.snapshot().state.kind;
|
const kind = next.snapshot().state.kind;
|
||||||
error.value = kind === 'static';
|
error.value = kind === 'static';
|
||||||
publish();
|
publish();
|
||||||
if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) {
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (controller === next && next.snapshot().state.kind === 'running') {
|
|
||||||
recordTestFaultEvent('first-frame-deadline');
|
|
||||||
next.setStatic('first-frame-timeout');
|
|
||||||
error.value = true;
|
|
||||||
publish();
|
|
||||||
}
|
|
||||||
}, 5_000);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!abort.signal.aborted && currentGeneration === generation) {
|
if (!abort.signal.aborted && currentGeneration === generation) {
|
||||||
error.value = true;
|
error.value = true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
} from '@/scene/types';
|
} from '@/scene/types';
|
||||||
|
|
||||||
const RESTORE_CUTOFF_MS = 5_000;
|
const RESTORE_CUTOFF_MS = 5_000;
|
||||||
|
const FIRST_FRAME_CUTOFF_MS = 5_000;
|
||||||
|
|
||||||
const legalTransitions: Readonly<Record<SceneLifecycleState['kind'], readonly SceneLifecycleState['kind'][]>> = {
|
const legalTransitions: Readonly<Record<SceneLifecycleState['kind'], readonly SceneLifecycleState['kind'][]>> = {
|
||||||
idle: ['loading', 'paused', 'static', 'disposed'],
|
idle: ['loading', 'paused', 'static', 'disposed'],
|
||||||
@@ -70,6 +71,8 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
let contextLossCount = 0;
|
let contextLossCount = 0;
|
||||||
let restorationSucceeded = false;
|
let restorationSucceeded = false;
|
||||||
let lostAtForRestore: number | null = null;
|
let lostAtForRestore: number | null = null;
|
||||||
|
let firstFrameTimer: number | null = null;
|
||||||
|
let firstFrameGeneration: number | null = null;
|
||||||
|
|
||||||
const transition = (next: SceneLifecycleState): void => {
|
const transition = (next: SceneLifecycleState): void => {
|
||||||
machine.transition(next);
|
machine.transition(next);
|
||||||
@@ -78,9 +81,48 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
|
|
||||||
const stopRaf = (): void => rootResources.cancelAnimationFrame();
|
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 => {
|
const disposeGeneration = (): void => {
|
||||||
stopRaf();
|
stopRaf();
|
||||||
if (current) {
|
if (current) {
|
||||||
|
clearFirstFrameWatchdog(current.generation);
|
||||||
current.resources.dispose();
|
current.resources.dispose();
|
||||||
current = null;
|
current = null;
|
||||||
}
|
}
|
||||||
@@ -91,6 +133,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
const invalidated = loading;
|
const invalidated = loading;
|
||||||
loading = null;
|
loading = null;
|
||||||
activeOperation = null;
|
activeOperation = null;
|
||||||
|
clearFirstFrameWatchdog(invalidated.generation);
|
||||||
invalidated.abort.abort();
|
invalidated.abort.abort();
|
||||||
invalidated.resources.dispose();
|
invalidated.resources.dispose();
|
||||||
};
|
};
|
||||||
@@ -123,9 +166,13 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
|
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
|
||||||
try {
|
try {
|
||||||
const frameStartedAt = scheduler.now();
|
const frameStartedAt = scheduler.now();
|
||||||
current.runtime.render(latestJourney, now);
|
const active = current;
|
||||||
|
const complete = active.runtime.render(latestJourney, now) !== false;
|
||||||
const frameEndedAt = scheduler.now();
|
const frameEndedAt = scheduler.now();
|
||||||
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
|
if (complete && current === active) {
|
||||||
|
confirmFirstFrame(active.generation);
|
||||||
|
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
enterStatic('render-failure');
|
enterStatic('render-failure');
|
||||||
return;
|
return;
|
||||||
@@ -148,6 +195,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
transition(mode === 'restoring'
|
transition(mode === 'restoring'
|
||||||
? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() }
|
? { kind: 'restoring', generation, lostAt: restoreLostAt ?? scheduler.now() }
|
||||||
: { kind: 'loading', generation });
|
: { kind: 'loading', generation });
|
||||||
|
if (mode === 'loading') armFirstFrameWatchdog(generation);
|
||||||
|
|
||||||
const context: SceneBuildContext = {
|
const context: SceneBuildContext = {
|
||||||
canvas: options.canvas,
|
canvas: options.canvas,
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export type SceneLifecycleState =
|
|||||||
export type PageLifecycleOverlay = 'active' | 'frozen' | 'disposed';
|
export type PageLifecycleOverlay = 'active' | 'frozen' | 'disposed';
|
||||||
|
|
||||||
export interface SceneGeneration {
|
export interface SceneGeneration {
|
||||||
render(snapshot: Readonly<JourneySnapshot>, now: number): void;
|
/** Return false only when the update/render attempt did not complete. */
|
||||||
|
render(snapshot: Readonly<JourneySnapshot>, now: number): void | false;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
let lastNow: number | null = null;
|
let lastNow: number | null = null;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let renderedFrames = 0;
|
let renderedFrames = 0;
|
||||||
|
let firstFrameSuppressionRecorded = false;
|
||||||
|
|
||||||
const resize = (): void => {
|
const resize = (): void => {
|
||||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||||
@@ -145,6 +146,13 @@ export async function createProceduralSceneGeneration(
|
|||||||
return {
|
return {
|
||||||
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) {
|
||||||
|
if (!firstFrameSuppressionRecorded) {
|
||||||
|
firstFrameSuppressionRecorded = true;
|
||||||
|
recordTestFaultEvent('first-frame-suppressed');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
TEST_BUILD
|
TEST_BUILD
|
||||||
&& isTestFault('F3-post-first-frame-exception')
|
&& isTestFault('F3-post-first-frame-exception')
|
||||||
|
|||||||
@@ -73,6 +73,10 @@
|
|||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-navigation__compact-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.portfolio-main {
|
.portfolio-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding-block: var(--space-4) var(--space-5);
|
padding-block: var(--space-4) var(--space-5);
|
||||||
@@ -131,8 +135,8 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-navigation a::after {
|
.site-navigation__compact-label {
|
||||||
content: attr(data-compact-label);
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portfolio-section {
|
.portfolio-section {
|
||||||
|
|||||||
@@ -186,3 +186,28 @@ test("explicit keyboard navigation focuses a section heading without a fixed-sur
|
|||||||
expect(geometry.insideSafeBounds).toBe(true);
|
expect(geometry.insideSafeBounds).toBe(true);
|
||||||
expect(geometry.overlapArea).toBe(0);
|
expect(geometry.overlapArea).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("responsive navigation exposes the exact visible label as each link's accessible name", async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
await expect(page.getByRole("link", { name: "Experience", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Additional work", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("link", { name: "Career", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("link", { name: "More work", exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
const career = page.getByRole("link", { name: "Career", exact: true });
|
||||||
|
const moreWork = page.getByRole("link", { name: "More work", exact: true });
|
||||||
|
await expect(career.locator(".site-navigation__compact-label")).toHaveText("Career");
|
||||||
|
await expect(moreWork.locator(".site-navigation__compact-label")).toHaveText("More work");
|
||||||
|
await expect(page.getByRole("link", { name: "Experience", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("link", { name: "Additional work", exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await career.focus();
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expect(page.locator("#experience-heading")).toBeFocused();
|
||||||
|
});
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ test.describe('scene failure matrix', () => {
|
|||||||
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-static-fallback', 'true');
|
||||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
||||||
await assertSemanticFaultOracle(page);
|
await assertSemanticFaultOracle(page);
|
||||||
|
const fault = await readFaultSnapshot(page);
|
||||||
|
expect(fault.events).toContain('first-frame-suppressed');
|
||||||
|
expect(fault.events).not.toContain('first-frame-deadline');
|
||||||
|
expect(fault.staticFallbackCount).toBe(1);
|
||||||
|
expect((await readSceneInstrumentation(page)).activeAnimationFrames).toBe(0);
|
||||||
await assertNoEscapedErrors(page, errors.pageErrors);
|
await assertNoEscapedErrors(page, errors.pageErrors);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ describe('semantic portfolio document', () => {
|
|||||||
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
expect(document.body.textContent).toContain('For a current CV or role-specific information, contact me directly.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders separate full and compact navigation labels without overriding their accessible names', async () => {
|
||||||
|
const document = await renderedDocument();
|
||||||
|
const experience = document.querySelector('nav a[href="#experience"]')!;
|
||||||
|
const additional = document.querySelector('nav a[href="#additional-work"]')!;
|
||||||
|
|
||||||
|
expect(experience.hasAttribute('aria-label')).toBe(false);
|
||||||
|
expect(experience.querySelector('.site-navigation__full-label')?.textContent).toBe('Experience');
|
||||||
|
expect(experience.querySelector('.site-navigation__compact-label')?.textContent).toBe('Career');
|
||||||
|
expect(additional.hasAttribute('aria-label')).toBe(false);
|
||||||
|
expect(additional.querySelector('.site-navigation__full-label')?.textContent).toBe('Additional work');
|
||||||
|
expect(additional.querySelector('.site-navigation__compact-label')?.textContent).toBe('More work');
|
||||||
|
});
|
||||||
|
|
||||||
it('binds navigation and section labels to their canonical IDs when content navigation is reordered', async () => {
|
it('binds navigation and section labels to their canonical IDs when content navigation is reordered', async () => {
|
||||||
const content = fixture('portfolio-content.valid.json') as {
|
const content = fixture('portfolio-content.valid.json') as {
|
||||||
navigation: Array<{ id: SectionId; label: string }>;
|
navigation: Array<{ id: SectionId; label: string }>;
|
||||||
@@ -78,7 +91,10 @@ describe('semantic portfolio document', () => {
|
|||||||
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
expect([...document.querySelectorAll('main > section > h2')].map((heading) => heading.textContent)).toEqual([
|
||||||
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT',
|
'START', 'PROFILE', 'EXPERIENCE', 'INFRASTRUCTURE', 'SELECTED BUILDS', 'ADDITIONAL WORK', 'TOOLKIT', 'CONTACT',
|
||||||
]);
|
]);
|
||||||
expect([...document.querySelectorAll('nav a')].map((link) => [link.getAttribute('href'), link.textContent])).toEqual([
|
expect([...document.querySelectorAll('nav a')].map((link) => [
|
||||||
|
link.getAttribute('href'),
|
||||||
|
link.querySelector('.site-navigation__full-label')?.textContent,
|
||||||
|
])).toEqual([
|
||||||
['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Experience'], ['#infrastructure', 'Infrastructure'],
|
['#start', 'Start'], ['#profile', 'Profile'], ['#experience', 'Experience'], ['#infrastructure', 'Infrastructure'],
|
||||||
['#selected-builds', 'Selected builds'], ['#additional-work', 'Additional work'], ['#toolkit', 'Toolkit'], ['#contact', 'Contact'],
|
['#selected-builds', 'Selected builds'], ['#additional-work', 'Additional work'], ['#toolkit', 'Toolkit'], ['#contact', 'Contact'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -106,6 +106,82 @@ describe('scene controller lifecycle', () => {
|
|||||||
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
|
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 () => {
|
it('retains the same complete generation and frame across a visitor pause', async () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
const controller = createSceneController(harness.options);
|
const controller = createSceneController(harness.options);
|
||||||
@@ -440,7 +516,7 @@ function createHarness(options: { deferredBuild?: boolean } = {}) {
|
|||||||
class FakeScheduler implements SceneScheduler {
|
class FakeScheduler implements SceneScheduler {
|
||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
private frames = new Map<number, FrameRequestCallback>();
|
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 }) {}
|
constructor(private readonly clock: { now: number }) {}
|
||||||
|
|
||||||
@@ -451,9 +527,9 @@ class FakeScheduler implements SceneScheduler {
|
|||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
cancelAnimationFrame = (id: number): void => { this.frames.delete(id); };
|
cancelAnimationFrame = (id: number): void => { this.frames.delete(id); };
|
||||||
setTimeout = (callback: () => void): number => {
|
setTimeout = (callback: () => void, delay: number): number => {
|
||||||
const id = this.nextId++;
|
const id = this.nextId++;
|
||||||
this.timers.set(id, callback);
|
this.timers.set(id, { callback, dueAt: this.clock.now + delay });
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
clearTimeout = (id: number): void => { this.timers.delete(id); };
|
clearTimeout = (id: number): void => { this.timers.delete(id); };
|
||||||
@@ -464,6 +540,17 @@ class FakeScheduler implements SceneScheduler {
|
|||||||
this.frames.clear();
|
this.frames.clear();
|
||||||
for (const [, callback] of entries) callback(now);
|
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 {
|
function pageEvent(type: string, persisted: boolean): Event {
|
||||||
|
|||||||
Reference in New Issue
Block a user