OpenContracts Routing System Documentation

May 10, 2026 · View on GitHub

Table of Contents

  1. Overview
  2. Architecture
  3. Route Patterns
  4. CentralRouteManager
  5. Route Components
  6. State Management
  7. Navigation Utilities
  8. Testing
  9. Best Practices

Overview

The OpenContracts routing system follows a centralized architecture where one component rules them all: CentralRouteManager. This component handles ALL URL ↔ State synchronization, entity fetching, and canonical redirects, while route components are simple consumers of global state.

Key Principles

  • One Place to Rule Them All: CentralRouteManager is the single source of truth for routing
  • Four-Phase Architecture: Clean separation of URL parsing, query params, redirects, and sync
  • Explicit Routes: Clear patterns with /c/ and /d/ prefixes eliminate ambiguity
  • Slug-First: SEO-friendly URLs with automatic ID → slug redirection
  • Dumb Consumers: Route components just read reactive vars and render
  • URL as Source of Truth: No persistent state, URL drives everything
  • Centralized Query Params: All selection (?ann=, ?analysis=) and visualization (?structural=, ?labels=) params handled in one place
  • Auth-Gated Resolution: Entity fetching waits for authentication to prevent 401/403 errors on deep links
  • Complete Viewer State: URLs encode full document viewer state (selections + visualization settings)

The Critical Convention

ONLY CentralRouteManager is allowed to SET the following reactive variables:

Entity State (set by Phase 1):

  • openedCorpus()
  • openedDocument()
  • openedExtract()
  • openedThread()
  • openedLabelset()
  • openedUser()

