Skip to content

Commit 9a397be

Browse files
authored
Merge pull request #1307 from Open-Source-Legal/claude/resolve-issue-1295-2uMj6
Fix Rules-of-Hooks violation in UserProfileRoute
2 parents cd7984c + 254a260 commit 9a397be

5 files changed

Lines changed: 112 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
175175

176176
### Fixed
177177

178+
- **Rules-of-Hooks violation in `UserProfileRoute`** (Issue #1295): `frontend/src/components/routes/UserProfileRoute.tsx` called `useQuery` _after_ a conditional early return for the `!slug` redirect case, so when the same component fiber was reused across the `/profile``/users/:slug` redirect (e.g. both routes rendering `UserProfileRoute` in a single `Routes` tree) React threw `Rendered more hooks than during the previous render`. Production flows that unmount/remount across the redirect masked the bug, but any future refactor that kept the fiber alive would start crashing, and test suites that mounted both routes together could not exercise the full redirect → render path. Moved `useQuery` above the early-return block and pass `{ slug: slug ?? "" }` as variables while keeping the existing `skip: !slug` gate on the network call.
178179
- **`PdfAnnotations.undoAnnotation()` mutated the source array** (Issue #1291): `frontend/src/components/annotator/types/annotations.ts` called `this.annotations.pop()` before constructing the returned instance, silently mutating the caller's `annotations` array even though the field is declared `readonly`. Every other method on this reducer-style class returns without touching the input, so the leak was a footgun for downstream consumers of the upcoming PdfAnnotator package extraction (Issue #1283) — any reference to the pre-undo instance would observe a stale shorter array without re-rendering. Replaced `.pop()` with a non-mutating `slice(0, -1)` + index read. Updated `frontend/src/components/annotator/types/__tests__/annotations.test.ts` to remove the pinning comment and add an explicit assertion that the original instance's `annotations` array is unchanged after `undoAnnotation()`.
179180
- **`RelationGroup.updateForAnnotationDeletion` never pruned orphaned relations** (Issue #1292): `frontend/src/components/annotator/types/annotations.ts:49-50` computed `nowSourceEmpty` / `nowTargetEmpty` against `this.sourceIds` / `this.targetIds` (the **pre-filter** arrays) instead of the `newSourceIds` / `newTargetIds` arrays produced a few lines above. Because the "now empty" flags were identical to the "was empty" flags, all four `return undefined` branches intended to prune orphaned relations were unreachable, and the method always returned a fresh `RelationGroup` — even when `PdfAnnotations.undoAnnotation()` had just removed the relation's only source or target annotation. That left zombie relations in `PdfAnnotations.relations` with empty `sourceIds` or `targetIds` and required defensive handling downstream. Fix swaps the two comparisons to the post-filter arrays so the documented deletion conditions fire correctly. `frontend/src/components/annotator/types/__tests__/annotations.test.ts` drops the bug-pinning header note and replaces the ambiguous "prunes any relations" assertion with two explicit cases: the survivor is kept when it still has a source and a target, and a relation whose only target references the popped annotation is now removed (21/21 unit tests pass).
180181
- **TOCTOU race on `DocumentPath` uniqueness** (Issue #1200): `DocumentFolderService.move_document_to_folder()`, `move_documents_to_folder()`, and `delete_folder()` previously caught `IntegrityError` from the `unique_active_path_per_corpus` partial unique constraint and either bubbled it up to the caller as a "Path conflict, please retry" error (single move) or rolled back the entire batch (bulk move / folder delete). Under concurrent moves of different documents to the same target folder, two transactions could both observe a candidate path as free in `_disambiguate_path()` and race to insert it; the loser hit the partial unique index and the operation failed. New helper `DocumentFolderService._create_successor_path_with_retry()` (`opencontractserver/corpuses/folder_service.py`) wraps the deactivate-then-create pair in a savepoint and retries with a freshly disambiguated path on `IntegrityError`, treating each losing path as occupied. Up to `MAX_PATH_CREATE_RETRIES + 1` attempts (`opencontractserver/constants/document_processing.py`) run before propagating the conflict. The partial unique index added in migration `0023_documentpath_documentpathgroupobjectpermission_and_more` remains the authoritative correctness guarantee. New test classes `TestMoveDocumentIntegrityRecovery`, `TestBulkMoveIntegrityRecovery`, and `TestDeleteFolderIntegrityRecovery` cover transient-failure recovery, disambiguated retry path selection, and exhausted-retry rollback (`opencontractserver/tests/test_document_folder_service.py`).
-2.51 KB
Loading

frontend/src/components/routes/UserProfileRoute.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ export const UserProfileRoute: React.FC = () => {
2121
const { slug } = useParams<{ slug: string }>();
2222
const currentUser = useReactiveVar(backendUserObj);
2323

24+
// Must be unconditional — skip: !slug gates the network call; see #1295.
25+
const { data, loading, error } = useQuery<GetUserOutput, GetUserInput>(
26+
GET_USER,
27+
{
28+
variables: { slug: slug ?? "" },
29+
skip: !slug,
30+
}
31+
);
32+
2433
// If no slug provided, redirect to current user's profile
2534
if (!slug) {
2635
if (!currentUser?.slug) {
@@ -29,14 +38,6 @@ export const UserProfileRoute: React.FC = () => {
2938
return <Navigate to={`/users/${currentUser.slug}`} replace />;
3039
}
3140

32-
const { data, loading, error } = useQuery<GetUserOutput, GetUserInput>(
33-
GET_USER,
34-
{
35-
variables: { slug },
36-
skip: !slug,
37-
}
38-
);
39-
4041
if (loading) {
4142
return <ModernLoadingDisplay type="default" message="Loading profile..." />;
4243
}

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,6 @@ describe("UserProfileRoute", () => {
8888
});
8989

9090
it("redirects /profile to /users/<slug> when a user is logged in", async () => {
91-
// NOTE: we don't mount the /users/:slug route here because
92-
// UserProfileRoute has a latent Rules-of-Hooks issue when it's reused
93-
// across a <Navigate> — the early-return path skips useQuery(), then
94-
// once the URL changes React tries to add the hook to the same fiber.
95-
// Tracked separately; the redirect itself still works in practice.
9691
backendUserObj({ id: "u-1", slug: "alice" } as any);
9792

9893
render(
@@ -110,6 +105,38 @@ describe("UserProfileRoute", () => {
110105
expect(loc.textContent).toBe("/users/alice");
111106
});
112107

108+
it("redirects /profile and then renders /users/:slug without a Rules-of-Hooks crash (issue #1295)", async () => {
109+
// Regression guard: the pre-fix code called useQuery *after* the
110+
// early-return for the no-slug case, so when /profile redirected to
111+
// /users/:slug the same UserProfileRoute fiber transitioned from 0 to 1
112+
// hook and React threw. With useQuery hoisted unconditionally, the two
113+
// renders share the same hook ordering and the redirect survives.
114+
backendUserObj({ id: "u-1", slug: "alice" } as any);
115+
116+
render(
117+
<MockedProvider
118+
mocks={[
119+
{
120+
request: { query: GET_USER, variables: { slug: "alice" } },
121+
result: { data: { userBySlug: user } },
122+
},
123+
]}
124+
addTypename={false}
125+
>
126+
<MemoryRouter initialEntries={["/profile"]}>
127+
<Routes>
128+
<Route path="/profile" element={<UserProfileRoute />} />
129+
<Route path="/users/:slug" element={<UserProfileRoute />} />
130+
</Routes>
131+
</MemoryRouter>
132+
</MockedProvider>
133+
);
134+
135+
await waitFor(() => {
136+
expect(screen.getByText("UserProfile:alice")).toBeInTheDocument();
137+
});
138+
});
139+
113140
it("renders UserProfile with isOwnProfile=true when viewer is the profile owner", async () => {
114141
backendUserObj({ id: "u-1", slug: "alice" } as any);
115142

frontend/tests/user-profile.ct.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,76 @@ const mockPublicUser = {
2525
totalDocumentsUploaded: 10,
2626
};
2727

28+
test.describe("UserProfileRoute - Hook Ordering Regression (Issue #1295)", () => {
29+
test("mounts with slug under a Routes tree that also defines /profile (no 'Rendered more hooks' crash)", async ({
30+
mount,
31+
page,
32+
}) => {
33+
// This test exercises the scenario that surfaced the original bug: both
34+
// the redirect route (/profile, no slug) and the render route
35+
// (/users/:slug) live in the same <Routes> tree. When useQuery was below
36+
// the `!slug` early return, React could (depending on fiber reuse)
37+
// compare hook call counts across the two renders and throw
38+
// "Rendered more hooks than during the previous render". With the fix,
39+
// useQuery is called unconditionally and skip: !slug gates the network
40+
// request, so the component renders without crashing on either path.
41+
//
42+
// NOTE: This is a narrow regression guard rather than a full reproduction
43+
// of the original crash. Reproducing the exact crash in-process requires
44+
// keeping the same fiber across /profile -> /users/:slug, which React
45+
// Router <Routes> doesn't do (it unmounts the old element when the
46+
// matched path changes). The positive assertions below (loading display
47+
// for the slug path, absence of hook-count errors) prove the hook
48+
// ordering is stable — exactly what the fix guarantees statically.
49+
const consoleErrors: string[] = [];
50+
page.on("console", (msg) => {
51+
if (msg.type() === "error") consoleErrors.push(msg.text());
52+
});
53+
54+
// Delay the mock so the render parks on the loading state, giving us
55+
// positive visible evidence (not just the absence of a crash) that
56+
// useQuery ran under skip:false on the slug path.
57+
const mocks = [
58+
{
59+
request: {
60+
query: GET_USER,
61+
variables: { slug: "publicuser-123" },
62+
},
63+
delay: 5000,
64+
result: {
65+
data: {
66+
userBySlug: mockPublicUser,
67+
},
68+
},
69+
},
70+
];
71+
72+
const component = await mount(
73+
<MockedProvider mocks={mocks} addTypename={false}>
74+
<MemoryRouter initialEntries={["/users/publicuser-123"]}>
75+
<Routes>
76+
<Route path="/profile" element={<UserProfileRoute />} />
77+
<Route path="/users/:slug" element={<UserProfileRoute />} />
78+
</Routes>
79+
</MemoryRouter>
80+
</MockedProvider>
81+
);
82+
83+
// Wait for positive DOM evidence that the slug render reached the
84+
// useQuery call (skip:false branch) instead of a silent bailout.
85+
await expect(page.locator("text=Loading profile...")).toBeVisible({
86+
timeout: 10000,
87+
});
88+
89+
const hookErrors = consoleErrors.filter((e) =>
90+
e.includes("Rendered more hooks than during the previous render")
91+
);
92+
expect(hookErrors).toEqual([]);
93+
94+
await component.unmount();
95+
});
96+
});
97+
2898
test.describe("UserProfile View - Loading and Error States", () => {
2999
test("should show loading state while fetching user data", async ({
30100
mount,

0 commit comments

Comments
 (0)