Return

Cesium Viewer Lifecycle Management and Crash Prevention

ARCHITECTURE

Situation

The application crashed during rapid navigation between Cesium utility demo pages with errors like Cannot read properties of undefined (reading 'scene') and Cannot set properties of undefined (setting 'canAnimate').

Investigated and identified the root cause: dangling events from terrain setups and tileset loading, plus animations called by requestAnimationFrame, were trying to access the viewer that was already destroyed.

Task

  • Prevent async operations and animations from accessing destroyed viewers
  • Eliminate the need for scattered viewer state checks across every demo page
  • Maintain native Cesium API usage without complex wrapper patterns

Action

1. First Attempt - Coordinating Viewer Destruction Timing

Initially tried to fix the viewer component by coordinating the timing of viewer destruction with async operations.

Discovered that Cesium's native API requires asynchronous functions for terrain loading and tileset initialization. Making this approach work would require guard checks like if (!viewer || viewer.isDestroyed()) return; scattered across every demo page using the viewer.

This would couple API usage patterns to lifecycle management - unacceptable for maintaining clean, native Cesium usage.

2. Decided to Split Context Scope

Created apps/web/app/cesium-utils/[api]/layout.tsx that wraps each API route with its own ViewerProvider. Removed ViewerProvider from the global cesium-utils layout, keeping only CesiumUtilsProvider for feature selection state. Wrapped the root demo page with its own ViewerProvider.

Each route now gets a fresh viewer instance that lives and dies with that route's lifecycle. Demo pages can continue using the viewer naturally without defensive checks everywhere.

3. Discovered Animation Destruction Race

After per-route isolation, discovered another crash: destroying the viewer during ongoing animations causes requestAnimationFrame callbacks to fire after destruction.

4. Improved Viewer Cleanup Logic

Split viewer component into two separate effects. Initialization effect (empty deps) runs once and handles cleanup by stopping render loop with viewer.useDefaultRenderLoop = false before calling destroy(), wrapped in try-catch to suppress Cesium's internal race condition errors.

Dynamic camera updates (flyTo) moved to separate effect to prevent re-initialization on prop changes.

5. Additional Improvements

Removed key={pathname} from viewer component - unnecessary with per-route providers.

Replaced conditional viewer checks with stable useRef pattern across highlight demos.

Converted event listeners to Promise-based async/await for terrain and tileset loading, with guard checks after async operations.

Result

Eliminated all crashes during rapid navigation through architectural change, not just defensive programming.

Initial approach of coordinating viewer destruction timing failed because it would require scattered guard checks across every demo page, coupling API usage to lifecycle management.

Per-route viewer isolation solved the problem by aligning viewer lifecycle with route lifecycle. Each demo gets a dedicated viewer that's guaranteed to exist during that page's mount. Async operations (terrain loading, tileset initialization) complete naturally without mid-operation viewer destruction.

Separated effects pattern ensures proper cleanup sequence: stop render loop before destroy, try-catch suppresses Cesium's internal requestAnimationFrame race conditions.

Demo pages maintain clean, native Cesium API usage. Guard checks only exist after async operations, not scattered throughout.

Trade-off: Per-route viewers briefly coexist during transitions, acceptable given simpler architecture and reliable cleanup.