mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user