Skip to main content
Features

View Transitions

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

February 23, 2026

View Transitions

ArgoBox 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, and 13 admin pages including email (120s), jobs (10s), deployments (60s), health (15s), cloudflare (5min), network-map (30s), openclaw (30s), rag (1s during ingest), workbench, ollama, homelab, playground, servers.

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)

Pattern: Defensive null-checks

Even with proper timer cleanup, race conditions or stale astro:page-load callbacks from is:inline scripts can still fire on the wrong page. Add null-checks on all DOM element access:

// FRAGILE — crashes if element doesn't exist on current page
document.getElementById('statInbox').textContent = data.inbox;

// DEFENSIVE — safe even if called from a stale timer
const set = (id, val) => {
  const el = document.getElementById(id);
  if (el) el.textContent = val;
};
set('statInbox', data.inbox);

For functions that update multiple elements, bail early if a key element is missing:

async function loadStatus() {
  const btn = document.getElementById('startStopBtn');
  const sub = document.getElementById('lastUpdated');
  if (!btn || !sub) return; // Not on this page
  // ... safe to proceed
}

Applied to: email.astro (loadStats), jobs.astro (loadStatus, loadPipeline, loadStats, loadQueue, loadFollowUps).

Pattern: Page guards for is:inline / define:vars

CRITICAL for is:inline and define:vars scripts. These scripts re-execute on every View Transitions navigation, adding new astro:page-load handlers to document. These handlers persist across navigations and fire on every subsequent page load — even pages they don't belong to.

Without a page guard, visiting the cloudflare page then navigating to jobs would trigger cloudflare's API calls on the jobs page.

// REQUIRED for is:inline and define:vars scripts
document.addEventListener('astro:page-load', () => {
  // Guard: only run on this specific page
  if (!document.getElementById('statsGrid')) return;

  // ... rest of page initialization
  let autoInterval = null;

  document.addEventListener('astro:before-swap', () => {
    if (autoInterval) { clearInterval(autoInterval); autoInterval = null; }
  }, { once: true });
});

Applied to: email.astro (emailList/emailApp), jobs.astro (startStopBtn), cloudflare.astro (statsGrid), openclaw.astro (oc-status), deployments.astro (list), network-map.astro (grid), rag.astro (publicBadge), servers/index.astro (grid/form), site-test.astro ($results/$badge/12 refs).

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
  6. is:inline scripts re-execute on navigation — timer vars get re-declared, losing old references. Register before-swap cleanup inside the astro:page-load handler to close over the correct variable scope
  7. Null element access from stale callbacks — always null-check getElementById results in polling functions
  8. is:inline/define:vars page-load handlers persist on document — they fire on EVERY subsequent navigation, not just the original page. Always add a page guard (DOM element check + early return) at the top of the handler
astroview-transitionsclient-side-routingjavascriptperformance