fix(webui): keep import pages cache-aware

- keep the /import loader from turning transient staging fetch failures into route errors
- keep cached auto-import status and results visible during refetch failures
- show inline notices only when there is no stale data to fall back to
- add regression coverage for staging, status, and results failure paths
This commit is contained in:
Antti Kettunen 2026-05-24 15:57:42 +03:00
parent a65fbfd532
commit e2a760bd68
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
4 changed files with 97 additions and 17 deletions

View file

@ -9,6 +9,7 @@ import { createShellBridge } from '@/test/shell-bridge';
import type { ImportStagingFile } from './-import.types';
import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api';
import { resetImportWorkflowStore } from './-import.store';
function renderImportRoute(initialEntries = ['/import']) {
@ -19,6 +20,7 @@ function renderImportRoute(initialEntries = ['/import']) {
return {
history,
router,
queryClient,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
@ -209,6 +211,26 @@ describe('import route', () => {
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import');
});
it('keeps the import page rendering when staging files fail to load', async () => {
server.use(
http.get('/api/import/staging/files', () =>
HttpResponse.json(
{
success: false,
error: 'Import folder unavailable',
},
{ status: 500 },
),
),
);
renderImportRoute();
expect(await screen.findByTestId('import-page')).toBeInTheDocument();
expect(await screen.findByText('Import Music')).toBeInTheDocument();
expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
});
it('stores the active tab in nested route paths', async () => {
const { history } = renderImportRoute();
@ -318,4 +340,56 @@ describe('import route', () => {
false,
);
});
it('keeps cached auto-import status visible when a refetch fails', async () => {
const { queryClient } = renderImportRoute(['/import/auto']);
expect(await screen.findByText('Watching')).toBeInTheDocument();
server.use(
http.get('/api/auto-import/status', () =>
HttpResponse.json(
{
success: false,
error: 'Auto-import unavailable',
},
{ status: 500 },
),
),
);
await queryClient.refetchQueries({
queryKey: autoImportStatusQueryOptions().queryKey,
});
expect(screen.getByText('Watching')).toBeInTheDocument();
expect(screen.queryByText(/Auto-import is unavailable:/)).not.toBeInTheDocument();
});
it('keeps cached auto-import results visible when a refetch fails', async () => {
const { queryClient } = renderImportRoute(['/import/auto?autoFilter=pending']);
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
server.use(
http.get('/api/auto-import/results', () =>
HttpResponse.json(
{
success: false,
error: 'Auto-import results unavailable',
},
{ status: 500 },
),
),
);
await queryClient.refetchQueries({
queryKey: autoImportResultsQueryOptions().queryKey,
});
expect(screen.getByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
expect(screen.queryByText(/Failed to load imports:/)).not.toBeInTheDocument();
});
});

View file

@ -10,7 +10,7 @@ import {
Select,
Switch,
} from '@/components/form/form';
import { Badge } from '@/components/primitives';
import { Badge, Notice } from '@/components/primitives';
import type {
ImportAutoFilter,
@ -151,17 +151,18 @@ export function AutoImportPanel({
const counts = getAutoImportCounts(allResults);
const activeLines = getActiveImportLines(statusQuery.data);
const statusTone = getAutoImportStatusTone(statusQuery.data);
if (statusQuery.error) {
return (
<div className={styles.autoImportEmpty}>
Auto-import is unavailable: {getErrorMessage(statusQuery.error)}
</div>
);
}
const statusError =
statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
const resultsError =
resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
return (
<>
{statusError ? (
<Notice tone="danger" role="alert">
Auto-import is unavailable: {statusError}
</Notice>
) : null}
<div className={styles.autoImportControls}>
<div className={styles.autoImportToggleRow}>
<div className={styles.autoImportToggleLabel}>
@ -293,10 +294,10 @@ export function AutoImportPanel({
) : null}
<div className={styles.autoImportResults} id="auto-import-results">
{resultsQuery.error ? (
<div className={styles.autoImportEmpty}>
Failed to load imports: {getErrorMessage(resultsQuery.error)}
</div>
{resultsError ? (
<Notice tone="danger" role="alert">
Failed to load imports: {resultsError}
</Notice>
) : allResults.length === 0 ? (
<div className={styles.autoImportEmpty}>
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>

View file

@ -1,6 +1,6 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ImportQueueJob } from '../-import.types';
import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
import {
importStagingFilesQueryOptions,
@ -11,6 +11,8 @@ import {
import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers';
import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store';
const EMPTY_STAGING_FILES: ImportStagingFile[] = [];
export function useImportStaging() {
const queryClient = useQueryClient();
const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
@ -23,7 +25,8 @@ export function useImportStaging() {
clearFinishedJobs();
await invalidateImportStagingQueries(queryClient);
},
stagingFiles: stagingQuery.data?.files ?? [],
// Keep the empty fallback stable so staging-driven effects do not loop while loading.
stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES,
stagingPath: stagingQuery.data?.staging_path || 'Not configured',
stagingQuery,
};

View file

@ -13,8 +13,10 @@ export const Route = createFileRoute('/import')({
throw redirect({ href: getProfileHomePath(bridge), replace: true });
}
},
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(importStagingFilesQueryOptions());
loader: ({ context }) => {
// Warm the staging query if possible, but never block the route on a transient fetch
// failure. The page owns the in-place error state for that case.
void context.queryClient.prefetchQuery(importStagingFilesQueryOptions());
},
component: ImportPage,
});