fix(ui): render a retryable error on a detail load failure, not "not found"

The template and knowledge detail pages dropped `error` from their query and
inferred "this record does not exist" from the absence of data. A real load
failure — a network drop, a 5xx, a cold-cache backend error on a deep link —
therefore rendered "Template not found" / bounced to the list with a toast,
offering no way back in short of retyping the URL. Only a genuine 404 should
do that; a transient failure should keep the user on the route behind Retry.

Both now split the two outcomes the way flow already does: a real error →
in-page ErrorState + Retry; a settled-empty result or a not-found error →
the existing redirect/not-found card. The `no rows`/`not found` predicate that
flow-provider kept privately becomes the shared `lib/errors.ts#isNotFoundError`
now that three call sites need it, and flow-provider moves onto it.

Proven by a runtime repro, not by reading: knowledge.test.tsx asserts the
in-page error + no redirect on a real failure and the redirect on a genuine
not-found — reverting the fix drops it to a failure. errors.test.ts pins the
predicate's two sides. e2e repros on both detail routes drive it through the
production bundle for CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-23 00:21:33 +07:00
co-authored by Claude Opus 4.8
parent f95c48b062
commit 28ab3d2e61
8 changed files with 208 additions and 13 deletions
@@ -122,4 +122,21 @@ test.describe('knowledges crud', { tag: '@crud' }, () => {
expectCleanPage(pageErrorLog);
});
});
// A real load failure on the detail route used to be mislabelled "not found" and bounced to
// the list with no way back in; it must keep the user on the route behind Retry instead.
test.describe('detail load failure', () => {
test.use({
cassette: knowledgesCassette({
queries: { knowledgeDocument: [{ errors: [{ message: 'e2e induced load failure' }] }] },
}),
});
test('shows an in-page error with Retry, not a bounce to the list', async ({ page }) => {
await page.goto(`/knowledges/${KNOWLEDGE_DOC.id}`);
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/knowledges/${KNOWLEDGE_DOC.id}$`));
});
});
});
@@ -66,4 +66,22 @@ test.describe('template detail', { tag: '@coverage' }, () => {
expect(raw).toContain('E2E-MARK');
expectCleanPage(pageErrorLog);
});
// A real load failure must offer Retry in place, not the "Template not found" card that a
// genuine 404 shows — the two used to collapse into the same dead-end.
test.describe('load failure', () => {
test.use({
cassette: templateDetailCassette({
queries: { flowTemplate: [{ errors: [{ message: 'e2e induced load failure' }] }] },
}),
});
test('shows an in-page error with Retry, not "not found"', async ({ page }) => {
await page.goto(`/templates/${TEMPLATE_DETAIL.id}`);
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
await expect(page.getByText('Template not found')).toBeHidden();
await expect(page).toHaveURL(new RegExp(`/templates/${TEMPLATE_DETAIL.id}$`));
});
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { isNotFoundError } from './errors';
describe('isNotFoundError', () => {
// The two shapes the backend uses for a genuinely missing record — these redirect to the list.
it.each(['no rows in result set', 'flow not found', 'Record Not Found'])('treats %j as not-found', (message) => {
expect(isNotFoundError(new Error(message))).toBe(true);
});
// Everything else is a real load failure — the detail page must keep the user behind Retry,
// not bounce them, so these must NOT read as not-found.
it.each(['network error', 'Failed to fetch', 'connection refused', 'internal server error', 'permission denied'])(
'treats %j as a real failure',
(message) => {
expect(isNotFoundError(new Error(message))).toBe(false);
},
);
});
+7
View File
@@ -0,0 +1,7 @@
/**
* A backend "no rows in result set" / "not found" GraphQL error, as opposed to a real load
* failure (network, 5xx, cold-cache backend error). Detail pages redirect to the list on the
* former and render an in-page ErrorState + Retry on the latter — collapsing the two silently
* bounces the user off a page that a retry would have loaded.
*/
export const isNotFoundError = (error: { message: string }) => /no rows in result set|not found/i.test(error.message);
@@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { routes } from '@/lib/routes';
const { navigate } = vi.hoisted(() => ({ navigate: vi.fn() }));
const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
const queryResult = vi.hoisted(() => ({
current: { data: undefined, error: undefined, loading: false, refetch: vi.fn() } as Record<string, unknown>,
}));
vi.mock('@apollo/client/react', () => ({
skipToken: Symbol('skipToken'),
useQuery: () => queryResult.current,
}));
vi.mock('react-router-dom', () => ({
useNavigate: () => navigate,
useParams: () => ({ knowledgeId: '7' }),
}));
vi.mock('sonner', () => ({ toast: { error: toastError } }));
vi.mock('@/providers/knowledges-provider', () => ({
useKnowledges: () => ({ createKnowledge: vi.fn(), updateKnowledge: vi.fn() }),
}));
// The layout shell and the form drag in the header/editor tree; the branching under test sits
// above them in Knowledge itself, so stub them to a marker.
vi.mock('@/features/knowledges/knowledge-layout', () => ({
KnowledgeLayout: ({ children }: { children: React.ReactNode }) => <div data-testid="layout">{children}</div>,
}));
vi.mock('@/features/knowledges/knowledge-form', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
KnowledgeForm: () => <div data-testid="form" />,
}));
const { default: Knowledge } = await import('./knowledge');
describe('Knowledge detail load states', () => {
beforeEach(() => {
navigate.mockClear();
toastError.mockClear();
});
it('renders an in-page error with Retry on a real load failure, without redirecting', () => {
queryResult.current = { data: undefined, error: new Error('network down'), loading: false, refetch: vi.fn() };
render(<Knowledge />);
expect(screen.getByText('Error loading knowledge document')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Try again/ })).toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
it('redirects to the list on a genuine not-found error', () => {
queryResult.current = {
data: undefined,
error: new Error('no rows in result set'),
loading: false,
refetch: vi.fn(),
};
render(<Knowledge />);
expect(screen.queryByText('Error loading knowledge document')).not.toBeInTheDocument();
expect(toastError).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith(routes.knowledges, { replace: true });
});
it('redirects to the list when the query resolves with no document', () => {
queryResult.current = { data: { knowledgeDocument: null }, error: undefined, loading: false, refetch: vi.fn() };
render(<Knowledge />);
expect(navigate).toHaveBeenCalledWith(routes.knowledges, { replace: true });
});
});
+36 -3
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { ErrorState } from '@/components/shared/error-state';
import { Spinner } from '@/components/ui/spinner';
import {
type DirtyFlags,
@@ -16,6 +17,7 @@ import {
} from '@/features/knowledges/knowledge-form';
import { KnowledgeLayout } from '@/features/knowledges/knowledge-layout';
import { KnowledgeDocumentDocument } from '@/graphql/types';
import { isNotFoundError } from '@/lib/errors';
import { routes } from '@/lib/routes';
import { useKnowledges } from '@/providers/knowledges-provider';
@@ -27,19 +29,33 @@ function Knowledge() {
const isNew = knowledgeId === 'new';
const shouldFetch = Boolean(knowledgeId) && !isNew;
const { data, loading: isLoadingKnowledge } = useQuery(
const {
data,
error,
loading: isLoadingKnowledge,
refetch,
} = useQuery(
KnowledgeDocumentDocument,
shouldFetch && knowledgeId ? { variables: { id: knowledgeId } } : skipToken,
);
const knowledge = data?.knowledgeDocument ?? null;
// A real load failure that left nothing to show, as opposed to a genuine not-found: the page
// renders it as an in-page ErrorState + Retry instead of bouncing to the list. Mirrors flow.
const loadError = error && !knowledge && !isNotFoundError(error) ? error : undefined;
useEffect(() => {
if (!isNew && !isLoadingKnowledge && !knowledge) {
// Redirect only when the document is genuinely gone (query settled with no document, or a
// not-found error) — never on a transient load failure, which Retry can recover.
if (isNew || isLoadingKnowledge || loadError) {
return;
}
if (!knowledge) {
toast.error('Knowledge document not found');
navigate(routes.knowledges, { replace: true });
}
}, [isNew, isLoadingKnowledge, knowledge, navigate]);
}, [isNew, isLoadingKnowledge, knowledge, loadError, navigate]);
const initialValues = useMemo<FormValues>(
() => (knowledge ? documentToFormValues(knowledge) : newDocumentDefaults),
@@ -73,6 +89,23 @@ function Knowledge() {
[createKnowledge, isNew, knowledgeId, updateKnowledge],
);
if (loadError) {
return (
<KnowledgeLayout
isNew={false}
knowledge={null}
>
<div className="flex flex-1 flex-col gap-4 p-4">
<ErrorState
message={loadError.message}
onRetry={() => refetch()}
title="Error loading knowledge document"
/>
</div>
</KnowledgeLayout>
);
}
if (!isNew && !knowledge) {
return (
<KnowledgeLayout
+29 -5
View File
@@ -15,6 +15,7 @@ import {
DetailNavigationToolbar,
} from '@/components/shared/detail-navigation';
import { DetailSplitLayout } from '@/components/shared/detail-split-layout';
import { ErrorState } from '@/components/shared/error-state';
import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit';
import { type EditorViewMode, EditorViewModeToggle, MarkdownEditorField } from '@/components/shared/markdown-editor';
import { UnsavedChangesDialog, useUnsavedChangesGuard } from '@/components/shared/unsaved-changes';
@@ -39,6 +40,7 @@ import { useTemplateDetailNavigation } from '@/features/templates/use-template-d
import { FlowTemplateDocument } from '@/graphql/types';
import { useAppForm } from '@/hooks/use-app-form';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { isNotFoundError } from '@/lib/errors';
import { routes } from '@/lib/routes';
import { cn } from '@/lib/utils';
import { type Template, useTemplates } from '@/providers/templates-provider';
@@ -261,10 +263,17 @@ function Template() {
stopEdit: handleTemplateRenameCancel,
} = useInlineEdit({ resetKey: templateId });
const { data: templateData, loading: isLoadingTemplate } = useQuery(
FlowTemplateDocument,
templateId && !isNew ? { variables: { templateId } } : skipToken,
);
const {
data: templateData,
error: templateError,
loading: isLoadingTemplate,
refetch: refetchTemplate,
} = useQuery(FlowTemplateDocument, templateId && !isNew ? { variables: { templateId } } : skipToken);
const template = templateData?.flowTemplate;
// A real load failure that left nothing to show, as opposed to a genuine not-found: the page
// renders it as an in-page ErrorState + Retry instead of the "not found" card. Mirrors flow.
const templateLoadError = templateError && !template && !isNotFoundError(templateError) ? templateError : undefined;
// `values` re-syncs the form whenever the cache refreshes (an inline rename, a refetch), while
// `keepDirtyValues` preserves the user's in-flight edits — without it an external re-emit would
@@ -736,7 +745,22 @@ function Template() {
);
}
if (!isNew && !isLoadingTemplate && !templateData?.flowTemplate) {
if (templateLoadError) {
return (
<div className={isDesktop ? 'flex h-[100dvh] min-h-0 flex-col' : 'flex min-h-[100dvh] flex-col'}>
{pageHeader}
<div className="flex flex-1 flex-col gap-4 p-4">
<ErrorState
message={templateLoadError.message}
onRetry={() => refetchTemplate()}
title="Error loading template"
/>
</div>
</div>
);
}
if (!isNew && !isLoadingTemplate && !template) {
return (
<div className={isDesktop ? 'flex h-[100dvh] min-h-0 flex-col' : 'flex min-h-[100dvh] flex-col'}>
{pageHeader}
+4 -5
View File
@@ -34,10 +34,9 @@ import {
TerminalLogAddedDocument,
VectorStoreLogAddedDocument,
} from '@/graphql/types';
import { isNotFoundError } from '@/lib/errors';
import { Log } from '@/lib/log';
const isFlowNotFoundError = (error: Error) => /no rows in result set|not found/i.test(error.message);
interface FlowContextValue {
assistantLogs: Array<AssistantLogFragmentFragment>;
assistants: Array<AssistantFragmentFragment>;
@@ -92,9 +91,9 @@ export function FlowProvider({ children }: FlowProviderProps) {
// A real load failure that left nothing to show (cold cache + backend error on a
// deep link), as opposed to a genuine not-found. The detail page renders this as an
// in-page ErrorState + Retry instead of silently bouncing to the list.
const flowLoadError = flowError && !flowData?.flow && !isFlowNotFoundError(flowError) ? flowError : undefined;
const flowLoadError = flowError && !flowData?.flow && !isNotFoundError(flowError) ? flowError : undefined;
const isFlowMissing = Boolean(flowData && !flowData.flow) || Boolean(flowError && isFlowNotFoundError(flowError));
const isFlowMissing = Boolean(flowData && !flowData.flow) || Boolean(flowError && isNotFoundError(flowError));
const { data: assistantsData, loading: isAssistantsLoading } = useQuery(AssistantsDocument, {
fetchPolicy: 'cache-first',
@@ -196,7 +195,7 @@ export function FlowProvider({ children }: FlowProviderProps) {
return;
}
if (isFlowNotFoundError(flowError)) {
if (isNotFoundError(flowError)) {
toast.error('Flow not found', { id: 'flow-load-error' });
}