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 type { ImportStagingFile } from './-import.types';
import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api';
import { resetImportWorkflowStore } from './-import.store'; import { resetImportWorkflowStore } from './-import.store';
function renderImportRoute(initialEntries = ['/import']) { function renderImportRoute(initialEntries = ['/import']) {
@ -19,6 +20,7 @@ function renderImportRoute(initialEntries = ['/import']) {
return { return {
history, history,
router, router,
queryClient,
...render(<AppRouterProvider router={router} queryClient={queryClient} />), ...render(<AppRouterProvider router={router} queryClient={queryClient} />),
}; };
} }
@ -209,6 +211,26 @@ describe('import route', () => {
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import'); 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 () => { it('stores the active tab in nested route paths', async () => {
const { history } = renderImportRoute(); const { history } = renderImportRoute();
@ -318,4 +340,56 @@ describe('import route', () => {
false, 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, Select,
Switch, Switch,
} from '@/components/form/form'; } from '@/components/form/form';
import { Badge } from '@/components/primitives'; import { Badge, Notice } from '@/components/primitives';
import type { import type {
ImportAutoFilter, ImportAutoFilter,
@ -151,17 +151,18 @@ export function AutoImportPanel({
const counts = getAutoImportCounts(allResults); const counts = getAutoImportCounts(allResults);
const activeLines = getActiveImportLines(statusQuery.data); const activeLines = getActiveImportLines(statusQuery.data);
const statusTone = getAutoImportStatusTone(statusQuery.data); const statusTone = getAutoImportStatusTone(statusQuery.data);
const statusError =
if (statusQuery.error) { statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
return ( const resultsError =
<div className={styles.autoImportEmpty}> resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
Auto-import is unavailable: {getErrorMessage(statusQuery.error)}
</div>
);
}
return ( return (
<> <>
{statusError ? (
<Notice tone="danger" role="alert">
Auto-import is unavailable: {statusError}
</Notice>
) : null}
<div className={styles.autoImportControls}> <div className={styles.autoImportControls}>
<div className={styles.autoImportToggleRow}> <div className={styles.autoImportToggleRow}>
<div className={styles.autoImportToggleLabel}> <div className={styles.autoImportToggleLabel}>
@ -293,10 +294,10 @@ export function AutoImportPanel({
) : null} ) : null}
<div className={styles.autoImportResults} id="auto-import-results"> <div className={styles.autoImportResults} id="auto-import-results">
{resultsQuery.error ? ( {resultsError ? (
<div className={styles.autoImportEmpty}> <Notice tone="danger" role="alert">
Failed to load imports: {getErrorMessage(resultsQuery.error)} Failed to load imports: {resultsError}
</div> </Notice>
) : allResults.length === 0 ? ( ) : allResults.length === 0 ? (
<div className={styles.autoImportEmpty}> <div className={styles.autoImportEmpty}>
<p>No imports yet. Drop album folders or single tracks into your import folder.</p> <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 { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ImportQueueJob } from '../-import.types'; import type { ImportQueueJob, ImportStagingFile } from '../-import.types';
import { import {
importStagingFilesQueryOptions, importStagingFilesQueryOptions,
@ -11,6 +11,8 @@ import {
import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers'; import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers';
import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store'; import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store';
const EMPTY_STAGING_FILES: ImportStagingFile[] = [];
export function useImportStaging() { export function useImportStaging() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs); const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
@ -23,7 +25,8 @@ export function useImportStaging() {
clearFinishedJobs(); clearFinishedJobs();
await invalidateImportStagingQueries(queryClient); 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', stagingPath: stagingQuery.data?.staging_path || 'Not configured',
stagingQuery, stagingQuery,
}; };

View file

@ -13,8 +13,10 @@ export const Route = createFileRoute('/import')({
throw redirect({ href: getProfileHomePath(bridge), replace: true }); throw redirect({ href: getProfileHomePath(bridge), replace: true });
} }
}, },
loader: async ({ context }) => { loader: ({ context }) => {
await context.queryClient.ensureQueryData(importStagingFilesQueryOptions()); // 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, component: ImportPage,
}); });