Skip to content

Commit 6c1d1fa

Browse files
authored
Merge pull request #1383 from Open-Source-Legal/feature/centralize-extract-and-user-route-resolution
Centralize extract and user-profile route resolution
2 parents 6d5f94e + 3e0975b commit 6c1d1fa

24 files changed

Lines changed: 1044 additions & 472 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828

2929
### Changed
3030

31+
- **Centralized extract and user-profile route resolution in `CentralRouteManager`** (`frontend/src/routing/CentralRouteManager.tsx`, `frontend/src/components/routes/ExtractDetailRoute.tsx`, `frontend/src/components/routes/UserProfileRoute.tsx`, new `frontend/src/components/routes/ProfileRedirect.tsx`, `frontend/src/utils/navigationUtils.ts`, `frontend/src/graphql/cache.ts`): `ExtractDetailRoute` and `UserProfileRoute` were each calling `useParams()`, running their own GraphQL resolution queries, and writing entity reactive vars — duplicating the four-phase flow documented in `docs/frontend/routing_system.md` and racing the manager during back-navigation. Both are now thin consumers that read `openedExtract` / `openedUser` / `routeLoading` / `routeError`. `parseRoute` learned `/users/:slug` and `/extracts/:extractId`; the manager added a `GET_USER` lazy query and a Phase 1 user branch alongside the existing extract handling. New `openedUser` reactive var (typed `OpenedUserProfile`) joins the routing-owned set. `/profile` is now served by a small `ProfileRedirect` that uses `backendUserObj` to redirect to `/users/<slug>` — auth-driven, not URL-driven, so legitimately outside the manager. Smaller fixes that ride along: `views/Corpuses.tsx:1807` swaps `window.history.replaceState` for `navigate({ pathname, search }, { replace: true })` so query-param mutations stay inside React Router; redundant `openedLabelset(null)` calls in `LabelSetDetailPage` and `LabelSetLandingRoute` handlers were removed (Phase 1 already clears the var on browse navigation).
32+
- Test coverage: 8 new `parseRoute` tests, 3 new manager tests (user resolve, user not-found, extract by id), updated `beforeEach` to reset `openedExtract` / `openedUser`. New `frontend/src/routing/__tests__/centralRouteDiscipline.test.ts` is a static regression test that grep-walks `frontend/src/` and fails if any production file outside the manager and `cache.ts` SETs one of the 15 routing-owned reactive vars — caught three pre-existing `openedLabelset` writes during development that were also fixed in this PR. New `frontend/tests/e2e/user-and-extract-routes.spec.ts` deep-links `/users/<slug>`, verifies the `/profile` redirect, and exercises the dumb-consumer error path on `/extracts/<unknown-id>`. `tests/e2e/helpers.ts` `VIEWS` catalog now includes `/users/admin` so the existing login-and-navigation walk also covers the user route.
33+
- Doc updates: `docs/frontend/routing_system.md` route-pattern table now lists `/users/:slug`, `/extracts/:extractId`, and `/label_sets/:labelsetId`; the "ONLY CentralRouteManager may SET" list and the four critical RULE blocks were extended to include `openedThread`, `openedLabelset`, and `openedUser`; the new discipline test is referenced as the CI enforcement mechanism.
34+
- **Pre-existing `tsc` failure on `frontend/src/components/corpuses/caml/CamlDirectiveRenderer.tsx`** resolved by refreshing `@os-legal/caml-react` to `0.1.0` (latest on the registry). The lockfile had been pinned to `0.0.1`, whose published `dist/index.d.ts` lacks the `resolveImageSrc` prop that the consumer passes through; `0.1.0` ships the prop on `CamlArticleProps`, `CamlChapterRendererProps`, and the block-renderer prop interfaces. `yarn upgrade @os-legal/caml-react@^0.1.0 @os-legal/caml@^0.1.0` updates the lockfile; no code changes needed.
3135
- **Simplified `RelationGroup.updateForAnnotationDeletion` pruning logic** (Issue #1317, follow-up to #1314, `frontend/src/components/annotator/types/annotations.ts:40-60`): The method previously branched on four near-duplicate conditions (`sourceEmpty && nowTargetEmpty`, `targetEmpty && nowSourceEmpty`, `!sourceEmpty && nowSourceEmpty`, `!targetEmpty && nowTargetEmpty`) each returning `undefined`. All four are equivalent to a single `nowSourceEmpty || nowTargetEmpty` check (`filter` is monotonic, so an originally-empty side stays empty after filtering). Collapsed the branches and removed the now-unused `sourceEmpty` / `targetEmpty` locals per the project's DRY guideline. Behavior is unchanged; existing regression tests in `frontend/src/components/annotator/types/__tests__/annotations.test.ts` still pass unmodified, confirming the simplification is semantics-preserving.
3236
- **Frontend Vitest unit coverage provider switched from V8 to Istanbul** (`frontend/vite.config.ts:210-230`, `frontend/package.json:154`, `frontend/yarn.lock`): The merged `frontend` Codecov flag was landing at ~44% even though `frontend-component` alone was at ~61% on the same code — impossible for a union-aggregated metric unless the three per-suite uploads were measuring on different yardsticks. Root cause: Vitest's `@vitest/coverage-v8` provider emits ~183 `DA:` records per source file (~86k across ~480 files) because V8's native coverage API reports hits for nearly every executable line — imports, declarations, block-closing `}`, etc. — as needed for engine profiling and DevTools. `vite-plugin-istanbul` (used by Playwright CT and E2E suites) emits ~61 `DA:` per file (~27k total) because it only instruments statements. Same code, ~3× denominator mismatch. When Codecov unions multiple uploads under one flag it keeps V8's larger line-number set; Istanbul hits from CT/E2E land on a subset of those line numbers and can never close the gap. Swapped the unit-coverage provider to Istanbul so all three suites report on the same universe. Swapped the `@vitest/coverage-v8` devDep for `@vitest/coverage-istanbul` at the same `^3.1.2` version to match the `vitest` major.minor, changed `coverage.provider` to `"istanbul"` in the Vitest config, regenerated `yarn.lock`, and updated the `all: true` rationale comment to stop singling out V8. The per-suite `flags:` tagging in `frontend.yml` / `frontend-e2e.yml` is unchanged — server-side aggregation still handles the merge. Verified locally: the new unit lcov reports ~451 `SF:` and ~24k `DA:` (previously ~86k), matching the CT/E2E scale. Trade-off: unit-test runtime under coverage grows somewhat (Istanbul transforms source pre-execution), likely 10–30s on this suite. `tsc --noEmit` clean.
3337

docs/frontend/routing_system.md

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ The OpenContracts routing system follows a **centralized architecture** where **
3636
- `openedCorpus()`
3737
- `openedDocument()`
3838
- `openedExtract()`
39+
- `openedThread()`
40+
- `openedLabelset()`
41+
- `openedUser()`
3942

4043
**URL-Driven State (set by Phase 2, watched by Phase 4):**
4144
- `selectedAnnotationIds()`
@@ -54,15 +57,11 @@ The OpenContracts routing system follows a **centralized architecture** where **
5457
**The Correct Pattern:**
5558
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.
5659

57-
This is non-negotiable. Violations cause infinite loops, route jittering, competing state updates, and unpredictable behavior. During development, we systematically removed all violations from:
58-
- `CorpusDocumentCards.tsx` (caused infinite loop bug)
59-
- `DocumentKnowledgeBase.tsx` (4 violations)
60-
- `FloatingDocumentControls.tsx` (1 violation)
61-
- `NavMenu.tsx` / `MobileNavMenu.tsx` (clearing on menu clicks)
62-
- `CorpusBreadcrumbs.tsx` (manual clearing)
63-
- `Corpuses.tsx` (3 violations)
60+
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.
6461

65-
**If you find yourself writing `openedCorpus(someValue)`, `openedDocument(someValue)`, or `openedExtract(someValue)` anywhere except `CentralRouteManager.tsx`, STOP. You are introducing a bug.**
62+
**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.**
63+
64+
> 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.
6665
6766
### Design Decisions
6867

@@ -176,7 +175,12 @@ graph TB
176175
| `/c/:userIdent/:corpusIdent` | `/c/john/my-corpus` | CorpusLandingRoute | Phase 1: Fetch corpus |
177176
| `/d/:userIdent/:docIdent` | `/d/john/my-document` | DocumentLandingRoute | Phase 1: Fetch document |
178177
| `/d/:userIdent/:corpusIdent/:docIdent` | `/d/john/corpus/doc` | DocumentLandingRoute | Phase 1: Fetch both |
179-
| `/e/:userIdent/:extractIdent` | `/e/john/extract-123` | ExtractLandingRoute | Phase 1: Fetch extract |
178+
| `/e/:userIdent/:extractIdent` | `/e/john/extract-123` | ExtractDetailRoute | Phase 1: Fetch extract |
179+
| `/extracts/:extractId` | `/extracts/extract-123` | ExtractDetailRoute | Phase 1: Fetch extract |
180+
| `/users/:slug` | `/users/jane` | UserProfileRoute | Phase 1: Fetch user |
181+
| `/label_sets/:labelsetId` | `/label_sets/labelset-1` | LabelSetLandingRoute | Phase 1: Fetch labelset |
182+
183+
> `/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.
180184
181185
### Browse Routes (Query Params Only)
182186

@@ -1328,25 +1332,32 @@ window.DEBUG_ROUTING = true
13281332
13291333
### Critical Rules (MUST Follow)
13301334
1331-
**🚨 RULE #1: NEVER Set `openedCorpus`, `openedDocument`, or `openedExtract` Outside CentralRouteManager**
1335+
**🚨 RULE #1: NEVER Set Routing-Owned Reactive Vars Outside CentralRouteManager**
1336+
1337+
The full set: `openedCorpus`, `openedDocument`, `openedExtract`, `openedThread`, `openedLabelset`, `openedUser`.
13321338
13331339
```typescript
13341340
// ❌ NEVER DO THIS (anywhere except CentralRouteManager.tsx):
13351341
openedCorpus(someCorpus);
13361342
openedDocument(someDocument);
13371343
openedExtract(someExtract);
1344+
openedUser(someUser);
13381345
openedCorpus(null);
13391346
openedDocument(null);
13401347
openedExtract(null);
1348+
openedUser(null);
13411349

13421350
// ✅ ALWAYS DO THIS:
13431351
const corpus = useReactiveVar(openedCorpus); // Read only
13441352
const document = useReactiveVar(openedDocument); // Read only
13451353
const extract = useReactiveVar(openedExtract); // Read only
1354+
const user = useReactiveVar(openedUser); // Read only
13461355
```
13471356
13481357
**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`.
13491358
1359+
**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.
1360+
13501361
**🚨 RULE #2: NEVER Fetch Entities in Route Components**
13511362
13521363
```typescript
@@ -1387,6 +1398,7 @@ const gotoHome = () => {
13871398
openedCorpus(null); // VIOLATION!
13881399
openedDocument(null); // VIOLATION!
13891400
openedExtract(null); // VIOLATION!
1401+
openedUser(null); // VIOLATION!
13901402
navigate("/corpuses");
13911403
};
13921404

@@ -1397,7 +1409,7 @@ const gotoHome = () => {
13971409
};
13981410
```
13991411
1400-
**Why?** CentralRouteManager watches the URL and automatically clears `openedCorpus`/`openedDocument`/`openedExtract` when navigating to browse routes. Manual clearing creates race conditions.
1412+
**Why?** CentralRouteManager watches the URL and automatically clears all entity vars when navigating to a browse route. Manual clearing creates race conditions.
14011413
14021414
### DO ✅
14031415

frontend/src/App.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import { NotFound } from "./components/routes/NotFound";
8484
import { CorpusLandingRoute } from "./components/routes/CorpusLandingRoute";
8585
import { CorpusThreadRoute } from "./components/routes/CorpusThreadRoute";
8686
import { UserProfileRoute } from "./components/routes/UserProfileRoute";
87+
import { ProfileRedirect } from "./components/routes/ProfileRedirect";
8788
import { LeaderboardRoute } from "./components/routes/LeaderboardRoute";
8889
import { GlobalDiscussionsRoute } from "./components/routes/GlobalDiscussionsRoute";
8990
import { ThreadSearchRoute } from "./views/ThreadSearchRoute";
@@ -490,7 +491,7 @@ export const App = () => {
490491
<Route path="/threads" element={<ThreadSearchRoute />} />
491492

492493
{/* User Profile Routes (Issue #611) */}
493-
<Route path="/profile" element={<UserProfileRoute />} />
494+
<Route path="/profile" element={<ProfileRedirect />} />
494495
<Route path="/users/:slug" element={<UserProfileRoute />} />
495496
{/* Convenience redirect for badge notifications (Issue #637) */}
496497
<Route

frontend/src/components/labelsets/LabelSetDetailPage.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export const LabelSetDetailPage: React.FC<LabelSetDetailPageProps> = ({
259259
if (onClose) {
260260
onClose();
261261
} else {
262-
openedLabelset(null);
262+
// CentralRouteManager Phase 1 clears openedLabelset on browse routes.
263263
navigate("/label_sets");
264264
}
265265
};
@@ -453,7 +453,7 @@ export const LabelSetDetailPage: React.FC<LabelSetDetailPageProps> = ({
453453
.then((result) => {
454454
if (result.data?.deleteLabelset?.ok) {
455455
toast.success("Label set deleted successfully");
456-
openedLabelset(null);
456+
// CentralRouteManager Phase 1 clears openedLabelset on browse routes.
457457
navigate("/label_sets");
458458
} else {
459459
toast.error(

frontend/src/components/routes/ExtractDetailRoute.tsx

Lines changed: 14 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,28 @@
1-
import React, { useEffect } from "react";
2-
import { useParams } from "react-router-dom";
3-
import { useQuery, useReactiveVar } from "@apollo/client";
1+
import React from "react";
2+
import { useReactiveVar } from "@apollo/client";
43
import { MetaTags } from "../seo/MetaTags";
54
import { ModernLoadingDisplay } from "../widgets/ModernLoadingDisplay";
65
import { ModernErrorDisplay } from "../widgets/ModernErrorDisplay";
76
import { ErrorBoundary } from "../widgets/ErrorBoundary";
8-
import { openedExtract } from "../../graphql/cache";
7+
import { openedExtract, routeLoading, routeError } from "../../graphql/cache";
98
import { ExtractDetail } from "../../views/ExtractDetail";
10-
import {
11-
RESOLVE_EXTRACT_BY_ID,
12-
ResolveExtractByIdOutput,
13-
ResolveExtractByIdInput,
14-
} from "../../graphql/queries";
159

1610
/**
17-
* ExtractDetailRoute - Handles the /extracts/:extractId route
11+
* ExtractDetailRoute - Renders the extract detail view for /extracts/:extractId
12+
* and /e/:userIdent/:extractIdent.
1813
*
19-
* This is the new route-based pattern for viewing extract details,
20-
* replacing the modal-based EditExtractModal.
21-
*
22-
* Route pattern: /extracts/:extractId
14+
* URL parsing, GraphQL resolution, and reactive-var population are owned by
15+
* CentralRouteManager. This component reads the resolved state and renders.
2316
*/
2417
export const ExtractDetailRoute: React.FC = () => {
25-
const { extractId } = useParams<{ extractId: string }>();
26-
27-
// Check if we already have the extract from reactive var
28-
const existingExtract = useReactiveVar(openedExtract);
29-
30-
// Query to resolve extract by ID if not already loaded
31-
const { loading, error, data } = useQuery<
32-
ResolveExtractByIdOutput,
33-
ResolveExtractByIdInput
34-
>(RESOLVE_EXTRACT_BY_ID, {
35-
variables: { extractId: extractId ?? "" },
36-
skip: !extractId || existingExtract?.id === extractId,
37-
fetchPolicy: "network-only",
38-
});
39-
40-
// Set the opened extract when query completes
41-
useEffect(() => {
42-
if (data?.extract) {
43-
openedExtract(data.extract);
44-
}
45-
}, [data]);
18+
const extract = useReactiveVar(openedExtract);
19+
const loading = useReactiveVar(routeLoading);
20+
const error = useReactiveVar(routeError);
4621

47-
// Handle missing extractId
48-
if (!extractId) {
49-
return <ModernErrorDisplay type="extract" error="No extract ID provided" />;
50-
}
51-
52-
// Loading state
53-
if (loading && !existingExtract) {
22+
if (loading && !extract) {
5423
return <ModernLoadingDisplay type="extract" size="large" />;
5524
}
5625

57-
// Error state
5826
if (error) {
5927
return (
6028
<ModernErrorDisplay
@@ -64,20 +32,15 @@ export const ExtractDetailRoute: React.FC = () => {
6432
);
6533
}
6634

67-
// Get the extract to display
68-
const extract =
69-
existingExtract?.id === extractId ? existingExtract : data?.extract;
70-
71-
// Not found state
72-
if (!extract && !loading) {
35+
if (!extract) {
7336
return <ModernErrorDisplay type="extract" error="Extract not found" />;
7437
}
7538

7639
return (
7740
<ErrorBoundary>
7841
<MetaTags
79-
title={extract?.name || "Extract"}
80-
description={`Extract: ${extract?.name}`}
42+
title={extract.name || "Extract"}
43+
description={`Extract: ${extract.name}`}
8144
entity={extract}
8245
entityType="extract"
8346
/>

frontend/src/components/routes/LabelSetLandingRoute.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ export const LabelSetLandingRoute: React.FC = () => {
3131
hasError: !!error,
3232
});
3333

34-
// Handle close by navigating back to list
34+
// Handle close by navigating back to list. CentralRouteManager Phase 1
35+
// clears openedLabelset when the new path resolves to a browse route.
3536
const handleClose = () => {
36-
openedLabelset(null);
3737
navigate("/label_sets");
3838
};
3939

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* ProfileRedirect - Resolves /profile to the current user's canonical
3+
* /users/:slug URL.
4+
*
5+
* This is auth-state-driven (not URL-state-driven), so it lives outside
6+
* the CentralRouteManager entity-resolution path. It reads backendUserObj
7+
* and renders a React Router <Navigate> to the canonical profile URL,
8+
* which then triggers CentralRouteManager Phase 1 user resolution.
9+
*
10+
* Anonymous visitors are sent to /login.
11+
*/
12+
13+
import React from "react";
14+
import { Navigate } from "react-router-dom";
15+
import { useReactiveVar } from "@apollo/client";
16+
import {
17+
backendUserObj,
18+
authStatusVar,
19+
authInitCompleteVar,
20+
} from "../../graphql/cache";
21+
import { ModernLoadingDisplay } from "../widgets/ModernLoadingDisplay";
22+
23+
export const ProfileRedirect: React.FC = () => {
24+
const currentUser = useReactiveVar(backendUserObj);
25+
const authStatus = useReactiveVar(authStatusVar);
26+
const authInitComplete = useReactiveVar(authInitCompleteVar);
27+
28+
// While Auth0 is still resolving the session, backendUserObj is null even
29+
// for an authenticated visitor. Redirecting before this settles would
30+
// briefly send an authenticated user to /login. Hold the render until the
31+
// auth pipeline (token + cache reset) signals it's done.
32+
if (authStatus === "LOADING" || !authInitComplete) {
33+
return <ModernLoadingDisplay type="default" message="Resolving profile…" />;
34+
}
35+
36+
if (!currentUser?.slug) {
37+
return <Navigate to="/login" replace />;
38+
}
39+
40+
return <Navigate to={`/users/${currentUser.slug}`} replace />;
41+
};
42+
43+
export default ProfileRedirect;

0 commit comments

Comments
 (0)