Skip to content

Commit 354f1f2

Browse files
fix(#1704): clear stale tab on node switch + sync tree focusedId (#1706)
Two root causes of the explorer selection appearing stuck: 1. writeUrl in app/units/page.tsx preserved the ?tab= param when only a new node was selected. Switching from a Unit with ?tab=Agents to an Agent node left ?tab=Agents in the URL; DetailPane's tab-correction useEffect then fired with a potentially-stale selectedId closure and could overwrite a subsequent click's URL update. Fix: delete the tab param whenever writeUrl is called with node but not tab. Also hoist the useCallback hooks above the early returns to comply with rules-of-hooks, and wrap the handlers in useCallback so the correction effect runs less often. 2. UnitTree initialised focusedId (the roving-tabindex anchor) from the selectedId prop via useState but never re-synced it on prop changes. After URL-driven selection changes (Cmd-K, deep-links, dashboard nav), the keyboard tabstop stayed on the old row. Pressing Enter then re-selected the old node, making selection look "snapped back". Fix: useEffect(() => setFocusedId(selectedId), [selectedId]). Closes #1704. Co-authored-by: savasp-agent[bot] <275188714+savasp-agent[bot]@users.noreply.github.com>
1 parent f509291 commit 354f1f2

4 files changed

Lines changed: 112 additions & 17 deletions

File tree

src/Cvoya.Spring.Web/src/app/units/page.tsx

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// route stays until `DEL-units-id` lands because its tabs still host
1111
// content the EXP-tab-unit-* issues are migrating into the Explorer.
1212

13-
import { Suspense } from "react";
13+
import { Suspense, useCallback } from "react";
1414
import Link from "next/link";
1515
import { AlertCircle, Loader2, Plus } from "lucide-react";
1616
import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -39,6 +39,42 @@ function UnitExplorerRoute() {
3939

4040
const treeQuery = useTenantTree();
4141

42+
// Hooks must be declared before any early return (#1704, react-hooks/rules-of-hooks).
43+
// `writeUrl`, `handleSelectNode`, and `handleTabChange` only depend on URL
44+
// state that is available on every render path.
45+
const writeUrl = useCallback(
46+
(next: { node?: string; tab?: TabName }) => {
47+
const params = new URLSearchParams(searchParams.toString());
48+
if (next.node !== undefined) {
49+
params.set("node", next.node);
50+
// #1704: clear a stale `tab` when only switching the node. Keeping
51+
// the old tab across a node switch makes `DetailPane` see an invalid
52+
// tab and fire its correction effect with a potentially-stale
53+
// `selectedId` closure, which can overwrite a subsequent click.
54+
if (next.tab === undefined) params.delete("tab");
55+
}
56+
if (next.tab !== undefined) params.set("tab", next.tab);
57+
const qs = params.toString();
58+
// #1039: Next.js 16's `router.replace("?foo=bar")` with a bare
59+
// query-only relative URL doesn't update the canonical URL — the
60+
// reconciler's `replaceState` call fires with the stale query, so
61+
// the URL (and controlled `tab`/`node` props derived from it) snap
62+
// back to the prior value the moment React commits. Passing the
63+
// full pathname alongside the query restores the intended navigation.
64+
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
65+
},
66+
[searchParams, pathname, router],
67+
);
68+
69+
const handleSelectNode = useCallback(
70+
(id: string) => writeUrl({ node: id }),
71+
[writeUrl],
72+
);
73+
const handleTabChange = useCallback(
74+
(id: string, nextTab: TabName) => writeUrl({ node: id, tab: nextTab }),
75+
[writeUrl],
76+
);
77+
4278
if (treeQuery.isError) {
4379
return (
4480
<Card
@@ -80,20 +116,6 @@ function UnitExplorerRoute() {
80116

81117
const tree = adaptValidatedNode(treeQuery.data);
82118

83-
const writeUrl = (next: { node?: string; tab?: TabName }) => {
84-
const params = new URLSearchParams(searchParams.toString());
85-
if (next.node !== undefined) params.set("node", next.node);
86-
if (next.tab !== undefined) params.set("tab", next.tab);
87-
const qs = params.toString();
88-
// #1039: Next.js 16's `router.replace("?foo=bar")` with a bare
89-
// query-only relative URL doesn't update the canonical URL — the
90-
// reconciler's `replaceState` call fires with the stale query, so
91-
// the URL (and controlled `tab`/`node` props derived from it) snap
92-
// back to the prior value the moment React commits. Passing the
93-
// full pathname alongside the query restores the intended navigation.
94-
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
95-
};
96-
97119
return (
98120
<div
99121
data-testid="unit-explorer-route"
@@ -107,9 +129,9 @@ function UnitExplorerRoute() {
107129
<UnitExplorer
108130
tree={tree}
109131
selectedId={selectedId}
110-
onSelectNode={(id) => writeUrl({ node: id })}
132+
onSelectNode={handleSelectNode}
111133
tab={tab ?? undefined}
112-
onTabChange={(id, nextTab) => writeUrl({ node: id, tab: nextTab })}
134+
onTabChange={handleTabChange}
113135
/>
114136
</div>
115137
</div>

src/Cvoya.Spring.Web/src/app/units/units-page.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ vi.mock("@/lib/api/queries", () => ({
9191
// with "no data" so Explorer page tests don't need to model
9292
// execution defaults.
9393
useUnitExecution: () => ({ data: null, isLoading: false }),
94+
// Agents tab — MembershipDialog reads the agent-runtimes catalog.
95+
useAgentRuntimes: () => ({ data: [], isLoading: false }),
9496
}));
9597

9698
function wrap(node: ReactNode) {
@@ -213,6 +215,32 @@ describe("UnitsPage — Explorer route (EXP-route)", () => {
213215
expect(replaceMock.mock.calls.at(-1)?.[0]).toMatch(/node=engineering/);
214216
});
215217

218+
it("#1704: clears a stale ?tab when switching to a different node kind", async () => {
219+
// Start with the tenant root showing and a Unit-only tab (Agents) in the
220+
// URL from a prior navigation. We simulate this by seeding the URL with
221+
// the tab but NOT the node so the page renders at the root (Tenant) —
222+
// this avoids rendering the full Unit Agents tab which needs extra mocks.
223+
currentSearchParams = new URLSearchParams("tab=Agents");
224+
useTenantTreeMock.mockReturnValue({
225+
data: sampleTree,
226+
isLoading: false,
227+
isError: false,
228+
});
229+
render(wrap(<UnitsPage />));
230+
await screen.findByTestId("unit-explorer");
231+
232+
// Click the Engineering unit. The stale ?tab=Agents is valid for a Unit,
233+
// so it carries over (correct). Then click Marketing — we just need the
234+
// first write to confirm the node switches without re-carrying the tab.
235+
replaceMock.mockClear();
236+
fireEvent.click(screen.getByTestId("tree-row-marketing"));
237+
await waitFor(() => expect(replaceMock).toHaveBeenCalled());
238+
const urlAfterSwitch = replaceMock.mock.calls.at(-1)?.[0] ?? "";
239+
// Node must update and the stale cross-kind tab must be cleared.
240+
expect(urlAfterSwitch).toMatch(/node=marketing/);
241+
expect(urlAfterSwitch).not.toMatch(/tab=/);
242+
});
243+
216244
it("renders a 'New unit' link in the page header pointing to /units/create (#1069)", async () => {
217245
useTenantTreeMock.mockReturnValue({
218246
data: sampleTree,

src/Cvoya.Spring.Web/src/components/units/unit-tree.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,41 @@ describe("UnitTree", () => {
353353
});
354354
});
355355

356+
it("#1704: re-anchors keyboard tabstop when selectedId changes externally", () => {
357+
const { rerender } = render(
358+
<UnitTree
359+
tree={tree}
360+
selectedId="tenant-acme"
361+
onSelect={vi.fn()}
362+
defaultExpanded={{ "tenant-acme": true }}
363+
/>,
364+
);
365+
expect(screen.getByTestId("tree-row-tenant-acme")).toHaveAttribute(
366+
"tabindex",
367+
"0",
368+
);
369+
370+
// Simulate an external selection change (URL navigation / Cmd-K / deep-link).
371+
rerender(
372+
<UnitTree
373+
tree={tree}
374+
selectedId="unit-eng"
375+
onSelect={vi.fn()}
376+
defaultExpanded={{ "tenant-acme": true }}
377+
/>,
378+
);
379+
380+
// The new selection should now hold tabIndex=0 so keyboard Enter targets it.
381+
expect(screen.getByTestId("tree-row-unit-eng")).toHaveAttribute(
382+
"tabindex",
383+
"0",
384+
);
385+
expect(screen.getByTestId("tree-row-tenant-acme")).toHaveAttribute(
386+
"tabindex",
387+
"-1",
388+
);
389+
});
390+
356391
it("surfaces a worst-status buried four levels deep on the collapsed top-level row", () => {
357392
// Fixture independent of the file-scoped `tree` above: a
358393
// Tenant → Unit → Unit → Unit → Agent(error) chain where only the leaf

src/Cvoya.Spring.Web/src/components/units/unit-tree.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Bot, Globe, Layers } from "lucide-react";
44
import {
55
type KeyboardEvent,
66
useCallback,
7+
useEffect,
78
useMemo,
89
useRef,
910
useState,
@@ -76,6 +77,15 @@ export function UnitTree({
7677
// the selected row, but tracks arrow-key movement separately so operators
7778
// can survey the tree without shifting selection.
7879
const [focusedId, setFocusedId] = useState<string>(selectedId);
80+
81+
// #1704: re-anchor the tabstop when `selectedId` changes externally (URL
82+
// navigation, Cmd-K teleport, dashboard deep-link). Without this, keyboard
83+
// Enter would re-select the previously-focused row, making selection appear
84+
// to "snap back" to the old node.
85+
useEffect(() => {
86+
setFocusedId(selectedId);
87+
}, [selectedId]);
88+
7989
const containerRef = useRef<HTMLDivElement>(null);
8090
// Short-lived type-ahead buffer: printable keys append; ~500 ms of
8191
// silence resets.

0 commit comments

Comments
 (0)