Deep-Linking Architecture¶
This document describes OpenContracts' deep-linking conventions, implementation patterns, and known gaps.
Table of Contents¶
- Overview
- URL Structure
- Entity Routes
- Query Parameters
- Implementation Architecture
- Deep-Link Examples
- Known Gaps & Limitations
- Adding New Deep-Link Parameters
- Testing Deep-Links
Overview¶
Deep-linking in OpenContracts allows users to share URLs that restore the complete application state, including:
- Entity context: Which corpus, document, extract, or thread is open
- Selection state: Which annotations, analyses, or extracts are selected
- Visualization settings: How annotations are displayed (labels, bounding boxes, etc.)
- UI state: Which sidebar panel or tab is active
Core Principles¶
- URL is Source of Truth: All shareable state lives in the URL
- Centralized Sync: Only
CentralRouteManager.tsxsets URL-driven reactive vars - Unidirectional Flow: Component → URL → CentralRouteManager → Reactive Var → Component
- Bidirectional Preservation: URL changes update state; state changes update URL
URL Structure¶
Base Patterns¶
/c/{userSlug}/{corpusSlug} # Corpus page
/c/{userSlug}/{corpusSlug}/discussions/{threadId} # Full-page thread
/d/{userSlug}/{documentSlug} # Standalone document
/d/{userSlug}/{corpusSlug}/{documentSlug} # Document in corpus context
/e/{userSlug}/{extractId} # Extract page
Query Parameter Format¶
?param1=value1¶m2=value2,value3¶m3=true
- Single values:
?thread=abc123 - Multiple values (CSV):
?ann=id1,id2,id3 - Boolean flags:
?structural=true(absence = false) - Enum values:
?labels=ALWAYS(one of predefined values)
Entity Routes¶
| Route Type | URL Pattern | CentralRouteManager Action | Reactive Vars Set |
|---|---|---|---|
| Corpus | /c/:user/:corpus | Phase 1: Fetch corpus | openedCorpus |
| Document (standalone) | /d/:user/:doc | Phase 1: Fetch document | openedDocument |
| Document (in corpus) | /d/:user/:corpus/:doc | Phase 1: Fetch both | openedCorpus, openedDocument |
| Extract | /e/:user/:extractId | Phase 1: Fetch extract | openedExtract |
| Thread (full-page) | /c/:user/:corpus/discussions/:threadId | Phase 1: Fetch thread + corpus | openedThread, openedCorpus |
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
Detection criteria: - Base64 strings (e.g., Q29ycHVzOjEyMw==) - Numeric IDs ≥4 digits (e.g., 1234, 456789) - GID format (e.g., gid://app/Corpus/123)
Query Parameters¶
Selection Parameters¶
| Parameter | Type | Example | Reactive Var | Description |
|---|---|---|---|---|
ann | CSV IDs | ?ann=id1,id2 | selectedAnnotationIds | Highlight annotations |
analysis | CSV IDs | ?analysis=id1 | selectedAnalysesIds | Filter by analyses |
extract | CSV IDs | ?extract=id1 | selectedExtractIds | Select extracts |
thread | Single ID | ?thread=abc | selectedThreadId | Open thread in sidebar |
folder | Single ID | ?folder=xyz | selectedFolderId | Filter by folder |
Visualization Parameters¶
| Parameter | Type | Values | Default | Reactive Var |
|---|---|---|---|---|
structural | Boolean | true or omit | false | showStructuralAnnotations |
selectedOnly | Boolean | true or omit | false | showSelectedAnnotationOnly |
boundingBoxes | Boolean | true or omit | false | showAnnotationBoundingBoxes |
labels | Enum | ALWAYS\|ON_HOVER\|HIDE | ON_HOVER | showAnnotationLabels |
UI State Parameters¶
| Parameter | Type | Values | Description |
|---|---|---|---|
tab | Enum | documents\|discussions\|analyses\|extracts | Active sidebar tab |
message | Single ID | ?message=msgId | Scroll to message in thread |
Implementation Architecture¶
Four-Phase Processing (CentralRouteManager.tsx)¶
URL Change
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 1: URL Path → Entity Resolution │
│ - Parse pathname (/c/user/corpus → route type) │
│ - Fetch entities via GraphQL (RESOLVE_*_BY_SLUGS) │
│ - Set: openedCorpus, openedDocument, openedExtract │
│ - Wait for auth before fetching (prevents 401 on refresh) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 2: URL Query Params → Reactive Vars │
│ - Parse all query params (?ann=, ?analysis=, etc.) │
│ - Batch update reactive vars (unstable_batchedUpdates) │
│ - Set: selectedAnnotationIds, showStructural, etc. │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 3: Entity Data → Canonical Redirects │
│ - Check if URL matches canonical slug path │
│ - Redirect /c/john/old-id → /c/john-doe/normalized-slug │
│ - Preserve query parameters during redirect │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 4: Reactive Vars → URL Sync │
│ - Watch reactive vars for changes (user selects item) │
│ - Build query string from current state │
│ - Update URL with navigate({ search }, { replace: true }) │
│ - Guards: Skip during loading, skip on initial mount │
└─────────────────────────────────────────────────────────────┘
State Flow Diagram¶
┌──────────────────┐ navigate() ┌───────────────────────┐
│ Component │ ───────────────> │ URL │
│ (ViewSettings) │ │ ?structural=true │
└──────────────────┘ └───────────────────────┘
▲ │
│ │ Phase 2
│ useReactiveVar() ▼
│ ┌───────────────────────┐
│ │ CentralRouteManager │
│ │ Sets reactive vars │
│ └───────────────────────┘
│ │
│ │ showStructuralAnnotations(true)
│ ▼
│ ┌───────────────────────┐
└──────────────────────────── │ Reactive Var │
│ showStructural=true │
└───────────────────────┘
Navigation Utilities (navigationUtils.ts)¶
Components MUST use these utilities instead of directly setting reactive vars:
// Selection updates
updateAnnotationSelectionParams(location, navigate, {
annotationIds: ["id1", "id2"],
analysisIds: ["analysis1"],
});
// Visualization updates
updateAnnotationDisplayParams(location, navigate, {
showStructural: true,
showBoundingBoxes: true,
labelDisplay: "ALWAYS",
});
// Entity navigation
navigateToDocument(document, corpus, navigate, currentPath);
navigateToCorpus(corpus, navigate, currentPath);
navigateToExtract(extract, navigate, currentPath);
navigateToCorpusThread(corpus, threadId, navigate, currentPath);
// Thread sidebar
navigateToDocumentThread(threadId, location, navigate);
clearThreadSelection(location, navigate);
// URL generation
getDocumentUrl(document, corpus, { annotationIds: ["id1"] });
getCorpusUrl(corpus, { analysisIds: ["id1"] });
getCorpusThreadUrl(corpus, threadId);
Deep-Link Examples¶
Basic Entity Links¶
# Corpus page
/c/john/legal-contracts
# Document in corpus
/d/john/legal-contracts/2024-deal
# Standalone document
/d/jane/my-document
# Extract page
/e/john/RXh0cmFjdFR5cGU6MTIz
Selection Links¶
# Document with annotations selected
/d/john/contracts/deal?ann=QW5ub3RhdGlvbjox,QW5ub3RhdGlvbjoy
# Corpus filtered by analysis
/c/john/contracts?analysis=QW5hbHlzaXM6NDU2
# Document with thread open in sidebar
/d/john/contracts/deal?thread=Q29udmVyc2F0aW9uOjEyMw
Visualization Links¶
# Show structural annotations with labels always visible
/d/john/contracts/deal?structural=true&labels=ALWAYS
# Show only selected annotations with bounding boxes
/d/john/contracts/deal?ann=id1&selectedOnly=true&boundingBoxes=true
# Full visualization state
/d/john/contracts/deal?ann=id1,id2&structural=true&boundingBoxes=true&labels=ALWAYS&selectedOnly=true
Combined Deep-Links¶
# Complete shareable state
/d/john/legal-contracts/2024-deal?ann=ann1,ann2&analysis=analysis1&structural=true&boundingBoxes=true&labels=ALWAYS&thread=thread123&folder=folder456
# Thread with message highlight
/c/john/contracts/discussions/thread123?message=msg456
Known Gaps & Limitations¶
Currently Not Deep-Linkable¶
| Feature | Current State | Impact | Priority |
|---|---|---|---|
| Tab state | Stored locally | Can't link to specific tab | P2 |
| Message in sidebar thread | Only works full-page | Can't link to message in sidebar | P2 |
| Thread sort/filter | Jotai atoms only | Preferences reset on refresh | P3 |
| Scroll position | Not tracked | Document opens at top | P4 |
| Panel widths | Local state | Layout not preserved | P4 |
Architectural Constraints¶
-
Query params for UI, not data: Query params control display state, not data filtering (that's done server-side via GraphQL)
-
No nested query params: Can't do
?thread[message]=123; use flat structure?thread=abc&message=123 -
Boolean defaults: Only
trueis encoded;falseis represented by param absence -
Enum completeness: All enum values must be handled in Phase 2 parsing
Adding New Deep-Link Parameters¶
Step-by-Step Process¶
-
Add reactive var (
frontend/src/graphql/cache.ts):export const myNewSetting = makeVar<boolean>(false); -
Add Phase 2 parsing (
CentralRouteManager.tsx):const myNewValue = searchParams.get("myParam") === "true"; updates.push(() => myNewSetting(myNewValue)); -
Add Phase 4 syncing (
CentralRouteManager.tsx):const myNewValue = useReactiveVar(myNewSetting); // In the useEffect deps and queryString builder -
Update QueryParams interface (
navigationUtils.ts):export interface QueryParams { // ... existing myNewParam?: boolean; } -
Update buildQueryParams() (
navigationUtils.ts):if (params.myNewParam) { searchParams.set("myParam", "true"); } -
Add navigation utility (
navigationUtils.ts):export function updateMyNewSetting( location: { search: string }, navigate: NavigateFunction, value: boolean ): void { const searchParams = new URLSearchParams(location.search); if (value) { searchParams.set("myParam", "true"); } else { searchParams.delete("myParam"); } navigate({ search: searchParams.toString() }, { replace: true }); } -
Update components to use the utility:
// Read const myValue = useReactiveVar(myNewSetting); // Write (via URL) updateMyNewSetting(location, navigate, true); -
Add tests (see Testing section)
-
Update documentation (
routing_system.mdand this file)
Testing Deep-Links¶
Unit Tests (navigationUtils.test.ts)¶
describe("buildQueryParams", () => {
it("should include myParam when true", () => {
const result = buildQueryParams({ myNewParam: true });
expect(result).toContain("myParam=true");
});
it("should omit myParam when false", () => {
const result = buildQueryParams({ myNewParam: false });
expect(result).not.toContain("myParam");
});
});
describe("parseRoute", () => {
it("should parse thread routes", () => {
const result = parseRoute("/c/john/corpus/discussions/thread-123");
expect(result).toEqual({
type: "thread",
userIdent: "john",
corpusIdent: "corpus",
threadIdent: "thread-123",
});
});
});
Integration Tests (CentralRouteManager.test.tsx)¶
describe("Phase 2: Query Params", () => {
it("should set reactive var from URL param", () => {
render(
<MemoryRouter initialEntries={["/documents?myParam=true"]}>
<CentralRouteManager />
</MemoryRouter>
);
expect(myNewSetting()).toBe(true);
});
});
describe("Phase 4: URL Sync", () => {
it("should update URL when reactive var changes", async () => {
render(<CentralRouteManager />);
myNewSetting(true);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
{ search: expect.stringContaining("myParam=true") },
{ replace: true }
);
});
});
});
Component Tests (Playwright)¶
test("ViewSettingsPopup updates URL on toggle", async ({ mount, page }) => {
const component = await mount(
<TestWrapper initialRoute="/d/user/doc">
<ViewSettingsPopup />
</TestWrapper>
);
// Toggle setting
await component.getByRole("checkbox", { name: "Show Bounding Boxes" }).click();
// Verify URL updated
await expect(page).toHaveURL(/boundingBoxes=true/);
});
test("deep-link restores visualization state", async ({ mount, page }) => {
const component = await mount(
<TestWrapper initialRoute="/d/user/doc?structural=true&labels=ALWAYS">
<DocumentKnowledgeBase />
</TestWrapper>
);
// Verify settings restored
await expect(component.getByTestId("structural-toggle")).toBeChecked();
await expect(component.getByTestId("labels-dropdown")).toHaveValue("ALWAYS");
});
E2E Tests¶
test("full deep-link flow", async ({ page }) => {
// Navigate to deep-link
await page.goto("/d/john/contracts/deal?ann=id1&structural=true");
// Verify state restored
await expect(page.locator("[data-annotation-id='id1']")).toHaveClass(/selected/);
await expect(page.getByTestId("structural-toggle")).toBeChecked();
// Modify state
await page.getByTestId("annotation-id2").click();
// Verify URL updated
await expect(page).toHaveURL(/ann=id1,id2/);
// Refresh and verify state persists
await page.reload();
await expect(page.locator("[data-annotation-id='id1']")).toHaveClass(/selected/);
await expect(page.locator("[data-annotation-id='id2']")).toHaveClass(/selected/);
});
Related Documentation¶
- Routing System - Complete routing architecture
- PDF Data Layer - Annotation rendering system
- Authentication Pattern - Auth-gated routing