Skip to main content

ADR-015: URL-Driven Navigation State

Status: Proposed Date: 2026-03-31 Author: Atlas Architecture Depends on: ADR-010 (Ontology Lifecycle), ADR-013 (Analyst Interaction Layer) Impacts: All Studio pages, MappingDesigner, AppShell navigation


Table of Contents

  1. Context & Problem Statement
  2. Decision Summary
  3. Current State Inventory
  4. URL Schema
  5. Implementation Strategy
  6. Hook: useStudioParams
  7. Migration Path
  8. Rejected Alternatives
  9. Open Questions

1. Context & Problem Statement

All Compliance Studio pages currently store navigation-critical state (selected schema line, selected version, active tab, selected evaluation run) in React useState or Zustand stores. None of it reaches the URL.

This causes five concrete problems:

  1. Refresh destroys context. A compliance officer designing a schema hits F5 and loses their selected line and version. The page resets to the first line with no version selected.

  2. No deep-linking. A senior compliance officer cannot send a colleague a URL pointing to a specific schema version under review. They have to say "go to Schema Designer, select the eba_standard line, pick version 3."

  3. Browser back/forward is broken. Switching from one schema line to another, then pressing back, navigates away from the page entirely instead of returning to the previous line.

  4. No audit trail of navigation. Browser history shows a flat list of /studio/mappings entries with no way to distinguish which schema was being viewed.

  5. Tabs reset on re-entry. Navigating away from Evaluations and back always lands on "Schema Runs" even if the officer was working in "Scoring Runs."

The root cause is that the Studio pages were built as single-route SPA panels where all selection logic lives in component state. The rest of the app (Investigations, Companies, Workflows) already uses URL params correctly (/investigations/:id, /companies/:companyId, /builder/:schemaId).


2. Decision Summary

Migrate all navigation-critical state in Studio pages from React component state to URL search params using react-router's useSearchParams. Keep edit-session state (dirty flags, unsaved mappings, drag state) in Zustand/component state where it belongs.

The split rule is simple: if losing it on refresh is a bug, it goes in the URL. If losing it on refresh is expected (unsaved draft), it stays in memory.


3. Current State Inventory

MappingDesigner.tsx (Schema Designer)

StateCurrent StorageShould Be URL?Param Name
selectedLineIduseStateYesline
selectedVersionIduseStateYesversion
userClearedSelectionuseStateNo--
configField / configOpenuseStateNo--
activeDragItemuseStateNo--
creatingDraftuseStateNo--

StudioMappings.tsx (Shell)

StateCurrent StorageShould Be URL?Param Name
selectedTab ('mappings' / 'versions')useStateYestab

StudioEvaluations.tsx

StateCurrent StorageShould Be URL?Param Name
activeTab ('schema-runs' / 'scoring-runs')useStateYestab
selectedRunuseStateYes (by ID)run
selectedSchemaIduseStateYesschema
selectedVersionIduseStateYesversion

StudioWorkflows.tsx

StateCurrent StorageShould Be URL?Param Name
activeTab ('schemas' / 'templates')useStateYestab

StudioSources.tsx

StateCurrent StorageShould Be URL?Param Name
activeTab ('library' / 'schemas' / 'health')useStateYestab
expandedProvideruseStateOptionalsource

4. URL Schema

All state is encoded as search params on the existing routes. No new routes are needed.

/studio/mappings?line={lineId}&version={versionId}
/studio/evaluations?tab=schema-runs&schema={schemaId}&version={versionId}&run={runId}
/studio/evaluations?tab=scoring-runs
/studio/workflows?tab=schemas
/studio/workflows?tab=templates
/studio/sources?source={sourceId}

Note: /studio/mappings no longer has a tab param. Version history is integrated inline in the Schema Designer (no separate tab).

Why search params, not path segments

Path segments (/studio/mappings/:lineId/:versionId) would require new route definitions for every combination and make optional params awkward. Search params are additive, optional by default, and don't require route changes. They also compose naturally: adding a new param later doesn't break existing URLs.

Param encoding rules

  • UUIDs are passed as-is (no encoding needed).
  • Tab values are lowercase kebab-case strings matching the Blueprint Tabs panel IDs.
  • Missing params fall back to current auto-selection behavior (first line, latest version, first tab).

5. Implementation Strategy

5.1 Replace useState with useSearchParams

Before:

const [selectedLineId, setSelectedLineId] = useState<string | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);

const handleLineChange = (lineId: string) => {
setSelectedLineId(lineId);
setSelectedVersionId(null);
};

After:

const [searchParams, setSearchParams] = useSearchParams();
const selectedLineId = searchParams.get('line');
const selectedVersionId = searchParams.get('version');

const handleLineChange = (lineId: string) => {
setSearchParams(prev => {
prev.set('line', lineId);
prev.delete('version'); // reset version when line changes
return prev;
}, { replace: false }); // push to history
};

5.2 Auto-selection writes to URL

The current auto-selection logic (pick first line on mount, pick latest draft version when versions load) should write its result to the URL via setSearchParams with { replace: true } so it doesn't pollute browser history.

