Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/components/EntityResourceTabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright 2026 bburda
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { EntityResourceTabs } from './EntityResourceTabs';
import type { ComponentTopic, Operation, Fault } from '@/lib/types';

// ---- store mock ----

const mockFetchEntityData = vi.fn();
const mockFetchEntityOperations = vi.fn();
const mockFetchConfigurations = vi.fn();
const mockListEntityFaults = vi.fn();
const mockSelectEntity = vi.fn();

vi.mock('@/lib/store', () => ({
useAppStore: vi.fn((selector: (s: Record<string, unknown>) => unknown) =>
selector({
selectEntity: mockSelectEntity,
fetchEntityData: mockFetchEntityData,
fetchEntityOperations: mockFetchEntityOperations,
fetchConfigurations: mockFetchConfigurations,
listEntityFaults: mockListEntityFaults,
configurations: new Map(),
})
),
}));

// ---- helpers ----

function sampleTopics(): ComponentTopic[] {
return [
{
topic: '/engine/temperature',
timestamp: Date.now(),
data: null,
status: 'metadata_only',
type: 'sensor_msgs/msg/Temperature',
},
];
}

// ---- tests ----

describe('EntityResourceTabs', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchEntityData.mockResolvedValue([] as ComponentTopic[]);
mockFetchEntityOperations.mockResolvedValue([] as Operation[]);
mockFetchConfigurations.mockResolvedValue(undefined);
mockListEntityFaults.mockResolvedValue({ items: [] as Fault[], count: 0 });
});

it('fetches and displays data items on first render', async () => {
mockFetchEntityData.mockResolvedValue(sampleTopics());

render(<EntityResourceTabs entityId="ecu-primary" entityType="components" />);

await waitFor(() => {
expect(mockFetchEntityData).toHaveBeenCalledWith('components', 'ecu-primary', expect.anything());
});

await waitFor(() => {
expect(screen.getByText('/engine/temperature')).toBeInTheDocument();
});
});

it('re-fetches data when entityId changes (loadedTabs ref race)', async () => {
mockFetchEntityData.mockResolvedValue(sampleTopics());

const { rerender } = render(<EntityResourceTabs entityId="ecu-primary" entityType="components" />);

// Wait for first fetch to complete
await waitFor(() => {
expect(screen.getByText('/engine/temperature')).toBeInTheDocument();
});

// Switch to a different entity - this is the scenario that was broken:
// the ref still had { data: true } from the first entity, so the load
// effect returned early and data stayed empty.
const secondTopics: ComponentTopic[] = [
{
topic: '/brake/pressure',
timestamp: Date.now(),
data: null,
status: 'metadata_only',
type: 'sensor_msgs/msg/FluidPressure',
},
];
mockFetchEntityData.mockResolvedValue(secondTopics);

rerender(<EntityResourceTabs entityId="ecu-mcu" entityType="components" />);

await waitFor(() => {
expect(mockFetchEntityData).toHaveBeenCalledWith('components', 'ecu-mcu', expect.anything());
});

await waitFor(() => {
expect(screen.getByText('/brake/pressure')).toBeInTheDocument();
});

// Old data should be gone
expect(screen.queryByText('/engine/temperature')).not.toBeInTheDocument();
});

