Skip to content

Commit 295ea9b

Browse files
committed
Address PR #1383 review: dedupe key, error-path cleanup, type derivation, auth gate
- buildRequestKey: add "user" to the type union and a userSlug? parameter so /users/:slug routes get a per-slug request key. Without this, every user-profile resolution dedupes to the same "user" key and rapid profile switches silently drop all but the first request. The CentralRouteManager call site now passes route.userSlug explicitly. - User not-found path now clears the residual entity vars (openedCorpus, openedDocument, openedExtract, openedThread, openedLabelset, openedUser). The corpus/document/extract paths redirect to /404 which clears via the browse-route handler, but the user error path stays on the URL so the visitor can fix the slug — meaning cleanup is the manager's job. - OpenedUserProfile is now derived from GetUserOutput["userBySlug"] so the type tracks the GraphQL schema automatically and the unsafe `as` cast in the user-resolution branch goes away. - ProfileRedirect now waits for authStatusVar !== "LOADING" and authInitCompleteVar before redirecting. Previously a cold load of /profile by an authenticated user would briefly redirect to /login while Auth0 was still resolving. - Added two ProfileRedirect tests that lock in the auth-gate behavior. - routing_system.md: collapse the historical violations bullet list into a single sentence — Corpuses.tsx is no longer a violator and the static centralRouteDiscipline test now enforces zero violations.
1 parent 07cb44f commit 295ea9b

6 files changed

Lines changed: 88 additions & 36 deletions

File tree

docs/frontend/routing_system.md

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,7 @@ The OpenContracts routing system follows a **centralized architecture** where **
5757
**The Correct Pattern:**
5858
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.
5959

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

6862
**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.**
6963

frontend/src/components/routes/ProfileRedirect.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,25 @@
1313
import React from "react";
1414
import { Navigate } from "react-router-dom";
1515
import { useReactiveVar } from "@apollo/client";
16-
import { backendUserObj } from "../../graphql/cache";
16+
import {
17+
backendUserObj,
18+
authStatusVar,
19+
authInitCompleteVar,
20+
} from "../../graphql/cache";
21+
import { ModernLoadingDisplay } from "../widgets/ModernLoadingDisplay";
1722

1823
export const ProfileRedirect: React.FC = () => {
1924
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+
}
2035

