Avoid delayed infrastructure focus theft

Only run the polling-row focus fallback while focus remains unclaimed, so closing Manage cannot override the operator's next control. Preserve recovery when the original Manage row was recreated and keep the return non-scrolling.

Contract-Neutral: frontend focus lifecycle bugfix; no API or persisted-data change.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-04 03:19:36 +01:00
parent 74c553aae3
commit 057f47b474
4 changed files with 83 additions and 28 deletions
+17 -20
View File
@@ -1,39 +1,36 @@
{
"version": 1,
"base_sha": "53ea86983c7c6786eb1a6fe1f3681e4566bc237e",
"verified_at": "2026-09-04T00:29:07Z",
"base_sha": "74c553aae330d2b8a0bfecc22124b40ef10c95d3",
"verified_at": "2026-09-04T02:18:06Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/types/resource.ts",
"frontend-modern/src/utils/resourceStateAdapters.ts"
],
"changed_paths": ["frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx"],
"content_sha256": {
"frontend-modern/src/types/resource.ts": "c43048a9796babd38f338cff14c21ed2b6dbbf8b16f2bb1fddf89529ef5a65f5",
"frontend-modern/src/utils/resourceStateAdapters.ts": "328efccc8b88b8e590bef9e56568b2f969a06df314d464b5f44b757a2acb189f"
"frontend-modern/src/components/Settings/InfrastructureWorkspace.tsx": "508b628748732e42dee19775fa3e0571fee0617dad8c2d28633bf7201887f904"
},
"routes": [
"/proxmox (managed hot-dev mock with two provider-scoped standalone sites sharing the native node name pve)"
"/settings/infrastructure (managed local backend with a two-node Proxmox cluster fixture)"
],
"viewports": [
{
"width": 1280,
"height": 800
"height": 720
},
{
"width": 390,
"height": 844
"width": 393,
"height": 727
}
],
"states": [
"Initial Proxmox overview showing Nodes 2 and two standalone rows named pve with distinct provider endpoint links",
"Proxmox overview after six mock websocket update intervals with both provider-scoped rows still present",
"Reloaded Proxmox overview with both same-native-name rows restored from the REST bootstrap snapshot",
"Narrow Proxmox overview showing Nodes 2 and two separate pve rows without clipping or navigation overlap"
"Manage dialog open for an expanded two-node Proxmox cluster at desktop and narrow widths",
"Unsaved node name, Host Telemetry Agent section and certificate checkbox retained after a 15-second connection-ledger poll",
"Manage dialog closed with the settings scroll position preserved and deliberate onward focus retained beyond the delayed fallback",
"Manage dialog reopened and a duplicate presentation label saved against the second node's immutable identity"
],
"interactions": [
"Authenticated to the managed development runtime and navigated to the Proxmox overview",
"Verified both distinct provider endpoints and waited 12 seconds across repeated realtime updates",
"Reloaded the route and rechecked both resource rows",
"Resized from 1280 by 800 to 390 by 844 and inspected full-page screenshots for row count, clipping, overflow, and fixed-navigation overlap"
"Expanded the cluster members, opened Manage, inspected the dialog pixels and native-node diagnostics",
"Edited fields, changed the active section and advanced the polling clock while checking the mounted editor state",
"Closed Manage, claimed Add infrastructure focus without scrolling, waited beyond the 250ms fallback and checked focus plus scroll position",
"Reopened Manage, saved the second member's display label and verified the PUT payload retained the cluster host and immutable node identity",
"Inspected the narrow open dialog and the closed desktop and narrow states for placement, clipping, scrolling, fixed-navigation overlap and responsive layout"
]
}
@@ -321,11 +321,20 @@ const InfrastructureWorkspaceContent: Component<InfrastructureWorkspaceProps> =
(element) =>
element.getAttribute('data-infrastructure-manage-id') === focusReturnConnectionId,
);
const activeElement = document.activeElement;
const focusReturnIsStillPending =
!activeElement ||
activeElement === document.body ||
activeElement === document.documentElement ||
!document.contains(activeElement);
// The shared dialog restores its captured trigger without scrolling,
// but this delayed lookup covers rows recreated while the dialog was
// open. Keep that fallback from pulling a lower infrastructure row
// into view after the operator closes Manage.
focusTarget?.focus({ preventScroll: true });
// open. Only use that fallback while focus remains unclaimed: the
// operator may already have moved to another control during the delay.
// Keep the fallback from pulling a lower infrastructure row into view.
if (focusReturnIsStillPending) {
focusTarget?.focus({ preventScroll: true });
}
}, 250);
}
};
@@ -1026,11 +1026,55 @@ describe('InfrastructureWorkspace', () => {
const focusSpy = vi.spyOn(manageButton, 'focus');
fireEvent.click(screen.getByRole('button', { name: 'Close edit infrastructure dialog' }));
await waitFor(() => expect(focusSpy).toHaveBeenCalledTimes(2));
expect(focusSpy.mock.calls).toEqual([[{ preventScroll: true }], [{ preventScroll: true }]]);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await new Promise((resolve) => window.setTimeout(resolve, 300));
expect(focusSpy).toHaveBeenCalledTimes(1);
expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true });
expect(manageButton).toHaveFocus();
});
it('does not steal focus back to Manage after the operator moves on', async () => {
renderWorkspace({
pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any],
});
const manageButton = await screen.findByRole('button', { name: /^Manage$/i });
fireEvent.click(manageButton);
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Close edit infrastructure dialog' }));
const nextAction = screen.getByRole('button', { name: /^Add infrastructure$/i });
nextAction.focus();
await new Promise((resolve) => window.setTimeout(resolve, 300));
expect(nextAction).toHaveFocus();
});
it('returns focus to a Manage control recreated by a connection poll', async () => {
const [revision, setRevision] = createSignal(0);
connectionState.setObserver(revision, () => setRevision((value) => value + 1));
renderWorkspace({
pveNodes: () => [{ name: 'zeus', host: 'https://10.0.0.1:8006' } as any],
});
const originalManageButton = await screen.findByRole('button', { name: /^Manage$/i });
fireEvent.click(originalManageButton);
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
connectionState.connections = [
connectionFixture({ lastSeen: new Date(Date.now() + 15_000).toISOString() }),
];
connectionState.notifyChanged();
await waitFor(() =>
expect(screen.getByRole('button', { name: /^Manage$/i })).not.toBe(originalManageButton),
);
const replacementManageButton = screen.getByRole('button', { name: /^Manage$/i });
fireEvent.click(screen.getByRole('button', { name: 'Close edit infrastructure dialog' }));
await new Promise((resolve) => window.setTimeout(resolve, 300));
expect(replacementManageButton).toHaveFocus();
});
it('keeps the mounted node editor across connection-ledger refreshes', async () => {
const [revision, setRevision] = createSignal(0);
connectionState.setObserver(revision, () => setRevision((value) => value + 1));
@@ -284,21 +284,26 @@ test.describe('Proxmox cluster node display names', () => {
),
{ timeout: 2_000 },
)
.toBe(2);
.toBe(1);
// The row-stability fallback runs later than the shared dialog cleanup.
// Moving on during that window must not pull focus back to Manage.
const nextAction = page.getByRole('button', { name: 'Add infrastructure', exact: true });
await nextAction.evaluate((element) => element.focus({ preventScroll: true }));
await page.waitForTimeout(350);
expect(
await page.evaluate(
() =>
(window as Window & { __infrastructureManageFocusCalls?: FocusOptions[] })
.__infrastructureManageFocusCalls,
),
).toEqual([{ preventScroll: true }, { preventScroll: true }]);
).toEqual([{ preventScroll: true }]);
expect(
await page.evaluate(() => {
const shell = document.querySelector<HTMLElement>('.app-scroll-shell');
return shell?.scrollTop ?? window.scrollY;
}),
).toBe(scrollTopBeforeClose);
await expect(manageButton).toBeFocused();
await expect(nextAction).toBeFocused();
await page.screenshot({ path: testInfo.outputPath('manage-dialog-closed.png') });
await manageButton.click();