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
- Context & Problem Statement
- Decision Summary
- Current State Inventory
- URL Schema
- Implementation Strategy
- Hook: useStudioParams
- Migration Path
- Rejected Alternatives
- 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:
-
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.
-
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."
-
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.
-
No audit trail of navigation. Browser history shows a flat list of
/studio/mappingsentries with no way to distinguish which schema was being viewed. -
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)
| State | Current Storage | Should Be URL? | Param Name |
|---|---|---|---|
selectedLineId | useState | Yes | line |
selectedVersionId | useState | Yes | version |
userClearedSelection | useState | No | -- |
configField / configOpen | useState | No | -- |
activeDragItem | useState | No | -- |
creatingDraft | useState | No | -- |
StudioMappings.tsx (Shell)
| State | Current Storage | Should Be URL? | Param Name |
|---|---|---|---|
selectedTab ('mappings' / 'versions') | useState | Yes | tab |
StudioEvaluations.tsx
| State | Current Storage | Should Be URL? | Param Name |
|---|---|---|---|
activeTab ('schema-runs' / 'scoring-runs') | useState | Yes | tab |
selectedRun | useState | Yes (by ID) | run |
selectedSchemaId | useState | Yes | schema |
selectedVersionId | useState | Yes | version |
StudioWorkflows.tsx
| State | Current Storage | Should Be URL? | Param Name |
|---|---|---|---|
activeTab ('schemas' / 'templates') | useState | Yes | tab |
StudioSources.tsx
| State | Current Storage | Should Be URL? | Param Name |
|---|---|---|---|
activeTab ('library' / 'schemas' / 'health') | useState | Yes | tab |
expandedProvider | useState | Optional | source |
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
Tabspanel 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
| Action | History Method | Rationale |
|---|---|---|
| User clicks a schema line | push | Back should return to previous line |
| User selects a version | push | Back should return to previous version |
| User switches tab | push | Back should return to previous tab |
| Auto-selection on mount | replace | Don't pollute history with defaults |
| Clearing version on line change | part of the line push | Single 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)
- Create
useStudioParamshook. - Refactor
StudioMappings.tsxto read/writetabfrom URL. - Refactor
MappingDesigner.tsxto read/writelineandversionfrom URL. RemoveselectedLineIdandselectedVersionIdfromuseState. - Update auto-selection effects to use
replace: true. - Verify: refresh preserves selection, back button works between lines/versions, deep-link URLs resolve correctly.
Phase 2: Evaluations
- Refactor
StudioEvaluations.tsxto read/writetab,schema,version,runfrom URL.
Phase 3: Workflows, Sources
- Refactor
StudioWorkflows.tsxtab state. - Refactor
StudioSources.tsxtab and optionallysourcestate.
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.
Keep as-is, add a "copy link" button
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
-
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. -
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.
-
Should the Evaluations page deep-link include enough state to re-run an evaluation? Currently
runpoints to a past run's results. A "re-run with same params" link would needschema+version+testSuitein the URL.