* [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>
9.0 KiB
Frontend hooks
This page documents the public React hooks available under panel/src/hooks.
usePageRefresh
A page-scoped refresh coordinator. Pages and panels register callbacks that refetch their data; UI chrome calls refresh() and reflects the combined loading/disabled state.
When to use it
Use usePageRefresh when several components on the same page need to refresh together from a single trigger, such as a navbar refresh button. It keeps the refresh lifecycle scoped to the current page and avoids invalidating unrelated data.
Setup
Wrap the page (or root layout) with PageRefreshProvider:
import { PageRefreshProvider } from "@/components/providers";
export default function Layout({ children }: { children: React.ReactNode }) {
return <PageRefreshProvider>{children}</PageRefreshProvider>;
}
Basic usage
import { useEffect } from "react";
import { usePageRefresh } from "@/hooks";
import { useTasks } from "@/hooks";
export function TasksPanel() {
const { refetch } = useTasks();
const { register, unregister } = usePageRefresh();
useEffect(() => {
const refresh = () => refetch();
register(refresh);
return () => unregister(refresh);
}, [register, unregister, refetch]);
return <div>{/* task list */}</div>;
}
Triggering a refresh from UI chrome
import { usePageRefresh } from "@/hooks";
export function RefreshButton() {
const { refresh, loading, disabled } = usePageRefresh();
return (
<button onClick={refresh} disabled={disabled || loading}>
{loading ? "Refreshing…" : "Refresh"}
</button>
);
}
Navbar refresh button
The canonical consumer is panel/src/components/layout/header.tsx. The refresh button is rendered between the connection-status badge and the theme toggle. Its accessible label and tooltip read "Refresh only the current page", and it is disabled with a spinning icon while the registered refresh cycle is running.
Dashboard pages no longer include their own inline "Refresh" buttons. Instead, each page registers its refetch callbacks with usePageRefresh and lets the shared header button drive the refresh. See components/page-refresh-provider.md for the full list of wired pages and the registration pattern.
API reference
PageRefreshProvider
| Prop | Type | Default | Description |
|---|---|---|---|
children |
React.ReactNode |
required | React tree that can consume the context. |
disabled |
boolean |
false |
When true, refresh() is ignored and disabled is exposed as true. |
usePageRefresh
Returns a PageRefreshState object:
| Property | Type | Description |
|---|---|---|
disabled |
boolean |
Whether refresh actions are currently disabled. |
loading |
boolean |
Whether a refresh cycle is currently in progress. |
register |
(callback: RefreshCallback) => void |
Add a callback to invoke on the next refresh. |
unregister |
(callback: RefreshCallback) => void |
Remove a previously registered callback. |
refresh |
() => Promise<void> |
Run every registered callback and update loading. |
RefreshCallback is () => void | Promise<void>. Synchronous and asynchronous callbacks are both supported.
Behavior
usePageRefreshthrows if called outside aPageRefreshProviderso consumers fail fast instead of silently missing refreshes.- Concurrent calls to
refresh()are coalesced: a second call while one is running returns immediately and does not start another cycle. - When
disabledistrue,refresh()is a no-op and callbacks are not invoked. registerandunregisterare stable across renders and can be used asuseEffectdependencies.
Exports
usePageRefreshfrom@/hooksPageRefreshProviderfrom@/components/providers- Types:
PageRefreshState,RefreshCallback,PageRefreshProviderProps
Migration notes
panel/src/components/providers.tsxwas renamed topanel/src/components/app-providers.tsxso that@/components/providerscould be used as a barrel export forPageRefreshProvider. Update any direct import of the root providers component from@/components/providersto@/components/app-providers.- The earlier scope-keyed provider files (
panel/src/components/page-refresh-provider.tsxandpanel/src/store/page-refresh-context.ts) were deleted. The current implementation lives inpanel/src/components/providers/page-refresh-provider.tsxand is consumed throughusePageRefreshfrom@/hooks.
Data-hook null-guard audit
Every useQuery hook in panel/src/hooks/ has been audited for missing enabled guards on undefined/null IDs, staleTime mismatches, and refetchInterval leaks on unmount.
Audit results
All hooks carrying id-driven queries (useTask, useSubtasks, useBoardReview, useTaskFindings, useTaskCollisionMap, useProject, useWorkSession, useWorkSessionForTask, useAgentStatus, useAgentDefinition, useJournalByAgent, useJournalEntry, useNotification, useGitStatus, useGitLog, useGitBranches, useGitDiff, useGitFile, useMemberScorecard, and others) already carry correct enabled: !!id guards preventing undefined/null IDs from reaching the API.
Special case: board-review polls. useTask includes a conditional refetchInterval when the task belongs to the Board team and board_review_complete is still false. The interval is correctly wired to self-disable via a selector function — once the backend reports board_review_complete: true, the refetchInterval gate closes and no further polls are scheduled. TanStack Query's Observer already tears down the interval timer on unmount, so there is no lifecycle leak.
No code changes were required. A regression test suite (panel/src/hooks/__tests__/use-tasks-null-guards.test.tsx) verifies the enabled guards and the board-review poll behavior with fake timers.
Using these hooks safely
When calling any id-driven hook, always pass the id from a verified source:
import { useTask } from "@/hooks";
export function TaskDetail({ taskId }: { taskId: string | undefined }) {
// The hook's `enabled` guard ensures no API call occurs when taskId is empty
const { data, isLoading, error } = useTask(taskId);
if (!taskId) return <p>No task selected</p>;
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <div>{data?.title}</div>;
}
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:
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.)