// Auto-select first line (replace, don't push)
useEffect(() => {
if (linesQuery.data?.length && !selectedLineId) {
setSearchParams(prev => {
prev.set('line', linesQuery.data[0].id);
return prev;
}, { replace: true });
}
}, [linesQuery.data]);

5.3 Tab sync with Blueprint Tabs

const activeTab = searchParams.get('tab') ?? 'mappings';

<Tabs
selectedTabId={activeTab}
onChange={(newTab: string) => {
setSearchParams(prev => {
prev.set('tab', newTab);
return prev;
}, { replace: false });
}}
>

5.4 History behavior

ActionHistory MethodRationale
User clicks a schema linepushBack should return to previous line
User selects a versionpushBack should return to previous version
User switches tabpushBack should return to previous tab
Auto-selection on mountreplaceDon't pollute history with defaults
Clearing version on line changepart of the line pushSingle history entry

6. Hook: useStudioParams

To avoid duplicating useSearchParams boilerplate across five pages, extract a shared hook:

// hooks/useStudioParams.ts

import { useSearchParams } from 'react-router-dom';
import { useCallback } from 'react';

interface StudioParams {
tab: string | null;
line: string | null;
version: string | null;
run: string | null;
schema: string | null;
source: string | null;
}

export function useStudioParams(defaults?: Partial<StudioParams>) {
const [searchParams, setSearchParams] = useSearchParams();

const params: StudioParams = {
tab: searchParams.get('tab') ?? defaults?.tab ?? null,
line: searchParams.get('line') ?? null,
version: searchParams.get('version') ?? null,
run: searchParams.get('run') ?? null,
schema: searchParams.get('schema') ?? null,
source: searchParams.get('source') ?? null,
};

const setParam = useCallback(
(key: keyof StudioParams, value: string | null, replace = false) => {
setSearchParams(prev => {
if (value === null) prev.delete(key);
else prev.set(key, value);
return prev;
}, { replace });
},
[setSearchParams],
);

const setParams = useCallback(
(updates: Partial<StudioParams>, replace = false) => {
setSearchParams(prev => {
for (const [key, value] of Object.entries(updates)) {
if (value === null || value === undefined) prev.delete(key);
else prev.set(key, value);
}
return prev;
}, { replace });
},
[setSearchParams],
);

return { params, setParam, setParams };
}

Usage in MappingDesigner:

const { params, setParams } = useStudioParams({ tab: 'mappings' });

// Read
const selectedLineId = params.line;
const selectedVersionId = params.version;

// Write (user selects a line)
setParams({ line: lineId, version: null });

// Write (auto-select, don't pollute history)
setParams({ line: firstLineId }, true);

7. Migration Path

Phase 1: Schema Designer (highest value)

  1. Create useStudioParams hook.
  2. Refactor StudioMappings.tsx to read/write tab from URL.
  3. Refactor MappingDesigner.tsx to read/write line and version from URL. Remove selectedLineId and selectedVersionId from useState.
  4. Update auto-selection effects to use replace: true.
  5. Verify: refresh preserves selection, back button works between lines/versions, deep-link URLs resolve correctly.

Phase 2: Evaluations

  1. Refactor StudioEvaluations.tsx to read/write tab, schema, version, run from URL.

Phase 3: Workflows, Sources

  1. Refactor StudioWorkflows.tsx tab state.
  2. Refactor StudioSources.tsx tab and optionally source state.

Phase 4: Sidebar active state

The AppShell sidebar currently highlights based on location.pathname prefix matching. With search params, this continues to work without changes since the path stays the same.

Estimated effort

Each phase is 1-2 hours of refactoring per page. The hook is shared, so Phase 1 carries the setup cost. Total: ~1 day.


8. Rejected Alternatives

Path segments instead of search params

/studio/mappings/:lineId/:versionId would make URLs more "RESTful" but creates problems: optional segments require multiple route definitions, tab state doesn't map to path segments naturally, and adding new params later requires route changes. Search params are the standard pattern for filter/selection state in SPAs.

Zustand with URL sync middleware

Libraries like zustand-querystring can sync store slices to the URL. Rejected because it adds a dependency for something useSearchParams handles natively, and it blurs the boundary between navigation state (URL) and edit state (store). Keeping them in separate mechanisms makes the split explicit.

Session storage

Persisting selection to sessionStorage would survive refresh but not provide deep-linking, back-button support, or shareability. It solves one problem out of five.

A "copy link" button that manually constructs a URL with the current selection would provide deep-linking without refactoring. But it doesn't fix refresh, back-button, or tab reset. It's a band-aid.


9. Open Questions

  1. Should line/version selection also be reflected in the page <title>? E.g., "Schema Designer - eba_standard v3 | TrustRelay Atlas". This helps with browser tab identification when multiple tabs are open.

  2. Should we add a "Copy link" button to the Schema Designer toolbar? Even with URL-driven state, an explicit copy action improves discoverability for users who don't think to check the address bar.

  3. Should the Evaluations page deep-link include enough state to re-run an evaluation? Currently run points to a past run's results. A "re-run with same params" link would need schema + version + testSuite in the URL.