Skip to main content
features

View Transitions

How Astro View Transitions work in Arcturus-Prime — client-side navigation, astro:page-load vs DOMContentLoaded, and cleanup patterns

February 23, 2026

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:

  1. astro:before-swap fires → cleanup code runs
  2. Old page content is swapped out
  3. New page content is inserted
  4. astro:page-load fires → 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

PersistsResets
Layout components (header, footer, sidebar)Page-specific DOM elements
Global state (localStorage, module-scope vars)Page-specific event listeners
CSS variables and themeActive form state
ViewTransitions component itselfScroll position (configurable)

Common Gotchas

  1. DOMContentLoaded doesn’t fire on soft navigations — use astro:page-load
  2. Intervals without cleanup = memory leaks — always pair with astro:before-swap
  3. Layout component listeners accumulate — use module-scope cleanup or { once: true }
  4. WebSocket connections stay open — explicitly disconnect in before-swap
  5. Complex pages need multi-stage cleanup — clean ALL subsystems or the page breaks on return
astroview-transitionsclient-side-routingjavascriptperformance