View Transitions
How Astro View Transitions work in Arcturus-Prime — client-side navigation, astro:page-load vs DOMContentLoaded, and cleanup patterns
View Transitions
Arcturus-Prime uses Astro’s <ViewTransitions /> component in both layouts (BaseLayout.astro and CosmicLayout.astro), enabling client-side routing across the entire site. This means page navigations happen without full reloads — the browser swaps content in-place.
Why This Matters
With View Transitions enabled, clicking a link doesn’t trigger a full page load. Instead:
astro:before-swapfires → cleanup code runs- Old page content is swapped out
- New page content is inserted
astro:page-loadfires → initialization code runs
The critical consequence: DOMContentLoaded only fires on the initial full page load. It does NOT fire on client-side navigations. Every script that initializes UI must use astro:page-load instead.
The Fix: astro:page-load
// WRONG — breaks on client-side navigation
document.addEventListener('DOMContentLoaded', () => { init(); });
// ALSO WRONG — readyState pattern doesn't help
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// CORRECT — fires on initial load AND View Transitions
document.addEventListener('astro:page-load', () => { init(); });
This pattern is used in 40+ files across the codebase: layouts, components (Header, Footer, UserMenu, KnowledgeGraph, MiniGraph, Terminal), status components (ContributionGrid, UptimeHero, ServiceDashboard), lab components (LabLauncher, ChallengeTracker, SessionBar), and admin pages (workbench, command center, pentest tools).
Cleanup with astro:before-swap
When navigating away from a page, resources must be cleaned up or they leak across navigations. The astro:before-swap event fires right before content swap — this is where cleanup goes.
Pattern: Interval cleanup
The most common pattern. Always pair setInterval with a cleanup listener:
document.addEventListener('astro:page-load', () => {
const intervalId = setInterval(() => {
// polling code
}, 30000);
document.addEventListener('astro:before-swap', () => {
clearInterval(intervalId);
}, { once: true });
});
Used by: ContributionGrid (5-min refresh), UptimeHero (30s polling), ServiceDashboard (30s polling), WeekView, TimelineView.
Pattern: Multi-stage cleanup
Complex pages need to clean up multiple subsystems:
document.addEventListener('astro:before-swap', function() {
disposeAllTerminals();
if (forgePollingTimer) clearInterval(forgePollingTimer);
if (alertsPollingTimer) clearInterval(alertsPollingTimer);
if (abortController) abortController.abort();
});
Used by: admin/workbench.astro — terminals, Forge polling, alerts polling, and active fetch requests all need disposal.
Pattern: Custom event listener cleanup
Components listening to custom events from other components:
document.addEventListener('astro:page-load', () => {
const handler = ((e: CustomEvent) => { /* ... */ }) as EventListener;
window.addEventListener('uptimeData', handler);
document.addEventListener('astro:before-swap', () => {
window.removeEventListener('uptimeData', handler);
}, { once: true });
});
Used by: UptimeHero.astro listening to data from sibling components.
Pattern: WebSocket/connection cleanup
Network connections must be closed gracefully:
document.addEventListener('astro:page-load', () => {
const conn = new VNCConnection(el);
document.addEventListener('astro:before-swap', () => {
conn.disconnect();
}, { once: true });
});
Used by: VNCEmbed.astro — VNC viewer opens WebSocket connections that must close on navigation.
Pattern: Module-scope state
For components in layouts (Header, Sidebar) that persist across navigations, use module-scope variables with explicit cleanup:
let _timer: ReturnType<typeof setInterval> | null = null;
function cleanup() {
if (_timer) { clearInterval(_timer); _timer = null; }
}
document.addEventListener('astro:before-swap', cleanup);
document.addEventListener('astro:page-load', () => {
cleanup(); // Clear previous cycle first
_timer = setInterval(() => { /* ... */ }, 25000);
});
Used by: Header.astro swarm prewarming — runs on admin/command pages, must avoid double-initialization across navigations.
The { once: true } Idiom
Page-scoped cleanup listeners should use { once: true } to prevent the listeners themselves from accumulating:
document.addEventListener('astro:before-swap', () => {
clearInterval(intervalId);
}, { once: true });
Without { once: true }, each navigation would add another cleanup listener that never gets removed.
Conditional Initialization
Components included in layouts but only relevant to specific pages should guard their initialization:
document.addEventListener('astro:page-load', () => {
const element = document.getElementById('status-beacon');
if (!element) return; // Not on this page
// Safe to initialize
});
This prevents errors when a layout script runs on a page that doesn’t have the target DOM elements.
What Persists Across Navigations
| Persists | Resets |
|---|---|
| Layout components (header, footer, sidebar) | Page-specific DOM elements |
| Global state (localStorage, module-scope vars) | Page-specific event listeners |
| CSS variables and theme | Active form state |
| ViewTransitions component itself | Scroll position (configurable) |
Common Gotchas
- DOMContentLoaded doesn’t fire on soft navigations — use
astro:page-load - Intervals without cleanup = memory leaks — always pair with
astro:before-swap - Layout component listeners accumulate — use module-scope cleanup or
{ once: true } - WebSocket connections stay open — explicitly disconnect in
before-swap - Complex pages need multi-stage cleanup — clean ALL subsystems or the page breaks on return