it('does not apply stale fetch result when entity changes mid-flight', async () => {
// First fetch returns a promise we control, so we can switch entities
// while it is still in-flight and verify the old result is discarded.
let resolveFirst: (value: ComponentTopic[]) => void = () => {};
const firstPromise = new Promise<ComponentTopic[]>((resolve) => {
resolveFirst = resolve;
});
mockFetchEntityData.mockReturnValueOnce(firstPromise);

const { rerender } = render(<EntityResourceTabs entityId="ecu-primary" entityType="components" />);

// Switch entity while the first fetch is still pending
const secondTopics: ComponentTopic[] = [
{
topic: '/brake/pressure',
timestamp: Date.now(),
data: null,
status: 'metadata_only',
type: 'sensor_msgs/msg/FluidPressure',
},
];
mockFetchEntityData.mockResolvedValueOnce(secondTopics);
rerender(<EntityResourceTabs entityId="ecu-mcu" entityType="components" />);

// New entity's fetch resolves and renders
await waitFor(() => {
expect(screen.getByText('/brake/pressure')).toBeInTheDocument();
});

// Late-resolve the first (aborted) fetch - it must NOT overwrite the
// current entity's data.
resolveFirst(sampleTopics());
// Give the microtask queue a chance to run the (hopefully discarded) setData.
await Promise.resolve();
await Promise.resolve();

expect(screen.queryByText('/engine/temperature')).not.toBeInTheDocument();
expect(screen.getByText('/brake/pressure')).toBeInTheDocument();
});
});
48 changes: 42 additions & 6 deletions src/components/EntityResourceTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,34 +65,47 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate
}))
);

// AbortController for in-flight fetches. Aborted (and replaced) whenever
// the entity changes so stale responses never overwrite fresh ones.
const abortRef = useRef<AbortController | null>(null);