URL-Driven State (set by Phase 2, watched by Phase 4):

  • selectedAnnotationIds()
  • selectedAnalysesIds()
  • selectedExtractIds() (when in browse/entity context, NOT on /e/ routes where it's entity state)
  • showStructuralAnnotations()
  • showSelectedAnnotationOnly()
  • showAnnotationBoundingBoxes()
  • showAnnotationLabels()

ALL other components must:

  • ONLY READ these reactive vars with useReactiveVar()
  • NEVER SET these reactive vars directly
  • UPDATE STATE by using navigation utilities that update the URL

The Correct Pattern: Components wanting to change URL-driven state must use utility functions that update the URL. CentralRouteManager Phase 2 will detect the URL change and set the reactive var. This maintains unidirectional data flow: Component → URL → CentralRouteManager → Reactive Var → Component.

This is non-negotiable. Violations cause infinite loops, route jittering, competing state updates, and unpredictable behavior. The static centralRouteDiscipline.test.ts regression guard enforces this rule today, and the historical violations it was built to prevent (URL bypasses in CorpusDocumentCards.tsx, DocumentKnowledgeBase.tsx, FloatingDocumentControls.tsx, NavMenu.tsx/MobileNavMenu.tsx, CorpusBreadcrumbs.tsx, and Corpuses.tsx) have all been removed.

If you find yourself writing openedCorpus(someValue), openedDocument(someValue), openedExtract(someValue), openedThread(someValue), openedLabelset(someValue), or openedUser(someValue) anywhere except CentralRouteManager.tsx, STOP. You are introducing a bug.

A static regression test (frontend/src/routing/__tests__/centralRouteDiscipline.test.ts) grep-walks frontend/src/ and fails CI if any production file outside CentralRouteManager.tsx and cache.ts SETs one of the routing-owned reactive vars. If you've come to this section because that test fired, either route your write through the URL via navigationUtils helpers or — if you genuinely have an auth-driven (not URL-driven) case like ProfileRedirect — discuss adding the file to the explicit allowlist.

Design Decisions

Why Centralized?

  • Previous scattered approach had URL parsing in 6+ locations
  • Circular dependencies between components
  • Duplicate entity fetching
  • Difficult to maintain and test

The Solution:

  • CentralRouteManager owns all routing logic (src/routing/CentralRouteManager.tsx)
  • Route components are ~40 lines (was ~180 lines)
  • Single GraphQL query per route
  • Predictable state flow: URL → Manager → Reactive Vars → Components

Architecture

graph TB
    subgraph "User Action"
        A[Browser URL Change]
        B[User Clicks Link/Button]
    end

    subgraph "CentralRouteManager (One Place)"
        C[Phase 1: URL Path → Entity Resolution]
        D[Phase 2: Query Params → Reactive Vars]
        E[Phase 3: Entity Data → Canonical Redirect]
        F[Phase 4: Reactive Vars → URL Sync]
    end

    subgraph "Global State"
        G[openedCorpus]
        H[openedDocument]
        I[selectedAnnotationIds]
        J[selectedAnalysesIds]
        K[selectedExtractIds]
        L[routeLoading]
        M[routeError]
    end

    subgraph "Route Components (Dumb Consumers)"
        N[CorpusLandingRoute]
        O[DocumentLandingRoute]
        P[Corpuses/Documents/etc]
    end

    A --> C
    B --> F
    C --> G
    C --> H
    D --> I
    D --> J
    D --> K
    E --> A
    F --> A
    G --> N
    H --> O
    I --> N
    I --> O
    J --> N
    J --> O
    K --> N
    K --> O
    L --> N
    L --> O

Four-Phase Flow

Phase 1: URL Path → Entity Resolution (CentralRouteManager.tsx:116-330)

  • Parse pathname: /c/john/corpus{ type: "corpus", userIdent: "john", corpusIdent: "corpus" }
  • Detect entity routes vs browse routes
  • Fetch entities via GraphQL (corpus/document)
  • Set openedCorpus/openedDocument reactive vars
  • Handle loading/error states

Phase 2: URL Query Params → Reactive Vars (CentralRouteManager.tsx:444-484)

  • Extract selection params → reactive vars:
    • ?ann=123,456selectedAnnotationIds(["123", "456"])
    • ?analysis=789selectedAnalysesIds(["789"])
    • ?extract=101selectedExtractIds(["101"])
  • Extract visualization params → reactive vars:
    • ?structural=trueshowStructuralAnnotations(true)
    • ?selectedOnly=trueshowSelectedAnnotationOnly(true)
    • ?boundingBoxes=trueshowAnnotationBoundingBoxes(true)
    • ?labels=ALWAYSshowAnnotationLabels("ALWAYS")
  • Centralized for ALL routes (corpus, document, browse)
  • Enables deep linking with complete viewer state

Phase 3: Entity Data → Canonical Redirects (CentralRouteManager.tsx:349-372)

  • Check if URL matches canonical slug path
  • Redirect /c/john/old-slug/c/john-doe/normalized-slug
  • Preserve query parameters during redirect
  • Ensure SEO-friendly URLs

Phase 4: Reactive Vars → URL Sync (CentralRouteManager.tsx:514-544)

  • Watch selection reactive vars (user selects annotation/analysis)
  • Watch visualization reactive vars (user toggles structural/bounding boxes)
  • Build query string: ann=123&analysis=456&structural=true&labels=ALWAYS
  • Update URL with navigate({ search: queryString }, { replace: true })
  • Bidirectional: URL drives state, state updates URL
  • Only adds non-default visualization values to keep URLs clean

Route Patterns

Entity Routes (Full Object Fetching)

PatternExampleComponentCentralRouteManager Action
/c/:userIdent/:corpusIdent/c/john/my-corpusCorpusLandingRoutePhase 1: Fetch corpus
/d/:userIdent/:docIdent/d/john/my-documentDocumentLandingRoutePhase 1: Fetch document
/d/:userIdent/:corpusIdent/:docIdent/d/john/corpus/docDocumentLandingRoutePhase 1: Fetch both
/e/:userIdent/:extractIdent/e/john/extract-123ExtractDetailRoutePhase 1: Fetch extract
/extracts/:extractId/extracts/extract-123ExtractDetailRoutePhase 1: Fetch extract
/users/:slug/users/janeUserProfileRoutePhase 1: Fetch user
/label_sets/:labelsetId/label_sets/labelset-1LabelSetLandingRoutePhase 1: Fetch labelset

/profile is handled by ProfileRedirect, a small auth-driven <Navigate> component that redirects to /users/<current-user-slug>. It lives outside CentralRouteManager because it is driven by auth state (backendUserObj), not URL state — once it redirects, Phase 1 takes over for the canonical /users/:slug path.

Browse Routes (Query Params Only)

PatternExampleComponentCentralRouteManager Action
/corpuses/corpuses?analysis=123CorpusesPhase 2: Parse query params
/documents/documentsDocumentsPhase 2: Parse query params
/annotations/annotations?ann=123,456AnnotationsPhase 2: Parse query params
/extracts/extracts?extract=789ExtractsPhase 2: Parse query params
/label_sets/label_setsLabelsetsNo action (simple list view)

Query Parameters (All Routes)

Selection Parameters:

ParameterPurposeExampleSet By
?ann=Select annotations?ann=123,456,789Phase 2 or 4
?analysis=Select analyses?analysis=123,456Phase 2 or 4
?extract=Select extracts?extract=456,789Phase 2 or 4
?thread=Select discussion thread (sidebar)?thread=thread-123Phase 2 or 4
?folder=Filter by folder in corpus?folder=folder-123Phase 2 or 4
?tab=Select active tab?tab=discussionsPhase 2 or 4
?message=Highlight message in thread?message=msg-456Phase 2 or 4
?homeView=Corpus home view (about/toc)?homeView=tocPhase 2 or 4
?tocExpanded=Expand all TOC nodes?tocExpanded=truePhase 2 or 4

Tab Values:

  • Corpus tabs: home (default), documents, annotations, analyses, extracts, discussions, chats, analytics, settings, badges
  • Document sidebar tabs: chat (default), feed, extract, analysis, discussions

Home View Values:

  • about (default): Shows corpus description/summary
  • toc: Shows table of contents (document relationships hierarchy)

TOC Expanded:

  • When tocExpanded=true, all nodes in the Table of Contents are expanded by default
  • Useful for deep-linking to a fully expanded TOC view
  • Defaults to collapsed (false) when not specified

Corpus Tab Inline Detail Views:

The corpus view supports inline detail panels for certain entity types. When viewing extracts within a corpus tab, selecting an extract shows a split-view layout with the list on the left and detail on the right, rather than navigating to a separate route.

TabInline SelectionURL PatternBehavior
extracts?extract=123/c/user/corpus?tab=extracts&extract=123Split view: list + detail panel
documents?folder=123/c/user/corpus?tab=documents&folder=123Filter documents by folder

This pattern keeps users in the corpus context while viewing entity details, with the option to "Open full page" to navigate to the standalone entity route (/extracts/123).

Document Version Parameter:

ParameterPurposeValuesDefaultSet By
?v=Select document versionInteger version numberomit (current version)Phase 1/2/4

When ?v=N is present in the URL:

  • Phase 1 passes versionNumber to the documentInCorpusBySlugs GraphQL query
  • The backend resolves the specific historical version via DocumentPath.version_number
  • The resolved document is the exact Document record for that version
  • Annotations are version-specific: each version's annotations are tied to its Document record
  • The version selector UI (in the document header) shows available versions and links via ?v=N

Without ?v=, the current (latest) version is displayed.

Visualization Parameters (Document Viewer):

ParameterPurposeValuesDefaultSet By
?structural=Show structural annotationstrue or omitfalsePhase 2/4
?selectedOnly=Show only selected annotationstrue or omitfalsePhase 2/4
?boundingBoxes=Show annotation bounding boxestrue or omitfalsePhase 2/4
?labels=Label display behaviorALWAYS|ON_HOVER|HIDEON_HOVERPhase 2/4

Examples:

# Corpus with selections
/c/john/legal-corpus?analysis=5678&extract=9012

# Document with annotation (structural annotations visible for deep linking)
/d/jane/contract?ann=1234&structural=true

# Document with full visualization state
/d/john/corpus/doc?ann=1,2&analysis=3&structural=true&boundingBoxes=true&labels=ALWAYS

# Document with discussion thread open in sidebar
/d/john/corpus/doc?thread=thread-456

# Browse with highlighting
/annotations?ann=123,456

# Deep link to structural annotation with optimal viewer settings
/d/user/corpus/document?ann=structural-ann-123&structural=true&selectedOnly=true&labels=ALWAYS

# View a specific historical version of a document
/d/john/corpus/contract?v=1

# Deep link to annotation on a specific version
/d/john/corpus/contract?v=2&ann=456&structural=true

# Corpus with tab selection (discussions tab)
/c/john/legal-corpus?tab=discussions

# Corpus with folder filter and documents tab
/c/john/legal-corpus?tab=documents&folder=folder-123

# Document with sidebar thread and specific message
/d/john/corpus/doc?thread=thread-456&message=msg-789

# Full-page thread with message highlight
/c/john/legal-corpus/discussions/thread-456?message=msg-789

# Corpus extracts tab with inline extract detail (split view)
/c/john/legal-corpus?tab=extracts&extract=456

# Corpus chat history tab
/c/john/legal-corpus?tab=chats

# Corpus home with table of contents view
/c/john/legal-corpus?homeView=toc

# Corpus home with fully expanded table of contents (for deep-linking)
/c/john/legal-corpus?homeView=toc&tocExpanded=true

Route Configuration

App.tsx (lines 301-343):

{/* CentralRouteManager handles ALL URL ↔ State sync */}
<CentralRouteManager />

<Routes>
  {/* Entity routes */}
  <Route path="/d/:userIdent/:corpusIdent/:docIdent" element={<DocumentLandingRoute />} />
  <Route path="/d/:userIdent/:docIdent" element={<DocumentLandingRoute />} />
  <Route path="/c/:userIdent/:corpusIdent" element={<CorpusLandingRoute />} />

  {/* Browse routes */}
  <Route path="/corpuses" element={<Corpuses />} />
  <Route path="/documents" element={<Documents />} />
  <Route path="/annotations" element={<Annotations />} />
  <Route path="/extracts" element={<Extracts />} />
  <Route path="/label_sets" element={<Labelsets />} />

  {/* 404 */}
  <Route path="/404" element={<NotFound />} />
  <Route path="*" element={<NotFound />} />
</Routes>

ID-Based Navigation (Auto-Redirect)

CentralRouteManager automatically detects IDs and redirects to canonical slug URLs:

/c/john/Q29ycHVzOjEyMw==/c/john-doe/my-corpus/d/jane/4567/d/jane/my-document/d/john/corpus/7890/d/john/corpus/doc-slug

Detection Logic (utils/navigationUtils.ts:getIdentifierType()):

  • Base64 strings (e.g., Q29ycHVzOjEyMw==)
  • Numeric IDs ≥4 digits (e.g., 1234, 456789)
  • GID format (e.g., gid://app/Corpus/123)

CentralRouteManager

Location: src/routing/CentralRouteManager.tsx

Core Responsibilities

  1. Parse URLs → Determine route type (corpus/document/extract/browse)
  2. Fetch Entities → GraphQL queries for corpus/document/extract
  3. Extract Query Params → Parse ?ann=, ?analysis=, ?extract=
  4. Set Global State → Update reactive vars
  5. Canonical Redirects → Ensure SEO-friendly URLs (ID → slug for corpus/document)
  6. Bidirectional Sync → Watch state changes, update URL

Implementation Details

Phase 1: Entity Resolution (lines 116-330)

useEffect(() => {
  const route = parseRoute(location.pathname);

  // Browse routes - clear entities
  if (route.type === "browse" || route.type === "unknown") {
    openedCorpus(null);
    openedDocument(null);
    openedExtract(null);
    routeLoading(false);
    return;
  }

  // Entity routes - fetch from GraphQL
  if (route.type === "corpus") {
    // RESOLVE_CORPUS_BY_SLUGS_FULL
    const { data } = await resolveCorpus({
      variables: { userSlug, corpusSlug }
    });
    openedCorpus(data.corpusBySlugs);
    openedDocument(null);
    openedExtract(null);
  }

  if (route.type === "document") {
    // RESOLVE_DOCUMENT_BY_SLUGS_FULL or
    // RESOLVE_DOCUMENT_IN_CORPUS_BY_SLUGS_FULL
    const { data } = await resolveDocument(...);
    openedDocument(data.documentBySlugs);
    openedCorpus(data.corpusBySlugs || null);
    openedExtract(null);
  }

  if (route.type === "extract") {
    // RESOLVE_EXTRACT_BY_ID (extracts use IDs, not slugs yet)
    const { data } = await resolveExtract({
      variables: { extractId: route.extractIdent }
    });
    openedExtract(data.extract);
    openedCorpus(null);
    openedDocument(null);
  }
}, [location.pathname]);

Phase 2: Query Params (lines 444-484)

useEffect(() => {
  // Selection state
  const annIds = parseQueryParam(searchParams.get("ann"));
  const analysisIds = parseQueryParam(searchParams.get("analysis"));
  const extractIds = parseQueryParam(searchParams.get("extract"));

  // Visualization state
  const structural = searchParams.get("structural") === "true";
  const selectedOnly = searchParams.get("selectedOnly") === "true";
  const boundingBoxes = searchParams.get("boundingBoxes") === "true";
  const labelsParam = searchParams.get("labels");

  // Update reactive vars
  selectedAnnotationIds(annIds);
  selectedAnalysesIds(analysisIds);
  selectedExtractIds(extractIds);
  showStructuralAnnotations(structural);
  showSelectedAnnotationOnly(selectedOnly);
  showAnnotationBoundingBoxes(boundingBoxes);

  if (labelsParam === "ALWAYS") {
    showAnnotationLabels("ALWAYS");
  } else if (labelsParam === "HIDE") {
    showAnnotationLabels("HIDE");
  } else {
    showAnnotationLabels("ON_HOVER");
  }
}, [searchParams]);

Phase 3: Canonical Redirects (lines 349-372)

useEffect(() => {
  const canonicalPath = buildCanonicalPath(document, corpus);
  if (canonicalPath && currentPath !== canonicalPath) {
    navigate(canonicalPath + location.search, { replace: true });
  }
}, [corpus, document, location.pathname]);

Phase 4: URL Sync (lines 514-544)

// Watch reactive vars
const annIds = useReactiveVar(selectedAnnotationIds);
const analysisIds = useReactiveVar(selectedAnalysesIds);
const extractIds = useReactiveVar(selectedExtractIds);
const structural = useReactiveVar(showStructuralAnnotations);
const selectedOnly = useReactiveVar(showSelectedAnnotationOnly);
const boundingBoxes = useReactiveVar(showAnnotationBoundingBoxes);
const labels = useReactiveVar(showAnnotationLabels);

useEffect(() => {
  // CRITICAL: Don't sync on initial mount - wait for Phase 2 to read URL first
  if (!hasInitializedFromUrl.current) {
    return;
  }

  // CRITICAL: Don't sync while route is loading!
  // Prevents race condition where Phase 4 reads stale reactive vars before Phase 2 updates them
  if (routeLoading()) {
    return;
  }

  const queryString = buildQueryParams({
    annotationIds: annIds,
    analysisIds,
    extractIds,
    showStructural: structural,
    showSelectedOnly: selectedOnly,
    showBoundingBoxes: boundingBoxes,
    labelDisplay: labels,
  });

  if (currentSearch !== queryString) {
    navigate({ search: queryString }, { replace: true });
  }
}, [annIds, analysisIds, extractIds, structural, selectedOnly, boundingBoxes, labels]);

GraphQL Queries

Corpus Resolution (src/graphql/queries.ts:RESOLVE_CORPUS_BY_SLUGS_FULL):

query ResolveCorpusBySlugsFull($userSlug: String!, $corpusSlug: String!) {
  corpusBySlugs(userSlug: $userSlug, corpusSlug: $corpusSlug) {
    id
    slug
    title
    description
    creator { id username slug }
    labelSet { id title }
    myPermissions
    isPublic
  }
}

Document Resolution (src/graphql/queries.ts:RESOLVE_DOCUMENT_BY_SLUGS_FULL):

query ResolveDocumentBySlugsFull($userSlug: String!, $documentSlug: String!) {
  documentBySlugs(userSlug: $userSlug, documentSlug: $documentSlug) {
    id
    slug
    title
    description
    creator { id username slug }
    corpus { id slug title creator { id slug } }
    pdfFile { id url }
  }
}

Extract Resolution (src/graphql/queries.ts:RESOLVE_EXTRACT_BY_ID):

query ResolveExtractById($extractId: ID!) {
  extract(id: $extractId) {
    id
    name
    created
    started
    finished
    error
    myPermissions
    creator { id username slug }
    corpus { id slug title creator { id slug } }
    fieldset { id name description }
  }
}

Document in Corpus (src/graphql/queries.ts:RESOLVE_DOCUMENT_IN_CORPUS_BY_SLUGS_FULL):

query ResolveDocumentInCorpusBySlugsFull(
  $userSlug: String!
  $corpusSlug: String!
  $documentSlug: String!
) {
  corpusBySlugs(userSlug: $userSlug, corpusSlug: $corpusSlug) { ... }
  documentInCorpusBySlugs(
    userSlug: $userSlug
    corpusSlug: $corpusSlug
    documentSlug: $documentSlug
  ) { ... }
}

Performance Optimizations

Request Deduplication (utils/performanceMonitoring.ts:RequestTracker):

// Prevents duplicate simultaneous requests
if (requestTracker.isPending(requestKey)) {
  return; // Skip duplicate request
}

await requestTracker.trackRequest(requestKey, async () => {
  // Perform GraphQL query
});

Route Processing Guard (line 120-123):

// Skip if we've already processed this exact path
if (lastProcessedPath.current === currentPath) {
  return;
}
lastProcessedPath.current = currentPath;

Apollo Cache:

fetchPolicy: "cache-first"
nextFetchPolicy: "cache-and-network"

Route Components

Route components are now dumb consumers that read reactive vars and render UI.

DocumentLandingRoute

Location: src/components/routes/DocumentLandingRoute.tsx (48 lines, was 180)

export const DocumentLandingRoute: React.FC = () => {
  const navigate = useNavigate();

  // Just read state from reactive vars (set by CentralRouteManager)
  const document = useReactiveVar(openedDocument);
  const corpus = useReactiveVar(openedCorpus);
  const loading = useReactiveVar(routeLoading);
  const error = useReactiveVar(routeError);

  // Loading/error states
  if (loading) return <ModernLoadingDisplay type="document" />;
  if (error || !document) return <ModernErrorDisplay error={error} />;

  // Close handler
  const handleClose = () => {
    if (corpus) {
      navigate(`/c/${corpus.creator.slug}/${corpus.slug}`);
    } else {
      navigate("/documents");
    }
  };

  // Just render with hydrated data
  return (
    <ErrorBoundary>
      <MetaTags title={document.title} type="document" />
      <DocumentKnowledgeBase
        documentId={document.id}
        corpusId={corpus?.id}
        onClose={handleClose}
      />
    </ErrorBoundary>
  );
};

Key Points:

  • No useParams() → CentralRouteManager owns URL parsing
  • No GraphQL queries → CentralRouteManager fetches entities
  • No URL sync → CentralRouteManager handles query params
  • Just reads openedDocument, openedCorpus, routeLoading, routeError
  • Pure view logic

CorpusLandingRoute

Location: src/components/routes/CorpusLandingRoute.tsx (42 lines, was 120)

export const CorpusLandingRoute: React.FC = () => {
  const corpus = useReactiveVar(openedCorpus);
  const loading = useReactiveVar(routeLoading);
  const error = useReactiveVar(routeError);

  if (loading) return <ModernLoadingDisplay type="corpus" />;
  if (error || !corpus) return <ModernErrorDisplay error={error} />;

  return (
    <ErrorBoundary>
      <MetaTags title={corpus.title} type="corpus" />
      <Corpuses />
    </ErrorBoundary>
  );
};

Browse Views (Corpuses, Documents, Annotations, Extracts)

Example: src/views/Annotations.tsx

export const Annotations = () => {
  // URL query parameters already set by CentralRouteManager
  const annotation_ids = useReactiveVar(selectedAnnotationIds);
  const analysis_ids = useReactiveVar(selectedAnalysesIds);

  // Just consume the state and render
  return <AnnotationCards />;
};

What They DON'T Do:

  • ❌ No useParams() extraction
  • ❌ No entity fetching by route
  • ❌ No URL query param parsing
  • ❌ No canonical redirects

What They DO:

  • ✅ Read reactive vars
  • ✅ Fetch lists (not entities)
  • ✅ Display UI
  • ✅ Update selections via URL utilities (CentralRouteManager Phase 2 sets reactive vars)

State Management

Global Reactive Variables

Location: src/graphql/cache.ts

// Entity state (set by CentralRouteManager Phase 1)
export const openedCorpus = makeVar<CorpusType | null>(null);
export const openedDocument = makeVar<DocumentType | null>(null);
export const openedExtract = makeVar<ExtractType | null>(null);

// Selection state (set by CentralRouteManager Phase 2, watched by Phase 4)
export const selectedAnnotationIds = makeVar<string[]>([]);
export const selectedAnalysesIds = makeVar<string[]>([]);
export const selectedExtractIds = makeVar<string[]>([]);

// Visualization state (set by CentralRouteManager Phase 2, watched by Phase 4)
export const showStructuralAnnotations = makeVar<boolean>(false);
export const showSelectedAnnotationOnly = makeVar<boolean>(false);
export const showAnnotationBoundingBoxes = makeVar<boolean>(false);
export const showAnnotationLabels = makeVar<LabelDisplayBehavior>("ON_HOVER");

// Loading/error state (set by CentralRouteManager Phase 1)
export const routeLoading = makeVar<boolean>(false);
export const routeError = makeVar<Error | null>(null);
export const authStatusVar = makeVar<AuthStatus>("LOADING");

State Flow

┌─────────────────────────────────────────────────────────────┐
│ URL: /c/john/my-corpus?analysis=123&extract=456            │
└─────────────────────────────────────────────────────────────┘

          ┌───────────────────────────┐
          │   CentralRouteManager     │
          │  (One Place to Rule All)  │
          └───────────────────────────┘
                    ↓           ↓
         ┌──────────────┐  ┌──────────────────────┐
         │ Phase 1      │  │ Phase 2              │
         │ Parse path   │  │ Parse query params   │
         │ Fetch corpus │  │ ?analysis=123        │
         └──────────────┘  │ ?extract=456         │
                ↓          └──────────────────────┘
    ┌───────────────────┐           ↓
    │ openedCorpus      │  ┌────────────────────────┐
    │ = CorpusType      │  │ selectedAnalysesIds    │
    │                   │  │ = ["123"]              │
    │ routeLoading      │  │                        │
    │ = false           │  │ selectedExtractIds     │
    └───────────────────┘  │ = ["456"]              │
                ↓          └────────────────────────┘
        ┌─────────────────────────┐
        │  CorpusLandingRoute     │
        │  (Dumb Consumer)        │
        │                         │
        │  const corpus =         │
        │    useReactiveVar(      │
        │      openedCorpus       │
        │    );                   │
        │                         │
        │  return <Corpuses />    │
        └─────────────────────────┘

            ┌──────────────┐
            │  Corpuses    │
            │  View        │
            │  Renders!    │
            └──────────────┘

Bidirectional Sync Example

User selects annotation → URL updates:

// Component updates URL via utility
updateAnnotationSelectionParams(location, navigate, {
  annotationIds: ["123", "456"],
});

// CentralRouteManager Phase 2 detects URL change
useEffect(() => {
  const annIds = parseQueryParam(searchParams.get("ann"));
  selectedAnnotationIds(annIds); // Sets reactive var
}, [searchParams]);

// CentralRouteManager Phase 4 keeps URL in sync if var changes from elsewhere
useEffect(() => {
  const queryString = buildQueryParams({ annotationIds: annIds, ... });
  navigate({ search: queryString }, { replace: true });
}, [annIds]);

// URL becomes: ?ann=123,456

URL changes → Component updates:

// CentralRouteManager Phase 2 watches URL
useEffect(() => {
  const annIds = parseQueryParam(searchParams.get("ann"));
  selectedAnnotationIds(annIds); // ["123", "456"]
}, [searchParams]);

// Component reads reactive var
const annotation_ids = useReactiveVar(selectedAnnotationIds);
// Renders with: ["123", "456"]

Location: src/utils/navigationUtils.ts

Core Functions

parseRoute() - Parse URL pathname

parseRoute("/c/john/my-corpus")
// Returns: { type: "corpus", userIdent: "john", corpusIdent: "my-corpus" }

parseRoute("/d/john/corpus/doc")
// Returns: { type: "document", userIdent: "john",
//           corpusIdent: "corpus", documentIdent: "doc" }

parseRoute("/annotations")
// Returns: { type: "browse", browsePath: "annotations" }

parseQueryParam() - Parse comma-separated IDs

parseQueryParam("123,456,789")
// Returns: ["123", "456", "789"]

parseQueryParam(null)
// Returns: []

buildCanonicalPath() - Build SEO-friendly path

buildCanonicalPath(document, corpus, extract)
// Returns: "/d/john-doe/my-corpus/doc-slug"

buildCanonicalPath(document, null, null)
// Returns: "/d/jane/standalone-doc"

buildCanonicalPath(null, corpus, null)
// Returns: "/c/john/my-corpus"

buildCanonicalPath(null, null, extract)
// Returns: "/e/john/extract-123" (ID-based, no slugs yet)

buildQueryParams() - Build query string

buildQueryParams({
  annotationIds: ["1", "2"],
  analysisIds: ["3"],
  extractIds: ["4"]
})
// Returns: "?ann=1,2&analysis=3&extract=4"

getCorpusUrl() - Generate corpus URL

getCorpusUrl(corpus)
// Returns: "/c/john/my-corpus"

getCorpusUrl(corpus, { analysisIds: ["123"] })
// Returns: "/c/john/my-corpus?analysis=123"

getCorpusUrl({ id: "1", slug: "" }) // Missing slug
// Returns: "#" (safe fallback)

getDocumentUrl() - Generate document URL

getDocumentUrl(document, corpus)
// Returns: "/d/john/corpus/doc"

getDocumentUrl(document, null)
// Returns: "/d/jane/standalone-doc"

getDocumentUrl(document, corpus, {
  annotationIds: ["1"],
  analysisIds: ["2"],
  extractIds: ["3"]
})
// Returns: "/d/john/corpus/doc?ann=1&analysis=2&extract=3"

getExtractUrl() - Generate extract URL

getExtractUrl(extract)
// Returns: "/e/john/extract-123"

getExtractUrl(extract, { analysisIds: ["456"] })
// Returns: "/e/john/extract-123?analysis=456"

getExtractUrl({ id: "1", creator: null }) // Missing creator
// Returns: "#" (safe fallback)

navigateToCorpus() - Smart corpus navigation

navigateToCorpus(corpus, navigate, currentPath)
// Only navigates if not already there
// Pushes to browser history (not replace) so back button works

navigateToCorpus(corpus, navigate, currentPath, { analysisIds: ["123"] })
// Navigates with query params

navigateToDocument() - Smart document navigation

navigateToDocument(document, corpus, navigate, currentPath)
// Only navigates if not already there
// Pushes to browser history (not replace) so back button works

navigateToDocument(document, corpus, navigate, currentPath, {
  annotationIds: ["456"]
})
// Navigates with query params

navigateToExtract() - Smart extract navigation

navigateToExtract(extract, navigate, currentPath)
// Only navigates if not already there
// Pushes to browser history (not replace) so back button works

navigateToExtract(extract, navigate, currentPath, { analysisIds: ["123"] })
// Navigates with query params

Important: These navigation functions use navigate(path) (push) NOT navigate(path, { replace: true }). This ensures browser history is maintained and the back button works correctly. When a user opens a document/corpus, a new history entry is created, allowing them to use the browser back button to return to the previous view.

State Update Utilities

CRITICAL: Components must NEVER directly set URL-driven reactive vars. Instead, use these utilities that update the URL, allowing CentralRouteManager Phase 2 to set the reactive var.

updateAnnotationSelectionParams() - Update selection state via URL

import { updateAnnotationSelectionParams } from "../utils/navigationUtils";

// Component wants to change analysis selection
const handleSelectAnalysis = (analysisId: string) => {
  updateAnnotationSelectionParams(location, navigate, {
    analysisIds: [analysisId],
    extractIds: [],  // Clear extracts when selecting analysis
  });
  // CentralRouteManager Phase 2 will detect URL change and set selectedAnalysesIds
};

// Component wants to toggle annotation selection
const toggleAnnotation = (annotationId: string) => {
  const currentIds = useReactiveVar(selectedAnnotationIds);
  const newIds = currentIds.includes(annotationId)
    ? currentIds.filter(id => id !== annotationId)
    : [...currentIds, annotationId];

  updateAnnotationSelectionParams(location, navigate, {
    annotationIds: newIds,
  });
};

updateAnnotationDisplayParams() - Update visualization state via URL

import { updateAnnotationDisplayParams } from "../utils/navigationUtils";

// Component wants to change display settings
const handleToggleStructural = (value: boolean) => {
  updateAnnotationDisplayParams(location, navigate, {
    showStructural: value,
  });
  // CentralRouteManager Phase 2 will detect URL change and set showStructuralAnnotations
};

// Setting multiple visualization params at once
const viewExtractAnnotations = () => {
  updateAnnotationDisplayParams(location, navigate, {
    showStructural: true,
    showBoundingBoxes: true,
    showSelectedOnly: false,
    labelDisplay: "ALWAYS",
  });
};

clearAnnotationSelection() - Clear all selection state via URL

import { clearAnnotationSelection } from "../utils/navigationUtils";

// Cleanup on component unmount
useEffect(() => {
  return () => {
    clearAnnotationSelection(location, navigate);
  };
}, [location, navigate]);

Why These Utilities?

  • Maintains unidirectional data flow: Component → URL → CentralRouteManager → Reactive Var
  • Prevents competing writes to reactive vars
  • Ensures URL is always source of truth
  • Avoids infinite loops and race conditions

Usage in Components

// Card component
const handleClick = () => {
  const url = getDocumentUrl(document, corpus, {
    annotationIds: [annotation.id]
  });
  if (url !== "#") {
    navigate(url);
  }
};

// Smart navigation (checks if already there)
const handleNavigate = () => {
  navigateToDocument(document, corpus, navigate, location.pathname, {
    annotationIds: selectedAnnotationIds(),
    analysisIds: selectedAnalysesIds()
  });
};

Testing

Unit Tests

navigationUtils.test.ts (74 tests):

describe("parseRoute()", () => {
  it("should parse corpus route", () => {
    expect(parseRoute("/c/john/my-corpus")).toEqual({
      type: "corpus",
      userIdent: "john",
      corpusIdent: "my-corpus"
    });
  });

  it("should parse browse routes", () => {
    expect(parseRoute("/annotations")).toEqual({
      type: "browse",
      browsePath: "annotations"
    });
  });
});

describe("buildQueryParams()", () => {
  it("should build query string", () => {
    expect(buildQueryParams({
      annotationIds: ["1", "2"],
      analysisIds: ["3"]
    })).toBe("?ann=1,2&analysis=3");
  });
});

Integration Tests

CentralRouteManager.test.tsx (21 tests, 500+ lines):

describe("Phase 1: URL Path → Entity Resolution", () => {
  it("should resolve corpus from slug-based URL", async () => {
    const mocks = [/* GraphQL mock */];

    render(
      <MockedProvider mocks={mocks}>
        <MemoryRouter initialEntries={["/c/john/my-corpus"]}>
          <CentralRouteManager />
        </MemoryRouter>
      </MockedProvider>
    );

    expect(routeLoading()).toBe(true);
    await waitFor(() => expect(routeLoading()).toBe(false));
    expect(openedCorpus()).toEqual(mockCorpus);
  });
});

describe("Phase 2: Query Params → Reactive Vars", () => {
  it("should parse annotation IDs from URL", () => {
    render(
      <MemoryRouter initialEntries={["/annotations?ann=123,456"]}>
        <CentralRouteManager />
      </MemoryRouter>
    );

    expect(selectedAnnotationIds()).toEqual(["123", "456"]);
  });
});

describe("Phase 4: Reactive Vars → URL Sync", () => {
  it("should update URL when annotation IDs change", async () => {
    render(<CentralRouteManager />);

    selectedAnnotationIds(["new-123"]);

    await waitFor(() => {
      expect(mockNavigate).toHaveBeenCalledWith(
        { search: "ann=new-123" },
        { replace: true }
      );
    });
  });
});

routing-integration.test.tsx (7 full-flow tests):

it("should flow from URL → Manager → Reactive Vars → Component", async () => {
  const mocks = [/* corpus mock */];

  render(
    <MockedProvider mocks={mocks}>
      <MemoryRouter initialEntries={["/c/john/my-corpus?analysis=123"]}>
        <CentralRouteManager />
        <Routes>
          <Route path="/c/:userIdent/:corpusIdent" element={<CorpusLandingRoute />} />
        </Routes>
      </MemoryRouter>
    </MockedProvider>
  );

  // Phase 1: Loading
  expect(routeLoading()).toBe(true);

  // Phase 2: Wait for resolution
  await waitFor(() => expect(routeLoading()).toBe(false));

  // Phase 3: Verify reactive vars
  expect(openedCorpus()).toEqual(mockCorpus);
  expect(selectedAnalysesIds()).toEqual(["123"]);

  // Phase 4: Verify component rendered
  expect(screen.getByTestId("corpus-view")).toBeInTheDocument();
});

Route Component Tests

DocumentLandingRoute.test.tsx (6 tests):

describe("State-Driven Rendering", () => {
  beforeEach(() => {
    openedDocument(null);
    routeLoading(false);
    routeError(null);
  });

  it("should show loading when routeLoading is true", () => {
    routeLoading(true);
    render(<DocumentLandingRoute />);
    expect(screen.getByText("Loading...")).toBeInTheDocument();
  });

  it("should render view when document is loaded", () => {
    openedDocument(mockDocument);
    routeLoading(false);
    render(<DocumentLandingRoute />);
    expect(screen.getByText("DocumentKnowledgeBase")).toBeInTheDocument();
  });
});

Debugging

Routing Logger

The routing system includes a debug logging utility that keeps console clean by default while providing detailed diagnostics when needed.

Location: src/utils/routingLogger.ts

Log Visibility

Normal Mode (Default):

  • ✅ Clean console during navigation
  • ✅ Errors and warnings always visible
  • ✅ Circuit breaker alerts always visible
  • 🔇 Verbose routing logs hidden (Phase 1/2/3/4 details)

Debug Mode (When Troubleshooting):

  • ✅ All Phase 1/2/3/4 routing logs visible
  • ✅ Navigation decisions and state changes
  • ✅ Entity resolution details
  • ✅ Circuit breaker tracking

Enabling Debug Mode

Browser Console:

// Enable verbose routing logs
window.DEBUG_ROUTING = true

// Disable verbose routing logs
window.DEBUG_ROUTING = false

// Or use helper functions
window.routingLogger.enableDebug()
window.routingLogger.disableDebug()

// Check status
window.routingLogger.getStatus()
// Returns: { debugEnabled: true/false }

Implementation Details

All routing logs use routingLogger.debug() instead of console.log():

import { routingLogger } from "../utils/routingLogger";

// Verbose logs (hidden by default)
routingLogger.debug("[RouteManager] Phase 1 - Entity check:", { ... });
routingLogger.debug("[DocumentLandingRoute] Render triggered", { ... });

// Critical logs (always visible)
console.error("[RouteManager] ❌ GraphQL error:", error);
console.warn("[RouteManager] ⚠️  Corpus not found");

Files Using Routing Logger:

  • CentralRouteManager.tsx - All 4 phases
  • DocumentLandingRoute.tsx - Navigation and state changes
  • DocumentKnowledgeBase.tsx - Component lifecycle and close handler
  • navigationCircuitBreaker.ts - Navigation tracking (errors always visible)

The circuit breaker detects and prevents infinite navigation loops caused by racing state updates.

Location: src/utils/navigationCircuitBreaker.ts

Detection Criteria (3-second window):

  1. Excessive navigations: >5 navigations in 3 seconds
  2. Loop detection: Same URL appearing ≥3 times
  3. Ping-pong pattern: Rapid back-and-forth between two URLs (A→B→A→B)

When Tripped:

🚨🚨🚨 [CircuitBreaker] CIRCUIT TRIPPED! 🚨🚨🚨
Reason: Ping-pong detected between "/c/user/corpus" and "/d/user/corpus/doc"
Navigation history (last 3000ms):
  1. [DocumentLandingRoute] /c/user/corpus @ 2025-10-09T07:00:00.696Z
  2. [CentralRouteManager] /d/user/corpus/doc @ 2025-10-09T07:00:00.724Z
  3. [CentralRouteManager] /c/user/corpus @ 2025-10-09T07:00:00.821Z
  4. [CentralRouteManager] /d/user/corpus/doc @ 2025-10-09T07:00:00.918Z
Circuit will remain tripped until page reload. Manual navigation required.

Accessing Circuit Breaker:

// Check status
window.navigationCircuitBreaker.getStatus()
// Returns: { tripped: boolean, eventCount: number, events: NavigationEvent[] }

// Manual reset (debugging only)
window.navigationCircuitBreaker.reset()

Navigation Tracking (Debug Mode):

📍 [CircuitBreaker] Recorded navigation #1:
  { url: "/c/user/corpus", source: "DocumentLandingRoute", timestamp: 1759993200696 }
📍 [CircuitBreaker] Recorded navigation #2:
  { url: "/d/user/corpus/doc", source: "CentralRouteManager", timestamp: 1759993200724 }

Debugging Workflow

1. Enable Verbose Logging:

window.DEBUG_ROUTING = true

2. Reproduce the Issue:

  • Navigate through the application
  • Watch console for routing logs
  • Circuit breaker will catch infinite loops

3. Analyze the Logs:

[RouteManager] Phase 1 - Entity check: { routeType: "corpus", hasEntitiesForRoute: false }
[RouteManager] Phase 3: Skipping redirect - corpus route but document still set
[DocumentLandingRoute] ✅ Decision: Navigate to corpus
🧭 [DocumentLandingRoute] navigate() called: { to: "/c/user/corpus" }
📍 [CircuitBreaker] Recorded navigation #1

4. Common Patterns to Look For:

  • Phase 3 redirects happening when they shouldn't (check entity/route type mismatch guards)
  • Multiple components navigating simultaneously (check for routing violations)
  • State changes triggering unexpected navigations (check Phase 4 guards)
  • Reactive var updates not batched (check for cascading re-renders)

5. Disable After Fixing:

window.DEBUG_ROUTING = false

Example: Debugging Route Jittering

Symptom: Modal opens/closes repeatedly when clicking back button

Debug Process:

// Enable debug mode
window.DEBUG_ROUTING = true

// Click back button, observe logs:
[DocumentLandingRoute] 🚪 handleClose START
[DocumentLandingRoute] ✅ Decision: Navigate to corpus
📍 [CircuitBreaker] Recorded navigation #1: /c/user/corpus
[RouteManager] Phase 3: Skipping redirect - corpus route but document still set ← THE FIX!
[RouteManager] Phase 3: Path already canonical, no redirect

// Without the guard, you'd see:
[RouteManager] Phase 3: Redirecting to canonical path: { from: "/c/user/corpus", to: "/d/user/corpus/doc" }
📍 [CircuitBreaker] Recorded navigation #2: /d/user/corpus/doc
🚨 [CircuitBreaker] CIRCUIT TRIPPED! Ping-pong detected...

Root Cause Identified: Phase 3 was redirecting corpus routes back to document routes because it still saw the stale openedDocument reactive var before Phase 1 cleared it (async race condition).

Fix: Add guard in Phase 3 to skip redirect when currentRoute.type === "corpus" && document (entity doesn't match route type).

Tips

  • Enable selectively: Only enable debug mode when actively troubleshooting to avoid log pollution
  • Trust the circuit breaker: If it trips, there's a real bug - don't just reset it
  • Watch the timestamps: Navigation events within 50-100ms of each other indicate race conditions
  • Check the source: Circuit breaker logs which component triggered each navigation
  • Inspect state snapshots: Debug logs include full state context at each decision point

Best Practices

Critical Rules (MUST Follow)

🚨 RULE #1: NEVER Set Routing-Owned Reactive Vars Outside CentralRouteManager

The full set: openedCorpus, openedDocument, openedExtract, openedThread, openedLabelset, openedUser.

// ❌ NEVER DO THIS (anywhere except CentralRouteManager.tsx):
openedCorpus(someCorpus);
openedDocument(someDocument);
openedExtract(someExtract);
openedUser(someUser);
openedCorpus(null);
openedDocument(null);
openedExtract(null);
openedUser(null);

// ✅ ALWAYS DO THIS:
const corpus = useReactiveVar(openedCorpus);  // Read only
const document = useReactiveVar(openedDocument);  // Read only
const extract = useReactiveVar(openedExtract);  // Read only
const user = useReactiveVar(openedUser);  // Read only

Why? Setting these vars from multiple places creates competing state updates, infinite loops, and route jittering. We learned this the hard way when CorpusDocumentCards.tsx was fetching corpus data and setting openedCorpus(), creating an infinite loop with CentralRouteManager.

CI enforcement: frontend/src/routing/__tests__/centralRouteDiscipline.test.ts runs in the standard Vitest suite and fails if any production file outside the manager and cache.ts SETs one of these vars. Adding to its allowlist requires the same scrutiny as adding to RULE #1.

🚨 RULE #2: NEVER Fetch Entities in Route Components

// ❌ NEVER DO THIS in route components:
const { data } = useQuery(GET_CORPUS_BY_ID, {
  onCompleted: (data) => {
    openedCorpus(data.corpus);  // VIOLATION!
  }
});

// ✅ CentralRouteManager handles ALL entity fetching:
// - Corpus resolution via RESOLVE_CORPUS_BY_SLUGS_FULL
// - Document resolution via RESOLVE_DOCUMENT_BY_SLUGS_FULL
// - Automatic caching and deduplication

Why? Duplicate fetching wastes bandwidth, creates race conditions, and makes caching impossible. Let CentralRouteManager do its job.

🚨 RULE #3: NEVER Parse Route Parameters Outside CentralRouteManager

// ❌ NEVER DO THIS:
const { userIdent, corpusIdent } = useParams();
// Parse URL yourself...

// ✅ ALWAYS DO THIS:
const corpus = useReactiveVar(openedCorpus);
// CentralRouteManager already parsed the URL and fetched the corpus

Why? URL parsing logic should exist in exactly one place. Multiple parsers lead to inconsistencies and bugs.

🚨 RULE #4: NEVER Manually Clear Route State

// ❌ NEVER DO THIS:
const gotoHome = () => {
  openedCorpus(null);  // VIOLATION!
  openedDocument(null);  // VIOLATION!
  openedExtract(null);  // VIOLATION!
  openedUser(null);  // VIOLATION!
  navigate("/corpuses");
};

// ✅ ALWAYS DO THIS:
const gotoHome = () => {
  navigate("/corpuses");
  // CentralRouteManager will detect the route change and clear state automatically
};

Why? CentralRouteManager watches the URL and automatically clears all entity vars when navigating to a browse route. Manual clearing creates race conditions.

DO ✅

Routing:

  • ✅ Let CentralRouteManager handle ALL URL ↔ State sync
  • ✅ Route components just read reactive vars with useReactiveVar()
  • ✅ Use navigation utilities (getCorpusUrl, getDocumentUrl, etc.)
  • ✅ Preserve query params when navigating
  • ✅ Check for "#" return from URL generators (indicates missing data)
  • ✅ Trust that CentralRouteManager will clear state when routes change

State:

  • ✅ URL is source of truth (no persistent local state)
  • ✅ Read entity vars: const corpus = useReactiveVar(openedCorpus);
  • ✅ Update selection via utilities: updateAnnotationSelectionParams(location, navigate, { annotationIds: ["123"] });
  • ✅ Update display via utilities: updateAnnotationDisplayParams(location, navigate, { showStructural: true });
  • ✅ Let CentralRouteManager Phase 2 set reactive vars from URL changes
  • ✅ Check routeLoading before accessing entities
  • ✅ Handle routeError for failed entity resolution

Navigation:

  • ✅ Use getCorpusUrl(corpus) to generate URLs
  • ✅ Use getDocumentUrl(document, corpus) with optional query params
  • ✅ Use navigateToCorpus() / navigateToDocument() for smart navigation (checks if already there)
  • ✅ Pass current path to smart navigation functions
  • ✅ Include query params to preserve user selections

Component Architecture:

  • ✅ Route components are < 50 lines (just read state and render)
  • ✅ View components consume reactive vars for selections
  • ✅ Card components use navigation utilities
  • ✅ Modal components read corpus/document context from reactive vars

Testing:

  • ✅ Test CentralRouteManager phases independently
  • ✅ Test route components as dumb consumers (mock reactive vars)
  • ✅ Test full integration flow (URL → Manager → Component)
  • ✅ In tests, you CAN set reactive vars directly for setup

DON'T ❌

Routing (CRITICAL - These cause bugs):

  • ❌ NEVER set openedCorpus(), openedDocument(), or openedExtract() outside CentralRouteManager
  • ❌ NEVER parse URLs with useParams() in route components or views
  • ❌ NEVER fetch corpus/document/extract entities in route components
  • ❌ NEVER manually clear openedCorpus(null), openedDocument(null), or openedExtract(null) on navigation
  • ❌ NEVER add routing logic outside CentralRouteManager
  • ❌ NEVER sync reactive vars to URL manually (Phase 4 does this)

State:

  • ❌ Don't persist routing state in localStorage/sessionStorage
  • ❌ Don't assume entities are loaded (always check routeLoading and handle null)
  • ❌ Don't ignore routeError (display error state)
  • ❌ Don't set entity reactive vars from onCompleted handlers

Navigation:

  • ❌ Don't navigate without checking URL validity (check for "#" return)
  • ❌ Don't assume slugs exist (use getCorpusUrl() which handles missing slugs)
  • ❌ Don't hardcode URLs (use navigation utilities)
  • ❌ Don't drop query params (loses user selections)
  • ❌ Don't use raw IDs in URLs (they auto-redirect to slugs anyway)

Code Examples

❌ WRONG - Scattered URL parsing:

// Old approach (DELETED)
const { userIdent, corpusIdent } = useParams();
const [resolveCorpus] = useLazyQuery(RESOLVE_CORPUS);

useEffect(() => {
  resolveCorpus({ variables: { userSlug: userIdent, corpusSlug: corpusIdent } });
}, [userIdent, corpusIdent]);

✅ CORRECT - Centralized:

// Route component (simple consumer)
export const CorpusLandingRoute = () => {
  const corpus = useReactiveVar(openedCorpus);
  const loading = useReactiveVar(routeLoading);

  if (loading) return <Loading />;
  return <Corpuses />;
};

// CentralRouteManager handles everything

❌ WRONG - Directly setting reactive vars:

// This creates competing writers and breaks unidirectional flow!
selectedAnnotationIds(["123", "456"]);
showStructuralAnnotations(true);

// CentralRouteManager ALSO sets these vars from URL (Phase 2)
// Result: Race conditions, infinite loops, unpredictable behavior

✅ CORRECT - Update URL, let CentralRouteManager set vars:

import { updateAnnotationSelectionParams } from "../utils/navigationUtils";

// Update URL - CentralRouteManager Phase 2 will set reactive var
updateAnnotationSelectionParams(location, navigate, {
  annotationIds: ["123", "456"],
});

// Clean unidirectional flow:
// Component → URL → CentralRouteManager Phase 2 → Reactive Var → Component

Component Ecosystem

The routing system coordinates multiple components that work together. Understanding their roles and dependencies is critical.

Core Components

CentralRouteManager (src/routing/CentralRouteManager.tsx)

  • Role: The ONLY component that sets openedCorpus and openedDocument
  • Responsibilities: URL parsing, entity fetching, canonical redirects, query param sync
  • Dependencies: None (it's the source of truth)
  • Used by: All route components and views (indirectly via reactive vars)

CorpusLandingRoute (src/components/routes/CorpusLandingRoute.tsx)

  • Role: Dumb consumer for corpus entity routes (/c/:user/:corpus)
  • Reads: openedCorpus, routeLoading, routeError
  • Never Sets: Any reactive vars
  • Renders: <Corpuses /> view when corpus is loaded

DocumentLandingRoute (src/components/routes/DocumentLandingRoute.tsx)

  • Role: Dumb consumer for document entity routes (/d/:user/:doc)
  • Reads: openedDocument, openedCorpus, routeLoading, routeError
  • Never Sets: Any reactive vars
  • Renders: <DocumentKnowledgeBase /> when document is loaded

ExtractDetailRoute (src/components/routes/ExtractDetailRoute.tsx)

  • Role: Dumb consumer for extract entity routes (/e/:user/:extractId and /extracts/:extractId)
  • Reads: openedExtract, routeLoading, routeError
  • Never Sets: Any reactive vars
  • Renders: <ExtractDetail /> view when extract is loaded
  • Note: Extracts use IDs (not slugs) since they don't have slug fields yet. CentralRouteManager Phase 3 redirects /extracts/:id → canonical /e/:creator-slug/:id once the extract is resolved.

View Components

Corpuses (src/views/Corpuses.tsx)

  • Role: Main corpus list/detail view
  • Reads: openedCorpus, selectedAnalysesIds, selectedExtractIds
  • Never Sets: openedCorpus (CentralRouteManager owns this)
  • Updates Selections: Via utilities like updateAnnotationSelectionParams() (updates URL, CentralRouteManager Phase 2 sets vars)
  • Queries: Fetches corpus LIST data, NOT entity data by route

Documents (src/views/Documents.tsx)

  • Role: Document list view
  • Reads: openedDocument, selection vars
  • Never Sets: Entity vars
  • Queries: Document lists, not route entities

Annotations (src/views/Annotations.tsx)

  • Role: Annotation browse view
  • Reads: selectedAnnotationIds, selectedAnalysesIds
  • Never Sets: Entity vars
  • Uses: Query params set by CentralRouteManager Phase 2

Extracts (src/views/Extracts.tsx)

  • Role: Extract list/detail view
  • Reads: openedExtract (when on /e/ route), selectedExtractIds (browse context)
  • Never Sets: openedExtract (CentralRouteManager owns this)
  • Updates Selections: Via navigateToExtract() for entity routes, updateAnnotationSelectionParams() for query param selection
  • Queries: Fetches extract LIST data, NOT entity data by route

Card Components

CorpusDocumentCards (src/components/documents/CorpusDocumentCards.tsx)

  • Role: Display documents in a corpus
  • Reads: openedCorpus for context (e.g., file uploads)
  • Never Sets: openedCorpus ⚠️ This caused the infinite loop bug!
  • Navigation: Uses navigateToDocument() utility
  • Previous Bug: Was fetching corpus and setting openedCorpus() in onCompleted

DocumentItem (src/components/documents/DocumentItem.tsx)

  • Role: Individual document card
  • Reads: openedCorpus() for context
  • Never Sets: Any entity vars
  • Navigation: Uses navigateToDocument(document, corpus, navigate, path)

AnnotationCards (src/components/annotations/AnnotationCards.tsx)

  • Role: Display annotation list
  • Updates: Selection via updateAnnotationSelectionParams() (updates URL, CentralRouteManager Phase 2 sets reactive vars)
  • Reads: Context from openedCorpus/openedDocument

ExtractCards (src/components/extracts/ExtractCards.tsx)

  • Role: Display extract list
  • Updates: Selection via updateAnnotationSelectionParams() (updates URL, CentralRouteManager Phase 2 sets reactive vars)
  • Reads: Context from entity vars

AnalysesCards (src/components/analyses/AnalysesCards.tsx)

  • Role: Display analysis list
  • Updates: Selection via updateAnnotationSelectionParams() (updates URL, CentralRouteManager Phase 2 sets reactive vars)
  • Reads: Context from entity vars

Knowledge Base Components

DocumentKnowledgeBase (src/components/knowledge_base/document/DocumentKnowledgeBase.tsx)

  • Role: Main document viewer with PDF, annotations, etc.
  • Reads: openedDocument, openedCorpus for rendering
  • Never Sets: These vars ⚠️ Had 4 violations removed (lines 658, 661, 1365, 1899)
  • Receives: documentId and corpusId as props from route component
  • Previous Violations: Was setting entities in query onCompleted handlers and cleanup effects

FloatingDocumentControls (src/components/knowledge_base/document/FloatingDocumentControls.tsx)

  • Role: Toolbar for document actions (analysis, export, etc.)
  • Reads: openedCorpus() for context
  • Never Sets: Entity vars ⚠️ Had 1 violation removed (line 652)
  • Uses: Jotai atom corpusStateAtom for component-local state (this is fine)
  • Previous Violation: Was setting openedCorpus(selectedCorpus) before opening modal

NavMenu (src/components/layout/NavMenu.tsx)

  • Role: Desktop top navigation
  • Reads: openedCorpus, openedDocument (only for display, not actively used)
  • Never Sets: Entity vars ⚠️ Had violations removed
  • Navigation: Just calls navigate() - CentralRouteManager clears state
  • Previous Violation: Had clearSelections() function that set vars to null

MobileNavMenu (src/components/layout/MobileNavMenu.tsx)

  • Role: Mobile hamburger navigation
  • Reads: Entity vars (not actively used)
  • Never Sets: Entity vars ⚠️ Had violations removed
  • Navigation: Just calls navigate() like NavMenu

CorpusBreadcrumbs (src/components/corpuses/CorpusBreadcrumbs.tsx)

  • Role: Display breadcrumb navigation (Corpuses > Corpus > Document)
  • Reads: openedCorpus, openedDocument for display
  • Never Sets: Entity vars ⚠️ Had violations removed
  • Navigation: Uses navigateToCorpus() utility
  • Previous Violation: Was manually clearing vars in gotoHome()

Data Flow: How Components Work Together

1. User clicks link

2. Browser URL changes to /c/john/my-corpus?analysis=123

3. CentralRouteManager detects URL change

4. Phase 1: Parse path → Fetch corpus → Set openedCorpus(data)
   Phase 2: Parse query → Set selectedAnalysesIds(["123"])

5. Reactive var changes trigger re-renders

6. CorpusLandingRoute reads openedCorpus → Renders <Corpuses />

7. Corpuses reads openedCorpus, selectedAnalysesIds → Renders UI

8. User clicks analysis in AnalysesCards

9. Component calls: updateAnnotationSelectionParams(location, navigate, { analysisIds: ["456"] })

10. URL updates to ?analysis=456

11. Phase 2: CentralRouteManager detects URL change → Sets selectedAnalysesIds(["456"])

12. Reactive var change triggers re-render

13. Cycle continues...

Common Patterns

Pattern 1: Card Component Navigating

// In DocumentItem.tsx, AnnotationCard.tsx, etc.
const handleClick = () => {
  const corpus = openedCorpus();  // Read for context
  navigateToDocument(document, corpus, navigate, location.pathname);
  // CentralRouteManager will detect URL change and set openedDocument()
};

Pattern 2: Card Component Updating Selection

// In AnnotationCards.tsx, ExtractCards.tsx, AnalysesCards.tsx
const handleToggleSelection = (id: string) => {
  const currentIds = useReactiveVar(selectedAnnotationIds);
  const newIds = currentIds.includes(id)
    ? currentIds.filter(existingId => existingId !== id)
    : [...currentIds, id];

  // Update URL - CentralRouteManager Phase 2 will set reactive var
  updateAnnotationSelectionParams(location, navigate, {
    annotationIds: newIds,
  });
};

Pattern 3: View Component Reading Selections

// In Annotations.tsx, Extracts.tsx, etc.
const annotation_ids = useReactiveVar(selectedAnnotationIds);
const corpus = useReactiveVar(openedCorpus);  // Context only

// Render with this data
return <AnnotationCards selectedIds={annotation_ids} />;

Pattern 4: Route Component Delegating

// In DocumentLandingRoute.tsx, CorpusLandingRoute.tsx
const document = useReactiveVar(openedDocument);
const loading = useReactiveVar(routeLoading);

if (loading) return <Loading />;
return <DocumentKnowledgeBase documentId={document.id} />;
// No fetching, no URL parsing, just read and render

Pattern 5: Navigation Without State Manipulation

// In NavMenu.tsx, CorpusBreadcrumbs.tsx, etc.
const handleNavigate = () => {
  navigate("/corpuses");
  // DON'T set openedCorpus(null) - CentralRouteManager handles this
};

Key Relationships

CentralRouteManager ← → Reactive Vars ← → All Components

  • CentralRouteManager is the ONLY writer of ALL URL-driven reactive vars
  • All other components are readers via useReactiveVar()
  • Components update state by modifying URL via utilities (CentralRouteManager Phase 2 sets vars)

Route Components → View Components

  • Route components just delegate to views after checking loading/error
  • Views don't know about routes, just consume reactive vars

View Components → Card Components

  • Views orchestrate layout and pass data
  • Cards handle interactions and navigation
  • Cards use navigation utilities, never raw URLs

CentralRouteManager → Navigation Utilities

  • CentralRouteManager uses parseRoute(), buildCanonicalPath(), etc.
  • Components use getCorpusUrl(), navigateToDocument(), etc.
  • Shared utilities ensure consistency

Key Bug Fixes & Lessons Learned

Bug #1: Infinite Loop on Document Click (Fixed)

Problem:

// CorpusDocumentCards.tsx was doing this:
const { data } = useQuery(GET_CORPUS, {
  onCompleted: (data) => {
    openedCorpus(data.corpus);  // ❌ Competing with CentralRouteManager
  }
});

Symptoms: Clicking a document caused infinite jittering, route flip-flopping between adding/removing doc ID, DocumentKnowledgeBase opening/closing infinitely.

Root Cause: Multiple components setting openedCorpus() created competing state updates.

Fix: Removed ALL openedCorpus()/openedDocument() setters except in CentralRouteManager. Components now only READ via useReactiveVar().

Problem: Pasting a document URL into a new browser tab redirected to 404, but navigating in-app worked fine.

Symptoms:

[RouteManager] ⚠️  documentInCorpusBySlugs is null
[RouteManager] ⚠️  corpusBySlugs is null
[RouteManager] Document in corpus not found

Root Cause: Race condition - CentralRouteManager tried to fetch entities BEFORE Auth0 finished initializing, causing GraphQL queries to execute without auth token (401/403 → null response → 404 redirect).

Fix: Added auth status check in Phase 1:

if (authStatus === "LOADING") {
  console.log("⏳ Waiting for auth to initialize...");
  routeLoading(true);
  return; // Don't fetch until authenticated
}

Now depends on authStatus in useEffect deps, re-processes route when auth becomes "AUTHENTICATED".

Problem: Deep linking to a structural annotation resulted in document loading but annotation not visible (structural annotations hidden by default).

Symptoms: URL has ?ann=structural-123, annotation is selected in sidebar, but not visible in PDF viewer because "Show Structural" checkbox is unchecked.

Root Cause: Visualization settings (showStructural, boundingBoxes, labels) were Jotai atoms in component-local state, not synchronized with URL.

Fix: Moved visualization settings to Apollo reactive vars, added to CentralRouteManager Phase 2 (URL → State) and Phase 4 (State → URL):

// Phase 2: Parse URL
const structural = searchParams.get("structural") === "true";
showStructuralAnnotations(structural);

// Phase 4: Sync to URL
const structural = useReactiveVar(showStructuralAnnotations);
useEffect(() => {
  const queryString = buildQueryParams({
    annotationIds,
    showStructural: structural // ✅ In URL now
  });
  navigate({ search: queryString }, { replace: true });
}, [structural]);

Now deep links include: ?ann=123&structural=true&boundingBoxes=true&labels=ALWAYS

Problem: When pasting a URL with full query params like ?ann=123&structural=true&boundingBoxes=true, the params would be stripped down to only ?selectedOnly=true during initial page load, preventing deep linking from working.

Symptoms:

  • URL starts with all params: ?ann=QW5ub3RhdGlvblR5cGU6Mw==&structural=true&selectedOnly=true&boundingBoxes=true
  • URL gets rewritten to: ?selectedOnly=true
  • Document loads but annotation is not selected/visible
  • Works fine when navigating within the app, only breaks on fresh page load/refresh

Root Cause: Classic race condition between Phase 2 and Phase 4:

  1. Phase 2 correctly parses URL params and sets reactive vars: selectedAnnotationIds(['QW5ub3RhdGlvblR5cGU6Mw=='])
  2. Phase 4 fires in the SAME render cycle before reactive vars propagate
  3. Phase 4 reads stale/default reactive var values (selectedAnnotationIds = [])
  4. Phase 4 syncs stale values back to URL, stripping the params: ?selectedOnly=true
  5. Phase 2 runs again, reading the now-stripped URL

Logs showing the bug:

Phase 2: annIds: ["QW5ub3RhdGlvblR5cGU6Mw=="]  ✅ Correct from URL
Phase 4: annIds: []                             ❌ Stale value!
Phase 4: Syncing → URL: ?selectedOnly=true      💥 Strips params!
Phase 2: annIds: []                             😢 Reads stripped URL

Fix: Added routeLoading() guard in Phase 4 to prevent URL sync during entity resolution:

useEffect(() => {
  // Don't sync on initial mount
  if (!hasInitializedFromUrl.current) return;

  // CRITICAL: Don't sync while route is loading!
  // Prevents race condition where Phase 4 reads stale reactive vars before Phase 2 updates them
  if (routeLoading()) return;

  // Safe to sync now - reactive vars are up to date
  const queryString = buildQueryParams({ annotationIds: annIds, ... });
  navigate({ search: queryString }, { replace: true });
}, [annIds, ...]);

Now Phase 4 waits until routeLoading = false (after Phase 1 entity resolution completes), ensuring reactive vars have stabilized before syncing to URL.

Bug #5: Infinite Rendering Loop from Apollo Cache Conflicts (Fixed)

Problem: After selecting entities (corpus, annotations, etc.), the application entered infinite rendering loops, repeatedly firing GraphQL queries and "hammering" the server every render cycle.

Symptoms:

  • Components continued re-rendering infinitely despite no data changes
  • Apollo DevTools showed continuous cache updates
  • Apollo console errors: "DocumentTypeConnection... either ensure all objects of type DocumentTypeConnection have an ID or a custom merge function"
  • Multiple queries firing on every render cycle

Root Cause: Apollo cache conflicts with Relay-style connection types (*Connection fields like DocumentTypeConnection, AnnotationTypeConnection, etc.). Apollo couldn't properly merge these connections because they lacked proper cache configuration. Without merge functions, Apollo created new object references on every query, triggering cache updates, causing components to re-render, which triggered new queries → INFINITE LOOP.

Even when components used deep equality checks or memo optimizations, the Apollo cache itself was creating new references, bypassing these optimizations and forcing re-renders.

Fix: Added comprehensive relayStylePagination() field policies for ALL connection fields across ALL entity types in src/graphql/cache.ts:

CorpusType: {
  fields: {
    // CRITICAL: Handle all Connection types properly to prevent infinite loops
    // Without these, Apollo creates new object references on every query,
    // triggering cache updates and infinite re-renders
    documents: relayStylePagination(),
    assignmentSet: relayStylePagination(),
    relationshipSet: relayStylePagination(),
    annotations: relayStylePagination(),
    analyses: relayStylePagination(),
    conversations: relayStylePagination(),
  },
},
DocumentType: {
  fields: {
    assignmentSet: relayStylePagination(),
    corpusSet: relayStylePagination(),
    annotationSet: relayStylePagination(),
    docLabelAnnotations: relayStylePagination(),
    metadataAnnotations: relayStylePagination(),
    conversations: relayStylePagination(),
    chatMessages: relayStylePagination(),
  },
},
AnalysisType: {
  fields: {
    analyzedDocuments: relayStylePagination(),
    annotations: relayStylePagination(),
  },
},
ServerAnnotationType: {
  fields: {
    created_by_analyses: relayStylePagination(),
    assignmentSet: relayStylePagination(),
    sourceNodeInRelationships: relayStylePagination(),
    targetNodeInRelationships: relayStylePagination(),
    chatMessages: relayStylePagination(),
    createdByChatMessage: relayStylePagination(),
  },
},
// ... and many more entity types

Impact: This comprehensive fix resolved infinite loops across the entire application. Also required updating test wrappers to include the same cache configuration.

Lesson: Always configure Apollo cache type policies for Relay-style pagination connections (Connection types with edges, pageInfo, etc.). Without proper merge functions, Apollo creates new object references on every query, breaking React's referential equality checks and causing infinite re-renders. This is especially critical when components use deep equality or memo optimizations that depend on stable object references. When adding new GraphQL entity types, ALWAYS add relayStylePagination() for all connection fields.

Related Issue: In Annotations.tsx, there was also a query/type mismatch where the component declared types for GET_CORPUS_LABELSET_AND_LABELS but was actually calling GET_CORPUSES. This caused Apollo cache confusion and contributed to infinite loops. The fix was to use the correct query that matches the declared types and pass the proper corpusId variable instead of the wrong annotation_variables object.

Problem: When navigating to a deep link with query parameters (e.g., /d/user/corpus/doc?ann=123&structural=true), DocumentKnowledgeBase was rendering 15+ times before displaying content, causing poor perceived performance and unnecessary React reconciliation work.

Symptoms:

[DocumentKnowledgeBase] 🔄 Render triggered  (x15 within 100ms)
[DocumentKnowledgeBase] 🔄 Render triggered  (x15 within 100ms)
// ... 13 more identical renders with same state

Root Cause: CentralRouteManager Phase 2 was setting reactive vars sequentially, not batched:

// Each setter triggers re-render for all useReactiveVar() subscribers
if (!arraysEqual(currentAnnIds, annIds)) {
  selectedAnnotationIds(annIds);  // Re-render #1
}
if (currentStructural !== structural) {
  showStructuralAnnotations(structural);  // Re-render #2
}
if (currentSelectedOnly !== selectedOnly) {
  showSelectedAnnotationOnly(selectedOnly);  // Re-render #3
}
// ... 4 more reactive var updates = 7+ cascading re-renders

Since DocumentKnowledgeBase subscribes to 5+ reactive vars via useReactiveVar(), each sequential update caused a new render cycle. With 7 reactive var updates, this created a cascade of 7+ re-renders before any content loaded.

Fix: Batch all reactive var updates using React's unstable_batchedUpdates API:

import { unstable_batchedUpdates } from "react-dom";

// PHASE 2: Collect all updates
const updates: Array<() => void> = [];

if (!arraysEqual(currentAnnIds, annIds)) {
  updates.push(() => selectedAnnotationIds(annIds));
}
if (currentStructural !== structural) {
  updates.push(() => showStructuralAnnotations(structural));
}
// ... collect all 7 updates

// Execute all updates in a single React tick
if (updates.length > 0) {
  console.log(`[RouteManager] Phase 2: Batching ${updates.length} reactive var updates`);
  unstable_batchedUpdates(() => {
    updates.forEach(update => update());
  });
}

Impact (Phase 1 - CentralRouteManager):

  • ✅ Before: 15+ renders during initial load (60-100ms of unnecessary reconciliation)
  • ✅ After Phase 1: Still 15 renders (batching worked but GraphQL onCompleted was still causing cascades)
  • Diagnosis: Additional investigation revealed a second source of cascading re-renders

Second Root Cause (Phase 2): GraphQL query onCompleted callbacks in DocumentKnowledgeBase were calling 15+ Jotai atom setters sequentially:

onCompleted: (data) => {
  setDocumentType(data.document.fileType);     // Re-render #1
  setDocument(processedDocData);               // Re-render #2
  setPermissions(getPermissions(...));         // Re-render #3
  processAnnotationsData(data);                // Calls 5+ more setters
  // Then in Promise chain:
  setPdfDoc(pdfDocProxy);                      // Re-render #9
  setPages(loadedPages);                       // Re-render #10
  setPageTextMaps(...);                        // Re-render #11
  setDocText(doc_text);                        // Re-render #12
  setViewState(ViewState.LOADED);              // Re-render #13
}

Each setter updated a Jotai atom that DocumentKnowledgeBase subscribed to via hooks like useDocumentType(), usePages(), etc. Since these weren't batched, each atom update triggered a separate re-render.

Fix (Phase 2): Batch all Jotai atom updates in GraphQL onCompleted callbacks:

import { unstable_batchedUpdates } from "react-dom";

onCompleted: (data) => {
  // Batch initial state updates
  unstable_batchedUpdates(() => {
    setDocumentType(data.document.fileType);
    setDocument(processedDocData);
    setPermissions(getPermissions(...));
    processAnnotationsData(data);  // Also batches internally
  });

  // Later in Promise chain, batch completion updates
  .then((loadedPages) => {
    unstable_batchedUpdates(() => {
      setPages(loadedPages);
      setPageTextMaps(...);
      setDocText(doc_text);
      setViewState(ViewState.LOADED);
    });
  });
}

Final Impact:

  • ✅ Before: 15 renders during initial load (100-150ms of unnecessary reconciliation)
  • ✅ After Phase 1 (CentralRouteManager batching): 15 renders (route params batched, but GraphQL still cascading)
  • ✅ After Phase 2 (onCompleted batching): 2-3 renders during initial load
  • Performance improvement: 80-85% reduction in initial render cycles
  • User experience: Significantly faster perceived loading, smooth UI transitions

Lesson: When setting multiple reactive vars or Jotai atoms (or any React state) that will trigger re-renders in the same components, always batch updates using unstable_batchedUpdates. This applies to:

  1. Route state updates (CentralRouteManager setting multiple reactive vars)
  2. GraphQL callbacks (onCompleted setting multiple Jotai atoms)
  3. Any synchronous code that updates multiple pieces of state consumed by the same component

React 18's automatic batching helps, but doesn't cover all cases (especially Apollo reactive vars and nested Promise chains). Explicit batching ensures optimal performance.

Note: Despite the unstable_ prefix, unstable_batchedUpdates is widely used in production (Redux, React Router, etc.) and is safe to use. In React 18+, automatic batching is enabled by default, but explicit batching is still useful for Apollo reactive vars which exist outside React's normal state system.

Architecture Lessons

  1. One Source of Truth Prevents Chaos: Scattered state management creates race conditions and infinite loops. Centralization eliminates 90% of routing bugs.

  2. Auth Must Gate Protected Requests: Always check authStatusVar before making authenticated GraphQL queries on routes that support deep linking.

  3. URL Should Encode Complete State: If a user can change a setting, it should be in the URL for:

    • Deep linking (share exact view)
    • Browser back/forward (preserves state)
    • Bookmarks (save complete context)
  4. Replace useEffect Cleanup with URL Changes: Instead of manually clearing state in useEffect cleanup, let CentralRouteManager clear when URL changes. Simpler and more predictable.

  5. Components Should Be Dumb Consumers: If a component has URL parsing or GraphQL entity fetching, it's doing too much. Move logic to CentralRouteManager.

  6. Guard Bidirectional Sync During Loading: When implementing bidirectional URL ↔ State sync, always check routeLoading() in the State → URL direction to prevent race conditions where stale reactive var values overwrite URL params during initial load.

  7. Batch Reactive Var Updates: When setting multiple Apollo reactive vars (or any state that triggers re-renders), use unstable_batchedUpdates to prevent cascading re-render storms. This is especially critical in centralized state managers like CentralRouteManager that update many vars at once during route transitions.

Summary

The OpenContracts routing system follows the principle: One Place to Rule Them All.

The Core Convention

ONLY CentralRouteManager sets URL-driven reactive variables.

This includes:

  • Entity state: openedCorpus, openedDocument
  • Selection state: selectedAnnotationIds, selectedAnalysesIds, selectedExtractIds
  • UI state: selectedTab, selectedFolderId, selectedThreadId, selectedMessageId, corpusHomeView, tocExpandAll
  • Document versioning state: selectedDocVersion (from ?v=N URL parameter)
  • Visualization state: showStructuralAnnotations, showSelectedAnnotationOnly, showAnnotationBoundingBoxes, showAnnotationLabels

All other components:

  • READ ONLY via useReactiveVar()
  • UPDATE STATE via URL utilities: updateAnnotationSelectionParams(), updateAnnotationDisplayParams(), updateTabParam(), updateHomeViewParam(), updateTocExpandedParam()

Violations cause race conditions, infinite loops, and unpredictable behavior.

Architecture Overview

CentralRouteManager (src/routing/CentralRouteManager.tsx):

  • 🎯 Single source of truth for ALL routing logic
  • 📍 4-phase architecture: Parse → Query → Redirect → Sync
  • 🔄 Bidirectional URL ↔ State synchronization
  • 🚀 Automatic canonical redirects (ID → slug)
  • ⚡ Request deduplication and caching
  • 🎭 Enables route components to be ~40 lines (was ~180)

Route Components (CorpusLandingRoute, DocumentLandingRoute):

  • Read reactive vars with useReactiveVar()
  • Handle loading/error states
  • Delegate to view components
  • NEVER fetch entities or parse URLs

View Components (Corpuses, Documents, Annotations, Extracts):

  • Consume reactive vars for entity context
  • Update selections via URL utilities (CentralRouteManager Phase 2 sets reactive vars)
  • Fetch list data (not route entities)
  • Use navigation utilities for links

Card Components (DocumentItem, AnnotationCards, etc.):

  • Use getCorpusUrl() / getDocumentUrl() for links
  • Use navigateToCorpus() / navigateToDocument() for smart navigation
  • Read entity vars for context only
  • NEVER set entity vars

Key Benefits

  • Simple: Predictable unidirectional flow (URL → Manager → Vars → UI)
  • 🧪 Testable: Phases isolated, components are dumb consumers
  • 🔧 Maintainable: One place to change routing logic
  • 🐛 Reliable: No circular dependencies, race conditions, or competing updates
  • 📚 Clear: Explicit architecture with documented patterns
  • 🚀 Performant: Request deduplication, caching, smart navigation

Critical Rules (Remember These!)

  1. NEVER set openedCorpus(), openedDocument(), or openedExtract() outside CentralRouteManager
  2. NEVER fetch corpus/document/extract entities in route components
  3. NEVER parse URLs with useParams() in components/views
  4. NEVER manually clear entity vars on navigation
  5. NEVER directly set URL-driven reactive vars (use utilities: updateAnnotationSelectionParams(), updateAnnotationDisplayParams())
  6. ALWAYS use navigation utilities (getCorpusUrl, getDocumentUrl, getExtractUrl, navigateToCorpus, navigateToDocument, navigateToExtract)
  7. ALWAYS check routeLoading before accessing entities
  8. ALWAYS preserve query params when navigating (includes both selection AND visualization params)
  9. ALWAYS check authStatusVar before fetching protected entities in new routing code
  10. ALWAYS add relayStylePagination() for all connection fields when adding new GraphQL entity types to Apollo cache

When You Need To...

Add a new entity type route:

  1. Add GraphQL resolver query to queries.ts
  2. Add parsing logic to parseRoute() in navigationUtils.ts
  3. Add fetch logic to CentralRouteManager Phase 1
  4. Add reactive var to cache.ts
  5. Create dumb route component that reads the var
  6. Add route to App.tsx

Add a new URL-driven state parameter:

  1. Add reactive var to cache.ts: export const myNewSetting = makeVar<boolean>(false);
  2. Import in CentralRouteManager and add to Phase 2 parsing:
    const myNewValue = searchParams.get("myParam") === "true";
    myNewSetting(myNewValue);
    
  3. Add to Phase 4 syncing:
    const myNewValue = useReactiveVar(myNewSetting);
    useEffect(() => {
      const queryString = buildQueryParams({
        // ... existing params
        myNewParam: myNewValue
      });
    }, [...deps, myNewValue]);
    
  4. Update QueryParams interface in navigationUtils.ts:
    export interface QueryParams {
      // ... existing
      myNewParam?: boolean;
    }
    
  5. Update buildQueryParams() to include the new param:
    if (params.myNewParam) {
      searchParams.set("myParam", "true");
    }
    
  6. Add utility function to navigationUtils.ts or extend existing utilities:
    export function updateMyNewSetting(
      location: { search: string },
      navigate: (to: { search: string }, options?: { replace?: boolean }) => void,
      value: boolean
    ) {
      const searchParams = new URLSearchParams(location.search);
      if (value) {
        searchParams.set("myParam", "true");
      } else {
        searchParams.delete("myParam");
      }
      navigate({ search: searchParams.toString() }, { replace: true });
    }
    
  7. Update components to use reactive var via utility:
    // Replace Jotai atom or useState
    const myValue = useReactiveVar(myNewSetting);
    
    // Update via utility (not direct setting!)
    const setMyValue = (val: boolean) => {
      updateMyNewSetting(location, navigate, val);
      // CentralRouteManager Phase 2 will set myNewSetting(val)
    };
    

Debug a routing issue:

  1. Check CentralRouteManager console logs
  2. Verify reactive vars are set correctly
  3. Confirm component is reading vars with useReactiveVar()
  4. Ensure no components are directly SETTING URL-driven reactive vars (use grep)
  5. Check that navigation utilities are being used (updateAnnotationSelectionParams, etc.)
  6. Verify components update URL instead of reactive vars

The Golden Rule

When in doubt, read the code in CentralRouteManager.tsx.

It's the single source of truth. If routing behavior seems wrong, the fix goes there. If a component needs routing data, it reads reactive vars. If you're writing openedCorpus(...), openedDocument(...), openedExtract(...), selectedAnnotationIds(...), or showStructuralAnnotations(...) outside CentralRouteManager, you're doing it wrong - use the navigation utilities instead.

Extract Routing: Both the canonical extract route (/e/:userIdent/:extractIdent) and the legacy ID-only route (/extracts/:extractId) render ExtractDetailRoute (the dumb consumer). CentralRouteManager Phase 1 resolves the extract by ID for either path, and Phase 3 then redirects /extracts/:id/e/:creator-slug/:id whenever the resolved extract has a creator slug. The previous ExtractLandingRoute redirect component was removed because it caused an infinite loop — Phase 3 was redirecting in one direction while the landing route was redirecting in the other.


This architecture doesn't suck because it's simple, testable, and maintainable. Keep it that way.


Last Updated: 2026-01-09