diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 48bc844e2..a4c192aae 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -6815,6 +6815,13 @@ the matching Open or History subtab from server-authored lifecycle state, and
removes the query when the dialog closes. Feature pages may summarize action
context, but they must not recreate approve, reject, run, progress, or outcome
controls outside the shared Actions review.
+The Actions route owns overlapping reads by request generation. A slower detail
+response or late dialog refresh must not replace a newer URL-selected action or
+reopen a closed review; a mismatched server action id is rejected. An older Open
+or History list response must not overwrite the newer tab's results or error.
+`Actions.requestOwnership.test.tsx` covers both response orders and close while
+a receipt re-read is pending; the browser navigation proof checks the rendered
+dialog and URL at desktop and phone widths without sending an action mutation.
The Actions ledger is a peer top-level navigation destination. Patrol remains
the primary detection and investigation home and may expose a route-backed
Actions handoff, but Actions owns its pending-approval count and selected state.
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 4701e6cbb..d34779661 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -2951,6 +2951,13 @@ missing provenance remains an immediate fail-closed warning rather than hidden
detail. Actions is the canonical browser hub for action review, execution
progress, and recorded outcomes; contextual sources such as Patrol link into an
exact action review instead of duplicating those mutations locally.
+When a user selects a different action before a prior detail read completes,
+only the detail matching the current `action` URL may open the review. Closing
+the review invalidates pending detail and receipt reads, so a late response
+cannot reopen it or substitute another resource's action. The page also keeps
+Open and History list responses scoped to their latest request; an older tab
+result or error cannot replace the current tab. This is a browser ownership
+rule over server-authored action identity, not a new action or receipt state.
APT review presents server-recorded policy provenance and distinguishes the
elevated update posture from low-risk-eligible cache cleanup. Both typed actions
diff --git a/frontend-modern/browser-tests/action-review-navigation.cjs b/frontend-modern/browser-tests/action-review-navigation.cjs
new file mode 100644
index 000000000..7e78e4dcb
--- /dev/null
+++ b/frontend-modern/browser-tests/action-review-navigation.cjs
@@ -0,0 +1,182 @@
+// Prove that late action reads cannot change the route-backed review or reopen it.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { chromium } = require('playwright');
+
+const action = (id) => ({
+ id,
+ createdAt: '2026-09-29T18:00:00Z',
+ updatedAt: '2026-09-29T18:01:00Z',
+ state: 'executing',
+ decisionRevision: 1,
+ request: {
+ requestId: `request-${id}`,
+ resourceId: `docker:container:${id}`,
+ capabilityName: 'update',
+ reason: `Update ${id}`,
+ requestedBy: 'operator',
+ },
+ plan: {
+ actionId: id,
+ requestId: `request-${id}`,
+ allowed: true,
+ requiresApproval: false,
+ approvalPolicy: 'none',
+ rollbackAvailable: false,
+ expiresAt: '2026-09-29T21:00:00Z',
+ planHash: `sha256:plan-${id}`,
+ },
+ verificationOutcome: { status: 'unknown' },
+});
+const detail = (id) => ({
+ audit: action(id),
+ events: [],
+ readiness: {
+ ready: false,
+ code: 'receipt_pending',
+ message: 'Awaiting receipt',
+ refreshable: false,
+ checkedAt: '2026-09-29T18:01:00Z',
+ },
+ attempt: {
+ id: `attempt-${id}`,
+ actionId: id,
+ state: 'receipt_pending',
+ createdAt: '2026-09-29T18:00:00Z',
+ updatedAt: '2026-09-29T18:01:00Z',
+ dispatchCount: 1,
+ },
+});
+
+(async () => {
+ const root = '/workspace/frontend-modern';
+ const artifacts = path.join(root, 'node_modules', 'action-review-navigation-proof');
+ fs.mkdirSync(artifacts, { recursive: true });
+ process.chdir(root);
+ const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
+ const server = await createServer({
+ root,
+ configFile: path.join(root, 'vite.config.ts'),
+ server: { host: '127.0.0.1', port: 5204, strictPort: true },
+ });
+ let browser;
+ try {
+ await server.listen();
+ browser = await chromium.launch({
+ headless: true,
+ channel: 'chromium',
+ args: ['--no-sandbox'],
+ });
+ const results = [];
+ for (const width of [1365, 390]) {
+ let releaseA;
+ let releaseReceipt;
+ let bReads = 0;
+ const requests = [];
+ const errors = [];
+ const page = await browser.newPage({
+ viewport: { width, height: width === 390 ? 844 : 900 },
+ });
+ page.on('pageerror', (error) => errors.push(error.message));
+ await page.route('**/*', async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+ if (url.origin !== 'http://127.0.0.1:5204') return route.abort();
+ if (url.pathname.startsWith('/api/')) requests.push(`${request.method()} ${url.pathname}`);
+ if (url.pathname === '/api/actions') {
+ const view = url.searchParams.get('view');
+ return route.fulfill({
+ json: {
+ view,
+ actions: view === 'pending' ? [action('edge-a'), action('edge-b')] : [],
+ count: view === 'pending' ? 2 : 0,
+ readOnly: false,
+ },
+ });
+ }
+ if (url.pathname === '/api/actions/edge-a') {
+ await new Promise((resolve) => {
+ releaseA = resolve;
+ });
+ return route.fulfill({ json: detail('edge-a') });
+ }
+ if (url.pathname === '/api/actions/edge-b') {
+ bReads += 1;
+ if (bReads === 2)
+ await new Promise((resolve) => {
+ releaseReceipt = resolve;
+ });
+ return route.fulfill({ json: detail('edge-b') });
+ }
+ if (url.pathname === '/api/security/status')
+ return route.fulfill({
+ json: {
+ hasAuthentication: true,
+ requiresAuth: true,
+ settingsCapabilities: { authenticationWrite: false },
+ },
+ });
+ if (url.pathname.startsWith('/api/'))
+ return route.fulfill({ status: 503, json: { error: 'fixture unavailable' } });
+ return route.continue();
+ });
+ await page.goto('http://127.0.0.1:5204/browser-tests/action-review-navigation.html', {
+ waitUntil: 'domcontentloaded',
+ timeout: 120_000,
+ });
+ await page.getByRole('button', { name: /Review Update on docker:container:edge-a/ }).click();
+ await page.waitForFunction(() => location.search === '?action=edge-a');
+ await page.getByRole('button', { name: /Review Update on docker:container:edge-b/ }).click();
+ await page.getByRole('dialog').getByText('docker:container:edge-b').first().waitFor();
+ releaseA();
+ await page.waitForTimeout(300);
+ assert.match(await page.getByRole('dialog').innerText(), /docker:container:edge-b/);
+ assert.doesNotMatch(await page.getByRole('dialog').innerText(), /docker:container:edge-a/);
+ assert.equal(new URL(page.url()).search, '?action=edge-b');
+ await page.screenshot({ path: path.join(artifacts, `newest-review-${width}.png`) });
+
+ const receiptRead = page.waitForRequest(
+ (request) =>
+ new URL(request.url()).pathname === '/api/actions/edge-b' && request.method() === 'GET',
+ );
+ await page.getByRole('button', { name: 'Check for receipt' }).click();
+ await receiptRead;
+ await page.waitForTimeout(50);
+ // The pending route is observable through the second GET, not a mutation.
+ assert.equal(bReads, 2);
+ await page.getByRole('button', { name: 'Close action review' }).click();
+ await page.waitForFunction(() => location.search === '');
+ releaseReceipt();
+ await page.waitForTimeout(300);
+ assert.equal(await page.getByRole('dialog').count(), 0);
+ assert.equal(new URL(page.url()).search, '');
+ assert.ok(
+ requests.every((item) => item.startsWith('GET ')),
+ JSON.stringify(requests),
+ );
+ assert.deepEqual(errors, []);
+ const dimensions = await page.evaluate(() => ({
+ scroll: document.documentElement.scrollWidth,
+ inner: innerWidth,
+ }));
+ assert.ok(dimensions.scroll <= dimensions.inner + 1, JSON.stringify(dimensions));
+ results.push({ width, requests, errors, dimensions });
+ await page.close();
+ }
+ console.log(
+ JSON.stringify({
+ result: 'passed',
+ browser: browser.version(),
+ playwright: '1.56.1',
+ results,
+ }),
+ );
+ } finally {
+ if (browser) await browser.close();
+ await server.close();
+ }
+})().catch((error) => {
+ console.error(error);
+ process.exitCode = 1;
+});
diff --git a/frontend-modern/browser-tests/action-review-navigation.html b/frontend-modern/browser-tests/action-review-navigation.html
new file mode 100644
index 000000000..ecd84c675
--- /dev/null
+++ b/frontend-modern/browser-tests/action-review-navigation.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/action-review-navigation.tsx b/frontend-modern/browser-tests/action-review-navigation.tsx
new file mode 100644
index 000000000..40a4d6a60
--- /dev/null
+++ b/frontend-modern/browser-tests/action-review-navigation.tsx
@@ -0,0 +1,14 @@
+// Exercise the production Actions page with scripted, read-only action responses.
+import { Route, Router } from '@solidjs/router';
+import { render } from 'solid-js/web';
+import Actions from '../src/pages/Actions';
+import '../src/index.css';
+
+render(
+ () => (
+
+
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json
index dcc2cbd29..11d01597e 100644
--- a/frontend-modern/browser-verification.json
+++ b/frontend-modern/browser-verification.json
@@ -1,16 +1,16 @@
{
"version": 1,
- "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
- "verified_at": "2026-09-29T19:09:52Z",
+ "base_sha": "fbf17b271abe70ad3e12042506b52f85b6cbc484",
+ "verified_at": "2026-09-29T19:50:45Z",
"result": "passed",
"changed_paths": [
- "frontend-modern/src/features/actions/ActionReviewDialog.tsx"
+ "frontend-modern/src/pages/Actions.tsx"
],
"content_sha256": {
- "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
+ "frontend-modern/src/pages/Actions.tsx": "30e862ecc5125f07cf93f0c1e0b5f58efa023f5fdb56fe4b01721bf9a453e7ce"
},
"routes": [
- "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
+ "/browser-tests/action-review-navigation.html (production Actions page with scripted action reads)"
],
"viewports": [
{
@@ -23,27 +23,70 @@
}
],
"states": [
- "fresh executing/receipt_pending, including after a same-action re-read",
- "failed status re-read with unknown outcome preserved",
- "completed action after a recorded receipt"
+ "detail for edge-a held while edge-b is selected and rendered",
+ "late edge-a response cannot replace edge-b review or URL",
+ "pending receipt re-read finishes after edge-b review closes"
],
"interactions": [
- "desktop pointer and phone keyboard Check for receipt",
- "GET same action under pending, read error and completion; no POST",
- "desktop and phone layout and horizontal-overflow checks"
+ "select edge-a then edge-b before the first detail response arrives",
+ "release older response and inspect current review at desktop and phone widths",
+ "start same-action receipt GET, close review, release response; no dialog reopens",
+ "assert all scripted API requests are GET and no horizontal overflow or page errors"
],
- "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
+ "command": "pulse-worker-browser frontend-modern/browser-tests/action-review-navigation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
- "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-browser.log",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-rkmwqerp/action-review-navigation-proof/newest-review-390.png"
],
- "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest.",
+ "notes": "Mock-backed navigation proof of production Actions page; screenshots inspected. No update was executed, no installed or reporter result established.",
"prior_verifications": [
+ {
+ "version": 1,
+ "base_sha": "c2f7aa8b471d6cbb858e8ec71b4e664fded6549c",
+ "verified_at": "2026-09-29T19:09:52Z",
+ "result": "passed",
+ "changed_paths": [
+ "frontend-modern/src/features/actions/ActionReviewDialog.tsx"
+ ],
+ "content_sha256": {
+ "frontend-modern/src/features/actions/ActionReviewDialog.tsx": "9a4af522484b42e37b118e8bfde7c1673122037a54b8bfed249818ba644e6b81"
+ },
+ "routes": [
+ "/browser-tests/action-receipt-wait.html (mock-backed production ActionReviewDialog)"
+ ],
+ "viewports": [
+ {
+ "width": 1365,
+ "height": 900
+ },
+ {
+ "width": 390,
+ "height": 844
+ }
+ ],
+ "states": [
+ "fresh executing/receipt_pending, including after a same-action re-read",
+ "failed status re-read with unknown outcome preserved",
+ "completed action after a recorded receipt"
+ ],
+ "interactions": [
+ "desktop pointer and phone keyboard Check for receipt",
+ "GET same action under pending, read error and completion; no POST",
+ "desktop and phone layout and horizontal-overflow checks"
+ ],
+ "command": "pulse-worker-browser frontend-modern/browser-tests/action-receipt-wait.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
+ "artifacts": [
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-browser.log",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-1365.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/pending-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/read-error-390.png",
+ "/var/lib/pulse-maintainer/worker-outputs/web-product-kp_huwb7/action-receipt-proof/completed-390.png"
+ ],
+ "notes": "Production dialog mounted with synthetic recent action data and mocked same-action GET. Both desktop and phone showed explicit receipt-pending/unknown outcome guidance, a non-mutating status refresh, a safe read error, and recorded completion; screenshots inspected. This is not an installed action or reporter retest."
+ },
{
"version": 1,
"base_sha": "498e2b12a22510520f9df7a510105d6775ea0522",
diff --git a/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx b/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx
new file mode 100644
index 000000000..bace802c0
--- /dev/null
+++ b/frontend-modern/src/features/actions/__tests__/Actions.requestOwnership.test.tsx
@@ -0,0 +1,174 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
+import { Route, Router } from '@solidjs/router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { ResourceActionsAPI } from '@/api/resourceActions';
+import { SecurityAPI } from '@/api/security';
+import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit';
+import Actions from '../../../pages/Actions';
+
+vi.mock('@/api/resourceActions', () => ({
+ ResourceActionsAPI: { listActions: vi.fn(), getAction: vi.fn() },
+}));
+vi.mock('@/api/security', () => ({ SecurityAPI: { getStatus: vi.fn() } }));
+vi.mock('@/api/patrol', () => ({ getPatrolAutonomySettings: vi.fn().mockResolvedValue({}) }));
+
+const deferred = () => {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+};
+
+const audit = (id: string, state: ActionAuditRecord['state'] = 'executing'): ActionAuditRecord => ({
+ id,
+ createdAt: '2026-09-29T18:00:00Z',
+ updatedAt: '2026-09-29T18:01:00Z',
+ state,
+ decisionRevision: 1,
+ request: {
+ requestId: `request-${id}`,
+ resourceId: `docker:container:${id}`,
+ capabilityName: 'update',
+ reason: `Update ${id}`,
+ requestedBy: 'operator',
+ },
+ plan: {
+ actionId: id,
+ requestId: `request-${id}`,
+ allowed: true,
+ requiresApproval: false,
+ approvalPolicy: 'none',
+ rollbackAvailable: false,
+ expiresAt: '2026-09-29T21:00:00Z',
+ planHash: `sha256:plan-${id}`,
+ },
+ verificationOutcome: { status: 'unknown' },
+});
+
+const detail = (id: string): ActionDetailResponse => ({
+ audit: audit(id),
+ events: [],
+ readiness: {
+ ready: false,
+ code: 'receipt_pending',
+ message: 'Awaiting receipt',
+ refreshable: false,
+ checkedAt: '2026-09-29T18:01:00Z',
+ },
+ attempt: {
+ id: `attempt-${id}`,
+ actionId: id,
+ state: 'receipt_pending',
+ createdAt: '2026-09-29T18:00:00Z',
+ updatedAt: '2026-09-29T18:01:00Z',
+ dispatchCount: 1,
+ },
+});
+
+const renderActions = () =>
+ render(() => (
+
+
+
+ ));
+
+beforeEach(() => {
+ window.history.replaceState(null, '', '/');
+ vi.mocked(ResourceActionsAPI.listActions).mockResolvedValue({
+ actions: [audit('edge-a'), audit('edge-b')],
+ count: 2,
+ view: 'pending',
+ readOnly: false,
+ });
+ vi.mocked(SecurityAPI.getStatus).mockResolvedValue({
+ hasAuthentication: true,
+ requiresAuth: true,
+ settingsCapabilities: { authenticationWrite: false },
+ } as Awaited>);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ window.history.replaceState(null, '', '/');
+});
+
+describe('Actions request ownership', () => {
+ it('keeps the newest selected action when an older detail response arrives last', async () => {
+ const first = deferred();
+ vi.mocked(ResourceActionsAPI.getAction).mockImplementation((id) =>
+ id === 'edge-a' ? first.promise : Promise.resolve(detail('edge-b')),
+ );
+ renderActions();
+ fireEvent.click(
+ await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
+ );
+ fireEvent.click(
+ screen.getByRole('button', { name: /Review Update on docker:container:edge-b/ }),
+ );
+ expect(await screen.findByText('docker:container:edge-b')).toBeVisible();
+
+ first.resolve(detail('edge-a'));
+ await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2));
+ expect(screen.getByRole('dialog')).toHaveTextContent('docker:container:edge-b');
+ expect(screen.getByRole('dialog')).not.toHaveTextContent('docker:container:edge-a');
+ expect(window.location.search).toBe('?action=edge-b');
+ });
+
+ it('does not reopen a closed review when a receipt re-read finishes late', async () => {
+ const read = deferred();
+ vi.mocked(ResourceActionsAPI.getAction)
+ .mockResolvedValueOnce(detail('edge-a'))
+ .mockReturnValueOnce(read.promise);
+ renderActions();
+ fireEvent.click(
+ await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
+ );
+ fireEvent.click(await screen.findByRole('button', { name: 'Check for receipt' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Close action review' }));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ await waitFor(() => expect(window.location.search).toBe(''));
+
+ read.resolve({ ...detail('edge-a'), audit: audit('edge-a', 'completed') });
+ await waitFor(() => expect(ResourceActionsAPI.getAction).toHaveBeenCalledTimes(2));
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ expect(window.location.search).toBe('');
+ });
+
+ it('refuses details returned for a different action id', async () => {
+ vi.mocked(ResourceActionsAPI.getAction).mockResolvedValue(detail('edge-b'));
+ renderActions();
+ fireEvent.click(
+ await screen.findByRole('button', { name: /Review Update on docker:container:edge-a/ }),
+ );
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'Action details did not match the selected action.',
+ );
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('does not show an old Open-list response under History', async () => {
+ const open = deferred>>();
+ vi.mocked(ResourceActionsAPI.listActions).mockImplementation((requestedView) =>
+ requestedView === 'pending'
+ ? open.promise
+ : Promise.resolve({
+ actions: [audit('finished', 'completed')],
+ count: 1,
+ view: 'settled',
+ readOnly: false,
+ }),
+ );
+ renderActions();
+ fireEvent.click(screen.getByRole('tab', { name: 'History' }));
+ expect(await screen.findByRole('button', { name: /docker:container:finished/ })).toBeVisible();
+
+ open.resolve({ actions: [audit('edge-a')], count: 1, view: 'pending', readOnly: false });
+ await waitFor(() => expect(ResourceActionsAPI.listActions).toHaveBeenCalledTimes(2));
+ expect(screen.getByRole('button', { name: /docker:container:finished/ })).toBeVisible();
+ expect(
+ screen.queryByRole('button', { name: /docker:container:edge-a/ }),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend-modern/src/pages/Actions.tsx b/frontend-modern/src/pages/Actions.tsx
index 7a85e93f9..068481a22 100644
--- a/frontend-modern/src/pages/Actions.tsx
+++ b/frontend-modern/src/pages/Actions.tsx
@@ -55,6 +55,8 @@ export function Actions() {
const [loadError, setLoadError] = createSignal('');
const [readOnly, setReadOnly] = createSignal(false);
const [patrolWatchOnly, setPatrolWatchOnly] = createSignal(false);
+ let listRequestGeneration = 0;
+ let detailRequestGeneration = 0;
// The Open tab is where users land wondering why nothing is queued. When
// Patrol runs Watch only it never proposes fixes, so the calm state must say
@@ -77,18 +79,22 @@ export function Actions() {
);
const loadActions = async () => {
+ const generation = ++listRequestGeneration;
+ const requestedView = view();
setLoading(true);
setLoadError('');
try {
- const response = await ResourceActionsAPI.listActions(view());
+ const response = await ResourceActionsAPI.listActions(requestedView);
+ if (generation !== listRequestGeneration) return;
setActions(response.actions);
setReadOnly(response.readOnly === true);
} catch (cause) {
+ if (generation !== listRequestGeneration) return;
setActions([]);
setReadOnly(false);
setLoadError(cause instanceof Error ? cause.message : 'The action store is unavailable.');
} finally {
- setLoading(false);
+ if (generation === listRequestGeneration) setLoading(false);
}
};
@@ -108,14 +114,26 @@ export function Actions() {
view() === 'pending' ? sortOpenActionsForReview(actions()) : actions(),
);
- const openActionById = async (actionId: string) => {
- setDetailError('');
+ const openActionById = async (actionId: string, generation: number) => {
try {
const detail = await ResourceActionsAPI.getAction(actionId);
+ if (
+ generation !== detailRequestGeneration ||
+ parseActionReviewId(location.search) !== actionId
+ )
+ return;
+ if (detail.audit.id !== actionId) {
+ setDetailError('Action details did not match the selected action. Open it again.');
+ return;
+ }
setSelected(detail);
setView(getInboxViewForState(detail.audit.state));
} catch (cause) {
- setSelected(null);
+ if (
+ generation !== detailRequestGeneration ||
+ parseActionReviewId(location.search) !== actionId
+ )
+ return;
setDetailError(cause instanceof Error ? cause.message : 'Action details are unavailable.');
}
};
@@ -125,6 +143,7 @@ export function Actions() {
};
const closeAction = () => {
+ ++detailRequestGeneration;
setSelected(null);
setDetailError('');
setSearchParams({ [ACTION_REVIEW_QUERY_PARAM]: null }, { replace: true });
@@ -137,7 +156,10 @@ export function Actions() {
createEffect(() => {
const actionId = parseActionReviewId(location.search);
- if (actionId) void openActionById(actionId);
+ const generation = ++detailRequestGeneration;
+ setSelected(null);
+ setDetailError('');
+ if (actionId) void openActionById(actionId, generation);
});
return (
@@ -330,9 +352,17 @@ export function Actions() {
detail={selected()}
onClose={closeAction}
onChanged={async (detail) => {
+ // A read or mutation started in an earlier review must not replace
+ // the action currently selected by the URL (or reopen a closed one).
+ if (
+ selected()?.audit.id !== detail.audit.id ||
+ parseActionReviewId(location.search) !== detail.audit.id
+ )
+ return;
setSelected(detail);
- setView(getInboxViewForState(detail.audit.state));
- await loadActions();
+ const nextView = getInboxViewForState(detail.audit.state);
+ if (view() === nextView) await loadActions();
+ else setView(nextView); // the view effect loads the new list once
}}
/>