2136
if (!currentUser?.slug) {
2237
return <Navigate to="/login" replace />;

frontend/src/components/routes/__tests__/ProfileRedirect.test.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,23 @@ import { MockedProvider } from "@apollo/client/testing";
33
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
44
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
55
import { ProfileRedirect } from "../ProfileRedirect";
6-
import { backendUserObj } from "../../../graphql/cache";
6+
import {
7+
backendUserObj,
8+
authStatusVar,
9+
authInitCompleteVar,
10+
} from "../../../graphql/cache";
11+
12+
vi.mock("../../widgets/ModernLoadingDisplay", () => ({
13+
ModernLoadingDisplay: () => <div>Loading...</div>,
14+
}));
715

816
/**
917
* Tests for ProfileRedirect.
1018
*
1119
* /profile is auth-state-driven, not URL-state-driven, so the redirect lives
12-
* outside CentralRouteManager. ProfileRedirect reads backendUserObj and
13-
* issues a Navigate to /login (anonymous) or /users/<slug> (logged in).
20+
* outside CentralRouteManager. ProfileRedirect waits for the auth pipeline
21+
* to complete, then issues a Navigate to /login (anonymous) or
22+
* /users/<slug> (logged in).
1423
*/
1524
describe("ProfileRedirect", () => {
1625
const LocationReporter: React.FC = () => {
@@ -33,11 +42,15 @@ describe("ProfileRedirect", () => {
3342

3443
beforeEach(() => {
3544
backendUserObj(null);
45+
authStatusVar("ANONYMOUS");
46+
authInitCompleteVar(true);
3647
});
3748

3849
afterEach(() => {
3950
vi.clearAllMocks();
4051
backendUserObj(null);
52+
authStatusVar("LOADING");
53+
authInitCompleteVar(false);
4154
});
4255

4356
it("redirects to /login when no user is authenticated", async () => {
@@ -47,16 +60,35 @@ describe("ProfileRedirect", () => {
4760
});
4861

4962
it("redirects to /users/<slug> when a user is authenticated", async () => {
63+
authStatusVar("AUTHENTICATED");
5064
backendUserObj({ id: "u-1", slug: "alice" } as any);
5165
renderAt("/profile");
5266
const loc = await screen.findByTestId("location");
5367
expect(loc.textContent).toBe("/users/alice");
5468
});
5569

5670
it("redirects to /login when the authenticated user has no slug", async () => {
71+
authStatusVar("AUTHENTICATED");
5772
backendUserObj({ id: "u-1" } as any);
5873
renderAt("/profile");
5974
const loc = await screen.findByTestId("location");
6075
expect(loc.textContent).toBe("/login");
6176
});
77+
78+
it("renders the loading display while auth status is LOADING (no premature /login flash)", () => {
79+
authStatusVar("LOADING");
80+
authInitCompleteVar(false);
81+
renderAt("/profile");
82+
expect(screen.getByText("Loading...")).toBeInTheDocument();
83+
expect(screen.queryByTestId("location")).toBeNull();
84+
});
85+
86+
it("renders the loading display while auth init is incomplete even after token resolves", () => {
87+
authStatusVar("AUTHENTICATED");
88+
authInitCompleteVar(false);
89+
backendUserObj(null);
90+
renderAt("/profile");
91+
expect(screen.getByText("Loading...")).toBeInTheDocument();
92+
expect(screen.queryByTestId("location")).toBeNull();
93+
});
6294
});

frontend/src/graphql/cache.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "../types/graphql-api";
2424
import { ViewState } from "../components/types";
2525
import { FileUploadPackageProps } from "../components/widgets/modals/DocumentUploadModal";
26+
import type { GetUserOutput } from "./queries";
2627

2728
export const mergeArrayByIdFieldPolicy: FieldPolicy<Reference[]> = {
2829
// eslint-disable-next-line @typescript-eslint/default-param-last
@@ -463,24 +464,11 @@ export const extractSearchTerm = makeVar<string>("");
463464
* openedUser - Profile snapshot resolved by CentralRouteManager Phase 1.
464465
* Set by: CentralRouteManager only.
465466
*
466-
* The shape mirrors the GET_USER GraphQL response. Components reading this
467-
* should treat null as "no user resolved yet" (loading or missing).
467+
* Derived from the GET_USER query output so the shape automatically tracks
468+
* any schema changes — adding a manual interface here would silently drift
469+
* the moment a new field appears on UserType.
468470
*/
469-
export interface OpenedUserProfile {
470-
id: string;
471-
username: string;
472-
slug: string;
473-
name: string;
474-
firstName: string;
475-
lastName: string;
476-
email: string;
477-
isProfilePublic: boolean;
478-
reputationGlobal: number;
479-
totalMessages: number;
480-
totalThreadsCreated: number;
481-
totalAnnotationsCreated: number;
482-
totalDocumentsUploaded: number;
483-
}
471+
export type OpenedUserProfile = NonNullable<GetUserOutput["userBySlug"]>;
484472
export const openedUser = makeVar<OpenedUserProfile | null>(null);
485473

486474
/**

frontend/src/routing/CentralRouteManager.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import {
2323
openedThread,
2424
openedLabelset,
2525
openedUser,
26-
OpenedUserProfile,
2726
selectedAnnotationIds,
2827
selectedAnalysesIds,
2928
selectedExtractIds,
@@ -324,16 +323,26 @@ export function CentralRouteManager() {
324323
}
325324
routeError(null);
326325

327-
// Type assertion: route.type is guaranteed to be "document" | "corpus" | "extract" | "thread" | "labelset" here
328-
// because "browse" and "unknown" are handled by early return above
326+
// route.type is guaranteed to be a resolvable entity here ("browse" and
327+
// "unknown" returned above). userSlug must be passed explicitly so
328+
// /users/:slug routes get a per-slug key — without it, every user
329+
// resolution would dedupe to the same key and rapid profile switches
330+
// would silently drop all but the first request.
329331
const requestKey = buildRequestKey(
330-
route.type as "document" | "corpus" | "extract" | "thread" | "labelset",
332+
route.type as
333+
| "document"
334+
| "corpus"
335+
| "extract"
336+
| "thread"
337+
| "labelset"
338+
| "user",
331339
route.userIdent,
332340
route.corpusIdent,
333341
route.documentIdent,
334342
route.extractIdent,
335343
route.threadIdent,
336-
route.labelsetIdent
344+
route.labelsetIdent,
345+
route.userSlug
337346
);
338347

339348
// Prevent duplicate simultaneous requests
@@ -815,7 +824,7 @@ export function CentralRouteManager() {
815824
}
816825

817826
if (!error && data?.userBySlug) {
818-
const user = data.userBySlug as OpenedUserProfile;
827+
const user = data.userBySlug;
819828

820829
routingLogger.debug(
821830
"[RouteManager] ✅ Resolved user via slug:",
@@ -833,6 +842,18 @@ export function CentralRouteManager() {
833842
}
834843

835844
console.warn("[RouteManager] User not found");
845+
// Unlike the corpus/document/extract not-found paths (which
846+
// redirect to /404 and clear via the browse-route handler), the
847+
// user error path stays on the URL so the visitor can fix the
848+
// slug. That makes clearing residual entity state our
849+
// responsibility — otherwise consumers of openedCorpus etc.
850+
// could render stale entity UI alongside the error display.
851+
openedUser(null);
852+
openedCorpus(null);
853+
openedDocument(null);
854+
openedExtract(null);
855+
openedThread(null);
856+
openedLabelset(null);
836857
routeError(new Error(`User "${route.userSlug}" not found`));
837858
routeLoading(false);
838859
return;

frontend/src/utils/navigationUtils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -535,13 +535,14 @@ export const requestTracker = new RequestTracker();
535535
* Build a unique key for request deduplication
536536
*/
537537
export function buildRequestKey(
538-
type: "corpus" | "document" | "extract" | "thread" | "labelset",
538+
type: "corpus" | "document" | "extract" | "thread" | "labelset" | "user",
539539
userIdent?: string,
540540
corpusIdent?: string,
541541
documentIdent?: string,
542542
extractIdent?: string,
543543
threadIdent?: string,
544-
labelsetIdent?: string
544+
labelsetIdent?: string,
545+
userSlug?: string
545546
): string {
546547
const parts = [
547548
type,
@@ -551,6 +552,7 @@ export function buildRequestKey(
551552
extractIdent,
552553
threadIdent,
553554
labelsetIdent,
555+
userSlug,
554556
].filter(Boolean);
555557
return parts.join("-");
556558
}

0 commit comments

Comments
 (0)