View Transitions
How Astro View Transitions work in ArgoBox — client-side navigation, astro:page-load vs DOMContentLoaded, and cleanup patterns
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:
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, 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
- 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
is:inlinescripts re-execute on navigation — timer vars get re-declared, losing old references. Registerbefore-swapcleanup inside theastro:page-loadhandler to close over the correct variable scope- Null element access from stale callbacks — always null-check
getElementByIdresults in polling functions is:inline/define:varspage-load handlers persist ondocument— 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