Quartz SPA Navigation Architecture

This document describes the Single Page Application (SPA) navigation system used in this Quartz-based knowledge vault and the patterns for building components that work reliably with it.

Overview

Quartz uses a custom SPA router built on micromorph for DOM morphing. When users click internal links, instead of full page reloads, the router:

  1. Fetches the new page content
  2. Fires a prenav event for cleanup
  3. Runs registered cleanup functions
  4. Morphs the DOM to the new state
  5. Fires a nav event for (re)initialization

Key Events

Fired after navigation completes. Components should register listeners for this event to initialize or reinitialize themselves.

document.addEventListener('nav', async (e: CustomEventMap['nav']) => {
  const currentSlug = e.detail.url;
  await initializeComponent(currentSlug);
});

prenav Event

Fired just before navigation occurs. Use this for saving state that should persist across navigations (e.g., scroll position).

document.addEventListener('prenav', () => {
  saveScrollPosition();
});

Cleanup System

Registering Cleanup Functions

Use window.addCleanup() to register functions that should run before the next navigation:

const handler = () => doSomething();
element.addEventListener('click', handler);
window.addCleanup(() => element.removeEventListener('click', handler));

Cleanup Function Requirements

  1. Must not throw - Cleanup functions are isolated in try-catch, but throwing disrupts the expected cleanup flow
  2. Same function reference - When removing event listeners, you must use the same function reference that was added
// WRONG - creates new function, won't remove listener
icon.addEventListener('click', () => renderGraph());
window.addCleanup(() => icon.removeEventListener('click', () => renderGraph()));
 
// CORRECT - uses same function reference
const handleClick = () => renderGraph();
icon.addEventListener('click', handleClick);
window.addCleanup(() => icon.removeEventListener('click', handleClick));

Component Initialization Pattern

document.addEventListener('nav', async (e: CustomEventMap['nav']) => {
  try {
    await initializeComponent(e.detail.url);
  } catch (error) {
    console.error('[ComponentName] Initialization failed:', error);
  }
});

Why This Works

The SPA router defers the initial nav event until either:

  • The page load event fires (if document isn’t ready)
  • A microtask completes (if document is already loaded)

This ensures component scripts have time to register their nav event listeners before the initial event fires.

Common Pitfalls

1. Timing-Based Initialization

Don’t do this:

// Anti-pattern: multiple fallback timers
requestAnimationFrame(() => init());
setTimeout(() => init(), 50);
setTimeout(() => init(), 200);
window.addEventListener('load', () => init());

This pattern is unreliable and indicates a race condition problem. If you need this, the root cause should be fixed instead.

2. Self-Cleanup Arrays

Don’t do this:

// Anti-pattern: manual cleanup tracking
let cleanups: (() => void)[] = [];
cleanups.push(setupFeature());
// later...
cleanups.forEach((fn) => fn());
cleanups = [];

Do this instead:

// Use the built-in cleanup system
const cleanup = setupFeature();
window.addCleanup(cleanup);

3. Missing Error Handling

Always wrap async initialization in try-catch to prevent unhandled rejections:

document.addEventListener('nav', async (e) => {
  try {
    await heavyInitialization(e.detail.url);
  } catch (error) {
    console.error('[Component] Init failed:', error);
  }
});

Technical Details

Script Execution Order

  1. spa.inline.ts - Creates router and defers initial nav event
  2. Component scripts - Register their nav listeners
  3. Initial nav event fires - All components initialize

DOM Morphing

Micromorph intelligently patches the DOM rather than replacing it entirely. This means:

  • Elements with data-persist attribute survive navigation
  • Focus state may be preserved in some cases
  • Existing event listeners on morphed elements are replaced

Programmatic navigation is available via:

window.spaNavigate(new URL(targetPath, window.location.toString()));

Files Reference

  • quartz/components/scripts/spa.inline.ts - SPA router and cleanup system
  • quartz/components/scripts/explorer.inline.ts - Explorer component (example)
  • quartz/components/scripts/graph.inline.ts - Graph component (example)