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">
|
||||
<a
|
||||
:href="`#${item.id}`"
|
||||
:aria-label="item.label"
|
||||
:data-compact-label="compactLabels[item.id]"
|
||||
:aria-current="
|
||||
props.journey.activeSectionId === item.id ? 'page' : undefined
|
||||
"
|
||||
@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>
|
||||
</ul>
|
||||
|
||||
@@ -64,9 +64,9 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
||||
|
||||
const reconcile = async (): Promise<void> => {
|
||||
if (!mounted) return;
|
||||
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
|
||||
const requestedQuality = sources.quality.value;
|
||||
const canvas = sources.canvas.value;
|
||||
if (!allowed) {
|
||||
if (!sources.policy.value.allowSceneRequest || requestedQuality === 'static') {
|
||||
if (sources.policy.value.reason === 'visitor-pause' && controller !== null) {
|
||||
controller.pause();
|
||||
publish();
|
||||
@@ -105,25 +105,19 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
||||
error.value = false;
|
||||
state.value = 'loading';
|
||||
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;
|
||||
next = createSceneController({
|
||||
canvas,
|
||||
quality: sources.quality.value,
|
||||
build: createProceduralSceneGeneration,
|
||||
quality: requestedQuality,
|
||||
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,
|
||||
onFrameRendered: sources.sampleFrame,
|
||||
onStateChange: (nextState) => {
|
||||
@@ -145,16 +139,6 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
||||
const kind = next.snapshot().state.kind;
|
||||
error.value = kind === 'static';
|
||||
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 {
|
||||
if (!abort.signal.aborted && currentGeneration === generation) {
|
||||
error.value = true;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,8 @@ export type SceneLifecycleState =
|
||||
export type PageLifecycleOverlay = 'active' | 'frozen' | 'disposed';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ export async function createProceduralSceneGeneration(
|
||||
let lastNow: number | null = null;
|
||||
let disposed = false;
|
||||
let renderedFrames = 0;
|
||||
let firstFrameSuppressionRecorded = false;
|
||||
|
||||
const resize = (): void => {
|
||||
const nextWidth = Math.max(1, canvas.clientWidth || canvas.width || 1);
|
||||
@@ -145,6 +146,13 @@ export async function createProceduralSceneGeneration(
|
||||
return {
|
||||
render(snapshot: Readonly<JourneySnapshot>, now: number) {
|
||||
if (disposed) return;
|
||||
if (TEST_BUILD && isTestFault('F2-first-frame-timeout')) {
|
||||
if (!firstFrameSuppressionRecorded) {
|
||||
firstFrameSuppressionRecorded = true;
|
||||
recordTestFaultEvent('first-frame-suppressed');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
TEST_BUILD
|
||||
&& isTestFault('F3-post-first-frame-exception')
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.site-navigation__compact-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.portfolio-main {
|
||||
min-width: 0;
|
||||
padding-block: var(--space-4) var(--space-5);
|
||||
@@ -131,8 +135,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-navigation a::after {
|
||||
content: attr(data-compact-label);
|
||||
.site-navigation__compact-label {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.portfolio-section {
|
||||
|
||||
Reference in New Issue
Block a user