// Lazy load resources for the active tab
const loadTabResources = useCallback(
async (tab: ResourceTabId) => {
if (loadedTabsRef.current[tab]) return;

const signal = abortRef.current?.signal;
setIsLoading(true);
try {
switch (tab) {
case 'data': {
const dataRes = await fetchEntityData(entityType, entityId).catch(() => [] as ComponentTopic[]);
const dataRes = await fetchEntityData(entityType, entityId, signal).catch(
() => [] as ComponentTopic[]
);
if (signal?.aborted) return;
setData(dataRes);
break;
}
case 'operations': {
const opsRes = await fetchEntityOperations(entityType, entityId).catch(() => [] as Operation[]);
const opsRes = await fetchEntityOperations(entityType, entityId, signal).catch(
() => [] as Operation[]
);
if (signal?.aborted) return;
setOperations(opsRes);
break;
}
case 'configurations': {
await fetchConfigurations(entityId, entityType);
await fetchConfigurations(entityId, entityType, signal);
if (signal?.aborted) return;
// Configurations are stored in the store's configurations map
break;
}
case 'faults': {
const faultsRes = await listEntityFaults(entityType, entityId).catch(() => ({
const faultsRes = await listEntityFaults(entityType, entityId, signal).catch(() => ({
items: [] as Fault[],
count: 0,
}));
if (signal?.aborted) return;
setFaults(faultsRes.items || []);
break;
}
Expand All @@ -101,11 +114,13 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate
break;
}
}
if (signal?.aborted) return;
setLoadedTabs((prev) => ({ ...prev, [tab]: true }));
} catch (error) {
if (signal?.aborted) return;
console.error(`Failed to load ${tab} resources:`, error);
} finally {
setIsLoading(false);
if (!signal?.aborted) setIsLoading(false);
}
},

Expand All @@ -115,13 +130,34 @@ export function EntityResourceTabs({ entityId, entityType, basePath, onNavigate
// Reset tab state when the entity changes so stale data from the
// previous entity does not leak into the new one.
useEffect(() => {
// Abort any fetches still running for the previous entity. The
// next load effect will spin up a fresh controller.
abortRef.current?.abort();
abortRef.current = new AbortController();

const reset: LoadedResources = {
data: false,
operations: false,
configurations: false,
faults: false,
logs: false,
};
setActiveTab('data');
setLoadedTabs({ data: false, operations: false, configurations: false, faults: false, logs: false });
setLoadedTabs(reset);
// Synchronously update the ref so the load effect (which fires in
// the same commit) sees the cleared flags instead of stale `true`
Comment thread
bburda marked this conversation as resolved.
// values from the previous entity.
loadedTabsRef.current = reset;
setData([]);
setOperations([]);
setFaults([]);
}, [entityId, entityType]);

// Abort in-flight fetches on unmount.
useEffect(() => {
return () => abortRef.current?.abort();
}, []);

// Load resources when tab changes
useEffect(() => {
loadTabResources(activeTab);
Expand Down
50 changes: 50 additions & 0 deletions src/components/UpdatesDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,54 @@ describe('UpdatesDashboard', () => {

expect(toast.error).toHaveBeenCalled();
});

it('passes an AbortSignal to mutation actions', async () => {
const user = userEvent.setup();
mockTriggerPrepare.mockResolvedValue(undefined);
mockUseUpdatesPolling.mockReturnValue(makeResult({ updates: [makeEntry('fw-v2', 'pending')] }));

render(<UpdatesDashboard />);

const prepareBtn = screen.getByRole('button', { name: /prepare/i });
await user.click(prepareBtn);

// triggerPrepare is called with (baseUrl, id, data, signal) - assert signal is an AbortSignal
const call = mockTriggerPrepare.mock.calls[0]!;
const signal = call[3];
expect(signal).toBeInstanceOf(AbortSignal);
});

it('aborts in-flight mutations on unmount', async () => {
const user = userEvent.setup();
let resolvePrepare: () => void = () => {};
mockTriggerPrepare.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolvePrepare = resolve;
})
);
mockUseUpdatesPolling.mockReturnValue(makeResult({ updates: [makeEntry('fw-v2', 'pending')] }));
const { toast } = await import('react-toastify');
vi.mocked(toast.success).mockClear();

const { unmount } = render(<UpdatesDashboard />);

const prepareBtn = screen.getByRole('button', { name: /prepare/i });
await user.click(prepareBtn);

// Capture the signal before unmount
const signal = mockTriggerPrepare.mock.calls[0]![3] as AbortSignal;
expect(signal.aborted).toBe(false);

unmount();

expect(signal.aborted).toBe(true);

// Late-resolve the prepare call - must NOT fire a success toast on
// the unmounted component.
resolvePrepare();
await new Promise((r) => setTimeout(r, 0));

expect(toast.success).not.toHaveBeenCalled();
});
});
35 changes: 25 additions & 10 deletions src/components/UpdatesDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/shallow';
import { Package, RefreshCw, AlertTriangle, Server } from 'lucide-react';
import { toast } from 'react-toastify';
Expand All @@ -39,6 +39,16 @@ export function UpdatesDashboard() {
const { updates, isLoading, error, notAvailable, refresh } = useUpdatesPolling(baseUrl);
const [busyIds, setBusyIds] = useState<Set<string>>(new Set());

// AbortController for mutation actions (prepare/execute/automated/delete).
// Aborted on unmount so in-flight requests don't resolve into setBusyIds
// on an unmounted component or issue stale toast notifications.
const actionAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
const controller = new AbortController();
actionAbortRef.current = controller;
return () => controller.abort();
}, []);

const summary = useMemo(() => {
let active = 0;
let failed = 0;
Expand All @@ -59,22 +69,27 @@ export function UpdatesDashboard() {
const confirmed = window.confirm(`Delete update "${id}"? This cannot be undone.`);
if (!confirmed) return;
}
const signal = actionAbortRef.current?.signal;
setBusyIds((prev) => new Set(prev).add(id));
try {
if (action === 'prepare') await triggerPrepare(baseUrl, id);
else if (action === 'execute') await triggerExecute(baseUrl, id);
else if (action === 'automated') await triggerAutomated(baseUrl, id);
else if (action === 'delete') await deleteUpdate(baseUrl, id);
if (action === 'prepare') await triggerPrepare(baseUrl, id, undefined, signal);
else if (action === 'execute') await triggerExecute(baseUrl, id, undefined, signal);
else if (action === 'automated') await triggerAutomated(baseUrl, id, undefined, signal);
else if (action === 'delete') await deleteUpdate(baseUrl, id, signal);
if (signal?.aborted) return;
toast.success(`${action} triggered for ${id}`);
refresh();
} catch (err) {
if (signal?.aborted || (err as { name?: string })?.name === 'AbortError') return;
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setBusyIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
if (!signal?.aborted) {
setBusyIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
}
},
[baseUrl, refresh]
Expand Down
Loading
Loading