mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [7f87f002] fix(panel): remove eslint-disable in journals-view mount effect Replace the empty-deps + eslint-disable-next-line react-hooks/exhaustive-deps mount-only localStorage-restore effect with a useRef mount-guard, so the deps array can honestly list router/searchParams/urlAgentId/urlType/urlTask while still only running the restore once per mount. Adds regression tests proving the restore never re-fires after a later filter clear, which a naive "just add the deps" fix would have broken. * [7f87f002] docs(frontend): document journals-view mount-guard eslint-disable fix --------- Co-authored-by: roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com> Co-authored-by: Frontend Developer 1 <fe-dev-1@roboco.tech> Co-authored-by: Frontend Documenter <fe-doc@roboco.tech>
This commit is contained in:
co-authored by
roboco-app[bot] <302741806+roboco-app[bot]@users.noreply.github.com>
Frontend Developer 1
Frontend Documenter
parent
9f07183b01
commit
424c7a0e59
@@ -138,3 +138,26 @@ export function TaskDetail({ taskId }: { taskId: string | undefined }) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
No manual guard is needed before calling the hook — the `enabled: !!taskId` guard is built in and prevents wasted API calls and race conditions.
|
No manual guard is needed before calling the hook — the `enabled: !!taskId` guard is built in and prevents wasted API calls and race conditions.
|
||||||
|
|
||||||
|
## Mount-only effect audit: eslint-disable in `journals-view.tsx`
|
||||||
|
|
||||||
|
Sentinel's `no_lint_suppressions` hygiene scan flagged `// eslint-disable-next-line react-hooks/exhaustive-deps` guarding the mount-only localStorage-restore effect in `JournalsViewContent` (`panel/src/components/journals/journals-view.tsx`). That effect restores the `agent`/`type`/`task` filters saved from a prior visit into the URL, but only on a fresh `/agents?tab=journals` visit that carries no query params yet — it must run exactly once per mount, never again, or it would clobber a later intentional "clear filters" action with stale saved state.
|
||||||
|
|
||||||
|
### Audit result: fixed at the source, no waiver needed
|
||||||
|
|
||||||
|
The suppression was removed by replacing the empty `[]` dependency array with a `useRef` mount-guard:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const hasRestoredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasRestoredRef.current) return;
|
||||||
|
hasRestoredRef.current = true;
|
||||||
|
// ...restore-from-localStorage logic, reading urlAgentId/urlType/urlTask/searchParams/router
|
||||||
|
}, [urlAgentId, urlType, urlTask, searchParams, router]);
|
||||||
|
```
|
||||||
|
|
||||||
|
The ref guard, not the (now honest and complete) dependency array, is what enforces "exactly once per mount" — so `react-hooks/exhaustive-deps` is satisfied without changing the effect's actual behavior. This is the same idiom already used elsewhere in the panel for mount-only effects: `panel/src/components/scroll-restoration.tsx` (`hasRestored`) and `panel/src/components/a2a/a2a-transcript.tsx` (`hasScrolledRef`). Prefer this pattern over `eslint-disable` + `[]` for any new mount-only effect — it keeps the dependency array truthful for future maintainers while still only firing once.
|
||||||
|
|
||||||
|
A naive fix that just added `router`/`searchParams` to the deps array **without** the ref guard would have been wrong: those values change across every navigation, so the effect would re-run on every subsequent URL change and re-apply the stale saved filters over a user's later, intentional filter clear. A regression suite, `panel/src/components/journals/__tests__/journals-view.test.tsx`, locks in the correct behavior: the restore fires once on a fresh no-param visit, is skipped when the URL already carries params, and — the key regression guard — never re-fires after mount even once the URL round-trips through params and back to empty.
|
||||||
|
|
||||||
|
No entry was added to `.roboco/conventions.yml`'s `waivers:` list — the suppression was eliminated at the source, not exempted. (The backend cell's unrelated waiver entries already committed to that file were left untouched.)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { PageRefreshProvider } from "@/components/providers";
|
||||||
|
|
||||||
|
// Sentinel flagged a react-hooks/exhaustive-deps suppression guarding the
|
||||||
|
// mount-only localStorage-restore effect. Fixed via a useRef mount guard
|
||||||
|
// instead of an empty deps array — this suite locks in that the restore
|
||||||
|
// fires exactly once and never re-fires to clobber a later, intentional
|
||||||
|
// filter clear (the hazard an honest-but-naive deps array would introduce).
|
||||||
|
|
||||||
|
const mockReplace = vi.fn();
|
||||||
|
// Stable per-render objects, same idiom as a2a-view.test.tsx — a fresh
|
||||||
|
// object/URLSearchParams on every render would make the effect's deps
|
||||||
|
// (searchParams, router) look "changed" even when the real URL hasn't
|
||||||
|
// moved, which would mask the very bug this test guards against.
|
||||||
|
const mockRouter = { replace: mockReplace, push: vi.fn() };
|
||||||
|
let searchParams = new URLSearchParams();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => mockRouter,
|
||||||
|
useSearchParams: () => searchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-agents", () => ({
|
||||||
|
useAgents: () => ({ data: [], isLoading: false, refetch: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { JournalsView } from "../journals-view";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "roboco-journals-state";
|
||||||
|
|
||||||
|
function withPageRefresh(ui: ReactNode) {
|
||||||
|
return <PageRefreshProvider>{ui}</PageRefreshProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("JournalsView — mount-only localStorage restore", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReplace.mockClear();
|
||||||
|
searchParams = new URLSearchParams();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the saved agent filter into the URL on a fresh visit with no params", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ agent: "be-dev-1", q: null, type: null, task: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(withPageRefresh(<JournalsView />));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReplace).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockReplace).toHaveBeenCalledWith("/agents?agent=be-dev-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restore when the URL already carries params", async () => {
|
||||||
|
searchParams = new URLSearchParams("agent=be-qa");
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ agent: "be-dev-1", q: null, type: null, task: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(withPageRefresh(<JournalsView />));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never re-fires the restore after mount, even once params are cleared back to empty", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify({ agent: "be-dev-1", q: null, type: null, task: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let rerender!: (ui: ReactNode) => void;
|
||||||
|
await act(async () => {
|
||||||
|
const result = render(withPageRefresh(<JournalsView />));
|
||||||
|
rerender = result.rerender;
|
||||||
|
});
|
||||||
|
expect(mockReplace).toHaveBeenCalledTimes(1);
|
||||||
|
mockReplace.mockClear();
|
||||||
|
|
||||||
|
// Simulate the URL round-tripping to carry params (post-restore) and
|
||||||
|
// then a user intentionally clearing every filter — a fresh
|
||||||
|
// URLSearchParams("") is referentially new, matching how the real
|
||||||
|
// `next/navigation` value changes across an actual navigation, so this
|
||||||
|
// is the exact scenario the naive "add router/searchParams to deps"
|
||||||
|
// fix would break by re-restoring over the user's clear.
|
||||||
|
searchParams = new URLSearchParams("agent=be-dev-1");
|
||||||
|
await act(async () => {
|
||||||
|
rerender(withPageRefresh(<JournalsView />));
|
||||||
|
});
|
||||||
|
searchParams = new URLSearchParams("");
|
||||||
|
await act(async () => {
|
||||||
|
rerender(withPageRefresh(<JournalsView />));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReplace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useCallback, useEffect, useState } from "react";
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { useAgents } from "@/hooks/use-agents";
|
import { useAgents } from "@/hooks/use-agents";
|
||||||
import { JournalEntryType } from "@/types";
|
import { JournalEntryType } from "@/types";
|
||||||
@@ -61,8 +61,16 @@ function JournalsViewContent() {
|
|||||||
() => loadJournalsState()?.q ?? "",
|
() => loadJournalsState()?.q ?? "",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restore from localStorage if URL has no params (fresh navigation)
|
// Restore from localStorage if URL has no params (fresh navigation).
|
||||||
|
// Guarded by a ref (not an empty deps array) so the deps list can honestly
|
||||||
|
// include everything the body reads while still firing exactly once per
|
||||||
|
// mount — otherwise the URL params clearing later (an intentional
|
||||||
|
// "remove filters" action) would re-trigger this and clobber that clear
|
||||||
|
// with the stale saved state.
|
||||||
|
const hasRestoredRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasRestoredRef.current) return;
|
||||||
|
hasRestoredRef.current = true;
|
||||||
const hasUrlParams = urlAgentId || urlType || urlTask;
|
const hasUrlParams = urlAgentId || urlType || urlTask;
|
||||||
if (!hasUrlParams) {
|
if (!hasUrlParams) {
|
||||||
const saved = loadJournalsState();
|
const saved = loadJournalsState();
|
||||||
@@ -74,8 +82,7 @@ function JournalsViewContent() {
|
|||||||
router.replace(`/agents?${params.toString()}`);
|
router.replace(`/agents?${params.toString()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [urlAgentId, urlType, urlTask, searchParams, router]);
|
||||||
}, []); // Intentionally only run on mount
|
|
||||||
|
|
||||||
// Derive state from URL
|
// Derive state from URL
|
||||||
const selectedAgentId = urlAgentId;
|
const selectedAgentId = urlAgentId;
|
||||||
|
|||||||
Reference in New Issue
Block a user